From 779a8ba322e75f9693281be2de60e614b8ed46c3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 16:33:07 +0200 Subject: [PATCH] feat(shared-stores): add createArchiveOps factory + unify archive pattern Standardize two-stage deletion across modules: archive (isArchived) + soft-delete (deletedAt). Add shared factory, Archivable type mixin, filterActive/filterArchived/filterNotDeleted query helpers. 13 tests. Migrate memoro, chat, picture, contacts to use createArchiveOps. Standardize picture from archivedAt timestamp to isArchived boolean. Picture toggleFavorite now uses shared toggleField (1 param, not 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/database.ts | 2 +- .../chat/stores/conversations.svelte.ts | 26 ++-- .../contacts/stores/contacts.svelte.ts | 14 +- .../lib/modules/memoro/stores/memos.svelte.ts | 29 ++-- .../src/lib/modules/picture/AppView.svelte | 2 +- .../web/src/lib/modules/picture/queries.ts | 8 +- .../modules/picture/stores/images.svelte.ts | 71 ++------- .../apps/web/src/lib/modules/picture/types.ts | 4 +- .../web/src/routes/(app)/picture/+page.svelte | 2 +- packages/shared-stores/src/archive.test.ts | 144 ++++++++++++++++++ packages/shared-stores/src/archive.ts | 124 +++++++++++++++ packages/shared-stores/src/index.ts | 10 ++ 12 files changed, 324 insertions(+), 112 deletions(-) create mode 100644 packages/shared-stores/src/archive.test.ts create mode 100644 packages/shared-stores/src/archive.ts diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index 7ba65c73e..476e0d415 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -46,7 +46,7 @@ db.version(1).stores({ conversationTags: 'id, conversationId, tagId, [conversationId+tagId]', // ─── Picture (appId: 'picture') ─── - images: 'id, isFavorite, isPublic, archivedAt, prompt', + images: 'id, isFavorite, isPublic, isArchived, prompt', boards: 'id, isPublic', boardItems: 'id, boardId, itemType, zIndex, [boardId+zIndex]', imageTags: 'id, imageId, tagId, [imageId+tagId]', // junction to globalTags diff --git a/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts b/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts index f44be5b60..49c6b8ae9 100644 --- a/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts +++ b/apps/manacore/apps/web/src/lib/modules/chat/stores/conversations.svelte.ts @@ -7,8 +7,14 @@ import { conversationTable, messageTable } from '../collections'; import { toConversation } from '../queries'; +import { createArchiveOps } from '@manacore/shared-stores'; import type { LocalConversation } from '../types'; +/** Archive/soft-delete ops for conversations. */ +export const conversationArchive = createArchiveOps({ + table: () => conversationTable, +}); + export const conversationsStore = { /** Create a new conversation. */ async create(data: { @@ -50,21 +56,9 @@ export const conversationsStore = { }); }, - /** Archive a conversation. */ - async archive(id: string) { - await conversationTable.update(id, { - isArchived: true, - updatedAt: new Date().toISOString(), - }); - }, - - /** Unarchive a conversation. */ - async unarchive(id: string) { - await conversationTable.update(id, { - isArchived: false, - updatedAt: new Date().toISOString(), - }); - }, + // Archive ops (delegated to shared factory) + archive: (id: string) => conversationArchive.archive(id), + unarchive: (id: string) => conversationArchive.unarchive(id), /** Pin a conversation. */ async pin(id: string) { @@ -86,7 +80,7 @@ export const conversationsStore = { async delete(id: string) { const now = new Date().toISOString(); await conversationTable.update(id, { deletedAt: now, updatedAt: now }); - // Soft-delete all messages for this conversation + // Cascade soft-delete to messages const msgs = await messageTable.where('conversationId').equals(id).toArray(); for (const msg of msgs) { await messageTable.update(msg.id, { deletedAt: now, updatedAt: now }); diff --git a/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts b/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts index 8b1b1cfed..3699f7b50 100644 --- a/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts +++ b/apps/manacore/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts @@ -7,8 +7,12 @@ import { contactTable } from '../collections'; import { toContact } from '../queries'; +import { createArchiveOps } from '@manacore/shared-stores'; import type { LocalContact, Contact } from '../types'; +/** Archive/soft-delete ops for contacts. */ +export const contactArchive = createArchiveOps({ table: () => contactTable }); + export const contactsStore = { async createContact(data: Partial & Record) { const newLocal: LocalContact = { @@ -86,13 +90,5 @@ export const contactsStore = { }); }, - async toggleArchive(id: string) { - const local = await contactTable.get(id); - if (!local) return; - - await contactTable.update(id, { - isArchived: !local.isArchived, - updatedAt: new Date().toISOString(), - }); - }, + toggleArchive: (id: string) => contactArchive.toggleArchive(id), }; diff --git a/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts b/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts index b6fb0b263..1df1cae1c 100644 --- a/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts +++ b/apps/manacore/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts @@ -7,8 +7,14 @@ import { memoTable } from '../collections'; import { toMemo } from '../queries'; +import { createArchiveOps } from '@manacore/shared-stores'; import type { LocalMemo } from '../types'; +/** Archive/soft-delete ops for memos. */ +export const memoArchive = createArchiveOps({ + table: () => memoTable, +}); + export const memosStore = { /** Create a new memo (e.g., after recording). */ async create(data: { @@ -45,21 +51,9 @@ export const memosStore = { }); }, - /** Archive a memo. */ - async archive(id: string) { - await memoTable.update(id, { - isArchived: true, - updatedAt: new Date().toISOString(), - }); - }, - - /** Unarchive a memo. */ - async unarchive(id: string) { - await memoTable.update(id, { - isArchived: false, - updatedAt: new Date().toISOString(), - }); - }, + // Archive ops (delegated to shared factory) + archive: (id: string) => memoArchive.archive(id), + unarchive: (id: string) => memoArchive.unarchive(id), /** Pin a memo. */ async pin(id: string) { @@ -78,8 +72,5 @@ export const memosStore = { }, /** Soft-delete a memo. */ - async delete(id: string) { - const now = new Date().toISOString(); - await memoTable.update(id, { deletedAt: now, updatedAt: now }); - }, + delete: (id: string) => memoArchive.softDelete(id), }; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/AppView.svelte b/apps/manacore/apps/web/src/lib/modules/picture/AppView.svelte index 050429e28..5ab537a5e 100644 --- a/apps/manacore/apps/web/src/lib/modules/picture/AppView.svelte +++ b/apps/manacore/apps/web/src/lib/modules/picture/AppView.svelte @@ -12,7 +12,7 @@ $effect(() => { const sub = liveQuery(async () => { const all = await db.table('images').toArray(); - return all.filter((i) => !i.deletedAt && !i.archivedAt); + return all.filter((i) => !i.deletedAt && !i.isArchived); }).subscribe((val) => { images = val ?? []; }); diff --git a/apps/manacore/apps/web/src/lib/modules/picture/queries.ts b/apps/manacore/apps/web/src/lib/modules/picture/queries.ts index b3085fe09..591a9c8b6 100644 --- a/apps/manacore/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/manacore/apps/web/src/lib/modules/picture/queries.ts @@ -40,7 +40,7 @@ export function toImage(local: LocalImage): Image { isFavorite: local.isFavorite, downloadCount: local.downloadCount, rating: local.rating ?? undefined, - archivedAt: local.archivedAt ?? undefined, + isArchived: local.isArchived ?? undefined, generationId: local.generationId ?? undefined, sourceImageId: local.sourceImageId ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), @@ -70,7 +70,7 @@ export function useAllImages() { return useLiveQueryWithDefault(async () => { const locals = await db.table('images').toArray(); return locals - .filter((img) => !img.archivedAt && !img.deletedAt) + .filter((img) => !img.isArchived && !img.deletedAt) .map(toImage) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }, [] as Image[]); @@ -81,7 +81,7 @@ export function useArchivedImages() { return useLiveQueryWithDefault(async () => { const locals = await db.table('images').toArray(); return locals - .filter((img) => !!img.archivedAt && !img.deletedAt) + .filter((img) => !!img.isArchived && !img.deletedAt) .map(toImage) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }, [] as Image[]); @@ -128,7 +128,7 @@ export function allImages$() { return liveQuery(async () => { const locals = await db.table('images').toArray(); return locals - .filter((img) => !img.archivedAt && !img.deletedAt) + .filter((img) => !img.isArchived && !img.deletedAt) .map(toImage) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }); diff --git a/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts b/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts index 189703eae..59368ea9b 100644 --- a/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts +++ b/apps/manacore/apps/web/src/lib/modules/picture/stores/images.svelte.ts @@ -7,8 +7,13 @@ */ import { db } from '$lib/data/database'; +import { createArchiveOps, toggleField } from '@manacore/shared-stores'; import type { LocalImage } from '../types'; -import { toImage } from '../queries'; + +const imageTable = () => db.table('images'); + +/** Archive/soft-delete ops for images. */ +export const imageArchive = createArchiveOps({ table: imageTable }); let error = $state(null); let selectedImageId = $state(null); @@ -33,16 +38,10 @@ export const imagesStore = { showFavoritesOnly = !showFavoritesOnly; }, - /** - * Toggle favorite status of an image. - */ - async toggleFavorite(id: string, currentIsFavorite: boolean) { + async toggleFavorite(id: string) { error = null; try { - await db.table('images').update(id, { - isFavorite: !currentIsFavorite, - updatedAt: new Date().toISOString(), - }); + await toggleField(imageTable(), id, 'isFavorite'); return { success: true }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to toggle favorite'; @@ -50,54 +49,8 @@ export const imagesStore = { } }, - /** - * Archive an image. - */ - async archiveImage(id: string) { - error = null; - try { - await db.table('images').update(id, { - archivedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - return { success: true }; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to archive image'; - return { success: false, error }; - } - }, - - /** - * Restore an archived image. - */ - async restoreImage(id: string) { - error = null; - try { - await db.table('images').update(id, { - archivedAt: null, - updatedAt: new Date().toISOString(), - }); - return { success: true }; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to restore image'; - return { success: false, error }; - } - }, - - /** - * Delete an image -- soft-deletes from IndexedDB instantly. - */ - async deleteImage(id: string) { - error = null; - try { - await db.table('images').update(id, { - deletedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - return { success: true }; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to delete image'; - return { success: false, error }; - } - }, + // Archive ops (delegated to shared factory) + archiveImage: (id: string) => imageArchive.archive(id), + restoreImage: (id: string) => imageArchive.unarchive(id), + deleteImage: (id: string) => imageArchive.softDelete(id), }; diff --git a/apps/manacore/apps/web/src/lib/modules/picture/types.ts b/apps/manacore/apps/web/src/lib/modules/picture/types.ts index 53cd7b8e8..d91d663ff 100644 --- a/apps/manacore/apps/web/src/lib/modules/picture/types.ts +++ b/apps/manacore/apps/web/src/lib/modules/picture/types.ts @@ -21,7 +21,7 @@ export interface LocalImage extends BaseRecord { isFavorite: boolean; downloadCount: number; rating?: number | null; - archivedAt?: string | null; + isArchived?: boolean; generationId?: string | null; sourceImageId?: string | null; } @@ -80,7 +80,7 @@ export interface Image { isFavorite: boolean; downloadCount: number; rating?: number; - archivedAt?: string; + isArchived?: boolean; generationId?: string; sourceImageId?: string; createdAt: string; diff --git a/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte index 0906ec641..7d6433d12 100644 --- a/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/picture/+page.svelte @@ -63,7 +63,7 @@ let selectedImage = $state(null); async function handleToggleFavorite(img: Image) { - await imagesStore.toggleFavorite(img.id, img.isFavorite); + await imagesStore.toggleFavorite(img.id); } async function handleArchive(img: Image) { diff --git a/packages/shared-stores/src/archive.test.ts b/packages/shared-stores/src/archive.test.ts new file mode 100644 index 000000000..6775b6be7 --- /dev/null +++ b/packages/shared-stores/src/archive.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createArchiveOps, filterActive, filterArchived, filterNotDeleted } from './archive'; + +function createMockTable(records: Record>) { + return { + get: vi.fn(async (id: string) => records[id] ?? null), + update: vi.fn(async (id: string, changes: Record) => { + if (records[id]) Object.assign(records[id], changes); + return 1; + }), + }; +} + +// ─── Query Helpers ──────────────────────────────────────── + +describe('filterActive', () => { + it('returns items that are neither archived nor deleted', () => { + const items = [ + { id: '1', isArchived: false, deletedAt: null }, + { id: '2', isArchived: true, deletedAt: null }, + { id: '3', isArchived: false, deletedAt: '2024-01-01' }, + { id: '4', isArchived: true, deletedAt: '2024-01-01' }, + ]; + expect(filterActive(items).map((i) => i.id)).toEqual(['1']); + }); + + it('treats undefined isArchived as false', () => { + const items = [{ id: '1', deletedAt: null }]; + expect(filterActive(items)).toHaveLength(1); + }); +}); + +describe('filterArchived', () => { + it('returns only archived, non-deleted items', () => { + const items = [ + { id: '1', isArchived: false, deletedAt: null }, + { id: '2', isArchived: true, deletedAt: null }, + { id: '3', isArchived: true, deletedAt: '2024-01-01' }, + ]; + expect(filterArchived(items).map((i) => i.id)).toEqual(['2']); + }); +}); + +describe('filterNotDeleted', () => { + it('returns items without deletedAt', () => { + const items = [{ id: '1', deletedAt: null }, { id: '2', deletedAt: '2024-01-01' }, { id: '3' }]; + expect(filterNotDeleted(items).map((i) => i.id)).toEqual(['1', '3']); + }); +}); + +// ─── Archive Ops Factory ────────────────────────────────── + +describe('createArchiveOps', () => { + let records: Record>; + let mockTable: ReturnType; + let ops: ReturnType; + + beforeEach(() => { + records = { + '1': { id: '1', isArchived: false, deletedAt: null }, + }; + mockTable = createMockTable(records); + ops = createArchiveOps({ table: () => mockTable as never }); + }); + + describe('archive', () => { + it('sets isArchived to true', async () => { + await ops.archive('1'); + expect(mockTable.update).toHaveBeenCalledWith( + '1', + expect.objectContaining({ isArchived: true }) + ); + }); + + it('sets updatedAt', async () => { + await ops.archive('1'); + const call = mockTable.update.mock.calls[0][1]; + expect(call.updatedAt).toBeTruthy(); + }); + }); + + describe('unarchive', () => { + it('sets isArchived to false', async () => { + records['1'].isArchived = true; + await ops.unarchive('1'); + expect(mockTable.update).toHaveBeenCalledWith( + '1', + expect.objectContaining({ isArchived: false }) + ); + }); + }); + + describe('toggleArchive', () => { + it('toggles false to true', async () => { + const result = await ops.toggleArchive('1'); + expect(result).toBe(true); + }); + + it('toggles true to false', async () => { + records['1'].isArchived = true; + const result = await ops.toggleArchive('1'); + expect(result).toBe(false); + }); + + it('throws if record not found', async () => { + await expect(ops.toggleArchive('missing')).rejects.toThrow('Record missing not found'); + }); + }); + + describe('softDelete', () => { + it('sets deletedAt timestamp', async () => { + await ops.softDelete('1'); + expect(mockTable.update).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + deletedAt: expect.any(String), + updatedAt: expect.any(String), + }) + ); + }); + }); + + describe('restore', () => { + it('clears deletedAt', async () => { + records['1'].deletedAt = '2024-01-01'; + await ops.restore('1'); + expect(mockTable.update).toHaveBeenCalledWith( + '1', + expect.objectContaining({ deletedAt: null }) + ); + }); + }); + + describe('custom archive field', () => { + it('uses custom field name', async () => { + const customOps = createArchiveOps({ + table: () => mockTable as never, + archiveField: 'hidden', + }); + await customOps.archive('1'); + expect(mockTable.update).toHaveBeenCalledWith('1', expect.objectContaining({ hidden: true })); + }); + }); +}); diff --git a/packages/shared-stores/src/archive.ts b/packages/shared-stores/src/archive.ts new file mode 100644 index 000000000..a6f563638 --- /dev/null +++ b/packages/shared-stores/src/archive.ts @@ -0,0 +1,124 @@ +/** + * Archive & Soft-Delete Utilities + * + * Standardizes the two-stage deletion pattern used across all modules: + * 1. Archive (isArchived: true) — user-initiated "put away", reversible + * 2. Soft-delete (deletedAt: timestamp) — trash, reversible but hidden deeper + * + * @example + * ```typescript + * import { createArchiveOps } from '@manacore/shared-stores'; + * import { db } from '$lib/data/database'; + * + * export const memoArchive = createArchiveOps({ + * table: () => db.table('memos'), + * }); + * + * // Usage: + * await memoArchive.archive('memo-123'); + * await memoArchive.unarchive('memo-123'); + * await memoArchive.softDelete('memo-123'); + * await memoArchive.restore('memo-123'); + * ``` + */ + +import type { Table } from 'dexie'; + +// ─── Types ──────────────────────────────────────────────── + +/** Mixin interface for archivable records. */ +export interface Archivable { + isArchived?: boolean; + deletedAt?: string | null; +} + +/** Mixin interface for soft-deletable records. */ +export interface SoftDeletable { + deletedAt?: string | null; +} + +// ─── Query Helpers (pure functions) ─────────────────────── + +/** Filter to only active (non-archived, non-deleted) records. */ +export function filterActive(items: T[]): T[] { + return items.filter((item) => !item.isArchived && !item.deletedAt); +} + +/** Filter to only archived (but not deleted) records. */ +export function filterArchived(items: T[]): T[] { + return items.filter((item) => item.isArchived && !item.deletedAt); +} + +/** Filter to exclude soft-deleted records (regardless of archive status). */ +export function filterNotDeleted(items: T[]): T[] { + return items.filter((item) => !item.deletedAt); +} + +// ─── Archive Ops Factory ────────────────────────────────── + +export interface ArchiveOpsConfig { + /** Dexie table accessor (lazy to avoid import-order issues) */ + table: () => Table; + /** Archive field name (default: 'isArchived') */ + archiveField?: string; +} + +export interface ArchiveOps { + /** Set isArchived = true */ + archive(id: string): Promise; + /** Set isArchived = false */ + unarchive(id: string): Promise; + /** Toggle isArchived */ + toggleArchive(id: string): Promise; + /** Set deletedAt = now (soft-delete) */ + softDelete(id: string): Promise; + /** Clear deletedAt (restore from trash) */ + restore(id: string): Promise; +} + +export function createArchiveOps(config: ArchiveOpsConfig): ArchiveOps { + const field = config.archiveField ?? 'isArchived'; + + return { + async archive(id: string) { + await config.table().update(id, { + [field]: true, + updatedAt: new Date().toISOString(), + }); + }, + + async unarchive(id: string) { + await config.table().update(id, { + [field]: false, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleArchive(id: string): Promise { + const record = await config.table().get(id); + if (!record) throw new Error(`Record ${id} not found`); + const current = !!(record as Record)[field]; + const newValue = !current; + await config.table().update(id, { + [field]: newValue, + updatedAt: new Date().toISOString(), + }); + return newValue; + }, + + async softDelete(id: string) { + const now = new Date().toISOString(); + await config.table().update(id, { + deletedAt: now, + updatedAt: now, + }); + }, + + async restore(id: string) { + await config.table().update(id, { + deletedAt: null, + updatedAt: new Date().toISOString(), + }); + }, + }; +} diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 76def48c3..f463c5e30 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -46,6 +46,16 @@ export { } from './tags-local.svelte'; export { createTagLinkOps, type TagLinkOps, type TagLinkOpsConfig } from './tag-links'; export { toggleField } from './toggle-field'; +export { + createArchiveOps, + filterActive, + filterArchived, + filterNotDeleted, + type Archivable, + type SoftDeletable, + type ArchiveOps, + type ArchiveOpsConfig, +} from './archive'; export { createGuestMode,