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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 16:33:07 +02:00
parent 7d3114df16
commit 779a8ba322
12 changed files with 324 additions and 112 deletions

View file

@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createArchiveOps, filterActive, filterArchived, filterNotDeleted } from './archive';
function createMockTable(records: Record<string, Record<string, unknown>>) {
return {
get: vi.fn(async (id: string) => records[id] ?? null),
update: vi.fn(async (id: string, changes: Record<string, unknown>) => {
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<string, Record<string, unknown>>;
let mockTable: ReturnType<typeof createMockTable>;
let ops: ReturnType<typeof createArchiveOps>;
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 }));
});
});
});

View file

@ -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<T extends Archivable & SoftDeletable>(items: T[]): T[] {
return items.filter((item) => !item.isArchived && !item.deletedAt);
}
/** Filter to only archived (but not deleted) records. */
export function filterArchived<T extends Archivable & SoftDeletable>(items: T[]): T[] {
return items.filter((item) => item.isArchived && !item.deletedAt);
}
/** Filter to exclude soft-deleted records (regardless of archive status). */
export function filterNotDeleted<T extends SoftDeletable>(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<void>;
/** Set isArchived = false */
unarchive(id: string): Promise<void>;
/** Toggle isArchived */
toggleArchive(id: string): Promise<boolean>;
/** Set deletedAt = now (soft-delete) */
softDelete(id: string): Promise<void>;
/** Clear deletedAt (restore from trash) */
restore(id: string): Promise<void>;
}
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<boolean> {
const record = await config.table().get(id);
if (!record) throw new Error(`Record ${id} not found`);
const current = !!(record as Record<string, unknown>)[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(),
});
},
};
}

View file

@ -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,