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,