diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 835caa1bd..50eacd02c 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -194,6 +194,35 @@ Each workbench scene can carry `scopeTagIds` — a per-scene tag filter that mod `ScopeEmptyState` renders a subdued "Bereichsfilter verbergen alles" message plus a one-click "Bereich zurücksetzen" button that calls `workbenchScenesStore.setSceneScopeTags(activeSceneId, undefined)`. `SceneAppBar` already shows a Funnel badge on scoped scene pills; the module doesn't need to duplicate that signal. Plan: [`docs/plans/scene-scope-empty-state.md`](../../docs/plans/scene-scope-empty-state.md). +## Per-Space Seeds + +When a module needs to pre-populate something the first time a Space is activated (e.g. a default workbench layout), register a seeder rather than rolling your own boot-time check. The active-space layer fires every registered seeder on each `setActiveSpace` and isolates errors per-seeder. + +```typescript +// apps/web/src/lib/data/seeds/my-module.ts +import { db } from '../database'; +import { registerSpaceSeed } from '../scope/per-space-seeds'; + +registerSpaceSeed('my-module-default', async (spaceId) => { + const id = `seed-default-${spaceId}`; // deterministic id + if (await db.table('myTable').get(id)) return; // idempotent + await db.table('myTable').add({ id, spaceId, /* default fields */ }); +}); +``` + +Then add a side-effect import to `data/seeds/index.ts` so the seeder lands in the registry before the first `loadActiveSpace` call: + +```typescript +import './my-module'; +``` + +Two non-negotiables that make this reliable: + +1. **Deterministic id** (`seed-default-${spaceId}`, `seed-home-${spaceId}`, …) — Dexie's PK uniqueness + a `get`-then-`add` guard make duplicates structurally impossible regardless of boot timing. +2. **`spaceId` set explicitly on the seed row** when seeding into a Space that isn't the currently-active one. The creating-hook auto-stamps `getEffectiveSpaceId()` for missing-spaceId writes, but seeders are typically called with a target spaceId argument and shouldn't lean on that. + +Reference implementation: [`data/seeds/workbench-home.ts`](apps/web/src/lib/data/seeds/workbench-home.ts). Background + design rationale: [`docs/plans/workbench-seeding-cleanup.md`](../../docs/plans/workbench-seeding-cleanup.md). + ## AI Workbench The companion is a **second actor** that works alongside the human in every module. Full pipeline live end-to-end: diff --git a/apps/mana/apps/web/src/lib/data/space-stamping.test.ts b/apps/mana/apps/web/src/lib/data/space-stamping.test.ts new file mode 100644 index 000000000..749d93b26 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/space-stamping.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for the Dexie creating-hook's tenancy stamping. The hook in + * `database.ts` calls `getEffectiveSpaceId()` on every write to a + * space-scoped table that didn't pre-set `spaceId`, so: + * + * - With an active Space loaded, writes land under that Space's UUID. + * - During the bootstrap window (no active Space), writes carry the + * personal sentinel `_personal:`, which `reconcileSentinels` + * rewrites once `loadActiveSpace` resolves the real id. + * - An explicitly-set spaceId on the record is preserved verbatim. + * + * Before the smart hook landed, the literal `_personal:` was + * stamped unconditionally — so writes during a Brand-Space session + * silently routed to Personal after `reconcileSentinels` rewrote them. + * The tests here are the regression guard for that bug. + */ + +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from './database'; +import { setCurrentUserId } from './current-user'; +import { setActiveSpace } from './scope/active-space.svelte'; +import type { ActiveSpace } from './scope/active-space.svelte'; + +const brandSpace: ActiveSpace = { + id: 'space-brand-uuid', + slug: 'acme', + name: 'Acme Brand', + type: 'brand', + tier: 'public', + role: 'owner', +}; + +beforeEach(async () => { + setCurrentUserId('test-user'); + setActiveSpace(null); + await db.table('tasks').clear(); +}); + +afterEach(() => { + setActiveSpace(null); + setCurrentUserId(null); +}); + +describe('creating-hook tenancy stamping', () => { + it('stamps the active Space id when one is loaded', async () => { + setActiveSpace(brandSpace); + + await db.table('tasks').add({ + id: 'task-stamp-1', + title: 'in brand', + priority: 'medium', + isCompleted: false, + order: 0, + }); + + const row = await db.table('tasks').get('task-stamp-1'); + expect(row?.spaceId).toBe('space-brand-uuid'); + }); + + it('falls back to the personal sentinel when no Space is loaded', async () => { + await db.table('tasks').add({ + id: 'task-stamp-2', + title: 'no space yet', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const row = await db.table('tasks').get('task-stamp-2'); + expect(row?.spaceId).toBe('_personal:test-user'); + }); + + it('preserves an explicitly-set spaceId verbatim', async () => { + // Cross-space write pattern (e.g. workbench-home seeder + // writing into a target Space that isn't the active one). + setActiveSpace(brandSpace); + + await db.table('tasks').add({ + id: 'task-stamp-3', + title: 'forced personal', + priority: 'medium', + isCompleted: false, + order: 0, + spaceId: 'space-explicit-target', + }); + + const row = await db.table('tasks').get('task-stamp-3'); + expect(row?.spaceId).toBe('space-explicit-target'); + }); + + it('updates the stamp when the active Space changes between writes', async () => { + setActiveSpace(brandSpace); + await db.table('tasks').add({ + id: 'task-stamp-4a', + title: 'first', + priority: 'low', + isCompleted: false, + order: 0, + }); + + setActiveSpace(null); + await db.table('tasks').add({ + id: 'task-stamp-4b', + title: 'second', + priority: 'low', + isCompleted: false, + order: 1, + }); + + const a = await db.table('tasks').get('task-stamp-4a'); + const b = await db.table('tasks').get('task-stamp-4b'); + expect(a?.spaceId).toBe('space-brand-uuid'); + expect(b?.spaceId).toBe('_personal:test-user'); + }); +});