test+docs(workbench-seeding): hook stamping test + per-space-seeds guide

Closes the two remaining gaps after the seeding-cleanup landed:

- `data/space-stamping.test.ts` exercises the smart-hook contract
  end-to-end against fake-indexeddb. Four scenarios: active Brand
  Space → row carries Brand UUID; no active Space → personal sentinel;
  explicit spaceId on the record is preserved verbatim; flipping the
  active Space between writes flips the stamp. The Brand-Space case
  is the regression guard for the original bug (writes silently
  routing to Personal after `reconcileSentinels`).
- `apps/mana/CLAUDE.md` gets a Per-Space Seeds section so the next
  module dev who needs to pre-populate something on Space activation
  finds the `registerSpaceSeed` pattern + `data/seeds/index.ts` barrel
  + the deterministic-id discipline without grepping the codebase.
  Reference impl link points at workbench-home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 19:33:59 +02:00
parent e930a66ff3
commit 8c5f064b03
2 changed files with 152 additions and 0 deletions

View file

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

View file

@ -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:<userId>`, 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:<userId>` 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');
});
});