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<string, unknown> to object for interface
compatibility. Includes 14 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 15:33:18 +02:00
parent eabd9200a3
commit 48aac82bcb
4 changed files with 273 additions and 3 deletions

View file

@ -44,3 +44,4 @@ export {
type LocalTag,
type LocalTagGroup,
} from './tags-local.svelte';
export { createTagLinkOps, type TagLinkOps, type TagLinkOpsConfig } from './tag-links';

View file

@ -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<Record<string, unknown>> = [];
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<string, unknown>) => {
records.push(record);
return record.id;
}),
update: vi.fn(async (id: string, changes: Record<string, unknown>) => {
const idx = records.findIndex((r) => r.id === id);
if (idx >= 0) records[idx] = { ...records[idx], ...changes };
return 1;
}),
};
}
describe('createTagLinkOps', () => {
let mockTable: ReturnType<typeof createMockTable>;
let ops: ReturnType<typeof createTagLinkOps>;
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);
});
});
});

View file

@ -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<BaseTagLink, string>;
/** 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<string[]>;
/** Add a tag to an entity (no-op if already linked) */
addTag(entityId: string, tagId: string): Promise<void>;
/** Remove a tag from an entity (soft-delete) */
removeTag(entityId: string, tagId: string): Promise<void>;
/** Replace all tags for an entity */
setTags(entityId: string, tagIds: string[]): Promise<void>;
/** Check if entity has a specific tag */
hasTag(entityId: string, tagId: string): Promise<boolean>;
}
export function createTagLinkOps(config: TagLinkOpsConfig): TagLinkOps {
const { entityIdField } = config;
async function getActive(entityId: string): Promise<BaseTagLink[]> {
const all = await config.table().where(entityIdField).equals(entityId).toArray();
return all.filter((r) => !r.deletedAt);
}
return {
async getTagIds(entityId: string): Promise<string[]> {
const links = await getActive(entityId);
return links.map((l) => l.tagId);
},
async addTag(entityId: string, tagId: string): Promise<void> {
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<void> {
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<void> {
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<boolean> {
const links = await getActive(entityId);
return links.some((l) => l.tagId === tagId);
},
};
}

View file

@ -39,7 +39,7 @@ export interface SavedFilter<F> {
createdAt: string;
}
export interface ViewStoreConfig<V extends string, F extends Record<string, unknown>> {
export interface ViewStoreConfig<V extends string, F extends object> {
/** Prefix for localStorage keys (e.g. 'inventar' → 'inventar_view_mode') */
storagePrefix: string;
/** Default view mode */
@ -50,7 +50,7 @@ export interface ViewStoreConfig<V extends string, F extends Record<string, unkn
hasActiveFilters?: (filters: F) => boolean;
}
export interface ViewStore<V extends string, F extends Record<string, unknown>> {
export interface ViewStore<V extends string, F extends object> {
readonly viewMode: V;
readonly sort: SortOption;
readonly activeFilters: F;
@ -87,7 +87,7 @@ function save(key: string, value: unknown) {
}
}
export function createViewStore<V extends string, F extends Record<string, unknown>>(
export function createViewStore<V extends string, F extends object>(
config: ViewStoreConfig<V, F>
): ViewStore<V, F> {
const VIEW_KEY = `${config.storagePrefix}_view_mode`;