From 48aac82bcb0edbd5d53627dc000c2d1e4780f12c Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 15:33:18 +0200 Subject: [PATCH] feat(shared-stores): add createTagLinkOps factory for junction tables Reusable factory for tag-entity junction operations (addTag, removeTag, setTags, getTagIds, hasTag) used by all modules. Also loosen ViewStore filter constraint from Record to object for interface compatibility. Includes 14 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-stores/src/index.ts | 1 + packages/shared-stores/src/tag-links.test.ts | 145 +++++++++++++++++++ packages/shared-stores/src/tag-links.ts | 124 ++++++++++++++++ packages/shared-stores/src/view.svelte.ts | 6 +- 4 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 packages/shared-stores/src/tag-links.test.ts create mode 100644 packages/shared-stores/src/tag-links.ts diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 1ccd605cf..8b5d8856a 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -44,3 +44,4 @@ export { type LocalTag, type LocalTagGroup, } from './tags-local.svelte'; +export { createTagLinkOps, type TagLinkOps, type TagLinkOpsConfig } from './tag-links'; diff --git a/packages/shared-stores/src/tag-links.test.ts b/packages/shared-stores/src/tag-links.test.ts new file mode 100644 index 000000000..9abf5281c --- /dev/null +++ b/packages/shared-stores/src/tag-links.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createTagLinkOps } from './tag-links'; + +// Mock Dexie table +function createMockTable() { + let records: Array> = []; + + return { + _records: () => records, + _reset: () => { + records = []; + }, + where: vi.fn((field: string) => ({ + equals: vi.fn((value: unknown) => ({ + toArray: vi.fn(async () => records.filter((r) => r[field] === value)), + })), + })), + add: vi.fn(async (record: Record) => { + records.push(record); + return record.id; + }), + update: vi.fn(async (id: string, changes: Record) => { + const idx = records.findIndex((r) => r.id === id); + if (idx >= 0) records[idx] = { ...records[idx], ...changes }; + return 1; + }), + }; +} + +describe('createTagLinkOps', () => { + let mockTable: ReturnType; + let ops: ReturnType; + + beforeEach(() => { + mockTable = createMockTable(); + ops = createTagLinkOps({ + table: () => mockTable as never, + entityIdField: 'memoId', + }); + }); + + describe('addTag', () => { + it('adds a tag link to an entity', async () => { + await ops.addTag('memo-1', 'tag-1'); + const records = mockTable._records(); + expect(records).toHaveLength(1); + expect(records[0].memoId).toBe('memo-1'); + expect(records[0].tagId).toBe('tag-1'); + }); + + it('does not add duplicate link', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.addTag('memo-1', 'tag-1'); + const records = mockTable._records(); + expect(records).toHaveLength(1); + }); + + it('allows different tags on same entity', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.addTag('memo-1', 'tag-2'); + const records = mockTable._records(); + expect(records).toHaveLength(2); + }); + }); + + describe('getTagIds', () => { + it('returns tag IDs for an entity', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.addTag('memo-1', 'tag-2'); + const ids = await ops.getTagIds('memo-1'); + expect(ids).toEqual(['tag-1', 'tag-2']); + }); + + it('returns empty array for entity with no tags', async () => { + const ids = await ops.getTagIds('memo-999'); + expect(ids).toEqual([]); + }); + + it('excludes soft-deleted links', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.addTag('memo-1', 'tag-2'); + await ops.removeTag('memo-1', 'tag-1'); + const ids = await ops.getTagIds('memo-1'); + expect(ids).toEqual(['tag-2']); + }); + }); + + describe('removeTag', () => { + it('soft-deletes a tag link', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.removeTag('memo-1', 'tag-1'); + const records = mockTable._records(); + expect(records[0].deletedAt).toBeTruthy(); + }); + + it('does nothing if link does not exist', async () => { + await ops.removeTag('memo-1', 'tag-999'); + // No error, no records + }); + }); + + describe('setTags', () => { + it('replaces all tags for an entity', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.addTag('memo-1', 'tag-2'); + await ops.setTags('memo-1', ['tag-3', 'tag-4']); + const ids = await ops.getTagIds('memo-1'); + expect(ids).toEqual(['tag-3', 'tag-4']); + }); + + it('keeps existing tags that are in new set', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.addTag('memo-1', 'tag-2'); + await ops.setTags('memo-1', ['tag-2', 'tag-3']); + const ids = await ops.getTagIds('memo-1'); + expect(ids).toContain('tag-2'); + expect(ids).toContain('tag-3'); + expect(ids).not.toContain('tag-1'); + }); + + it('handles empty array (removes all tags)', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.setTags('memo-1', []); + const ids = await ops.getTagIds('memo-1'); + expect(ids).toEqual([]); + }); + }); + + describe('hasTag', () => { + it('returns true if entity has the tag', async () => { + await ops.addTag('memo-1', 'tag-1'); + expect(await ops.hasTag('memo-1', 'tag-1')).toBe(true); + }); + + it('returns false if entity does not have the tag', async () => { + expect(await ops.hasTag('memo-1', 'tag-1')).toBe(false); + }); + + it('returns false after tag is removed', async () => { + await ops.addTag('memo-1', 'tag-1'); + await ops.removeTag('memo-1', 'tag-1'); + expect(await ops.hasTag('memo-1', 'tag-1')).toBe(false); + }); + }); +}); diff --git a/packages/shared-stores/src/tag-links.ts b/packages/shared-stores/src/tag-links.ts new file mode 100644 index 000000000..627df7476 --- /dev/null +++ b/packages/shared-stores/src/tag-links.ts @@ -0,0 +1,124 @@ +/** + * Tag Link Factory — Reusable junction table operations for modules. + * + * Each module has its own junction table (memoTags, fileTags, imageTags, etc.) + * linking entities to global tags. This factory provides standard CRUD for those junctions. + * + * @example + * ```typescript + * import { createTagLinkOps } from '@manacore/shared-stores'; + * import { db } from '$lib/data/database'; + * + * export const memoTagOps = createTagLinkOps({ + * table: () => db.table('memoTags'), + * entityIdField: 'memoId', + * }); + * + * // Usage: + * await memoTagOps.addTag('memo-123', 'tag-456'); + * await memoTagOps.setTags('memo-123', ['tag-1', 'tag-2']); + * const tagIds = await memoTagOps.getTagIds('memo-123'); + * ``` + */ + +import type { Table } from 'dexie'; + +interface BaseTagLink { + id: string; + tagId: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string; + [key: string]: unknown; +} + +export interface TagLinkOpsConfig { + /** Dexie table accessor (lazy to avoid import-order issues) */ + table: () => Table; + /** Entity ID field name on the junction record (e.g. 'memoId', 'fileId') */ + entityIdField: string; +} + +export interface TagLinkOps { + /** Get all tag IDs linked to an entity */ + getTagIds(entityId: string): Promise; + /** Add a tag to an entity (no-op if already linked) */ + addTag(entityId: string, tagId: string): Promise; + /** Remove a tag from an entity (soft-delete) */ + removeTag(entityId: string, tagId: string): Promise; + /** Replace all tags for an entity */ + setTags(entityId: string, tagIds: string[]): Promise; + /** Check if entity has a specific tag */ + hasTag(entityId: string, tagId: string): Promise; +} + +export function createTagLinkOps(config: TagLinkOpsConfig): TagLinkOps { + const { entityIdField } = config; + + async function getActive(entityId: string): Promise { + const all = await config.table().where(entityIdField).equals(entityId).toArray(); + return all.filter((r) => !r.deletedAt); + } + + return { + async getTagIds(entityId: string): Promise { + const links = await getActive(entityId); + return links.map((l) => l.tagId); + }, + + async addTag(entityId: string, tagId: string): Promise { + const existing = await getActive(entityId); + if (existing.some((l) => l.tagId === tagId)) return; + + const now = new Date().toISOString(); + await config.table().add({ + id: crypto.randomUUID(), + [entityIdField]: entityId, + tagId, + createdAt: now, + updatedAt: now, + } as BaseTagLink); + }, + + async removeTag(entityId: string, tagId: string): Promise { + const links = await getActive(entityId); + const link = links.find((l) => l.tagId === tagId); + if (!link) return; + + const now = new Date().toISOString(); + await config.table().update(link.id, { deletedAt: now, updatedAt: now }); + }, + + async setTags(entityId: string, tagIds: string[]): Promise { + const now = new Date().toISOString(); + const existing = await getActive(entityId); + const existingTagIds = new Set(existing.map((l) => l.tagId)); + const desiredTagIds = new Set(tagIds); + + // Remove tags no longer desired + for (const link of existing) { + if (!desiredTagIds.has(link.tagId)) { + await config.table().update(link.id, { deletedAt: now, updatedAt: now }); + } + } + + // Add new tags + for (const tagId of tagIds) { + if (!existingTagIds.has(tagId)) { + await config.table().add({ + id: crypto.randomUUID(), + [entityIdField]: entityId, + tagId, + createdAt: now, + updatedAt: now, + } as BaseTagLink); + } + } + }, + + async hasTag(entityId: string, tagId: string): Promise { + const links = await getActive(entityId); + return links.some((l) => l.tagId === tagId); + }, + }; +} diff --git a/packages/shared-stores/src/view.svelte.ts b/packages/shared-stores/src/view.svelte.ts index d2f1c531d..d87843e2d 100644 --- a/packages/shared-stores/src/view.svelte.ts +++ b/packages/shared-stores/src/view.svelte.ts @@ -39,7 +39,7 @@ export interface SavedFilter { createdAt: string; } -export interface ViewStoreConfig> { +export interface ViewStoreConfig { /** Prefix for localStorage keys (e.g. 'inventar' → 'inventar_view_mode') */ storagePrefix: string; /** Default view mode */ @@ -50,7 +50,7 @@ export interface ViewStoreConfig boolean; } -export interface ViewStore> { +export interface ViewStore { readonly viewMode: V; readonly sort: SortOption; readonly activeFilters: F; @@ -87,7 +87,7 @@ function save(key: string, value: unknown) { } } -export function createViewStore>( +export function createViewStore( config: ViewStoreConfig ): ViewStore { const VIEW_KEY = `${config.storagePrefix}_view_mode`;