mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
e930a66ff3
commit
8c5f064b03
2 changed files with 152 additions and 0 deletions
|
|
@ -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).
|
`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
|
## AI Workbench
|
||||||
|
|
||||||
The companion is a **second actor** that works alongside the human in every module. Full pipeline live end-to-end:
|
The companion is a **second actor** that works alongside the human in every module. Full pipeline live end-to-end:
|
||||||
|
|
|
||||||
123
apps/mana/apps/web/src/lib/data/space-stamping.test.ts
Normal file
123
apps/mana/apps/web/src/lib/data/space-stamping.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue