diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index ee14c03a2..f69dc4fdd 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -12,6 +12,7 @@ import type { SpaceType, SpaceTier } from '@mana/shared-types'; import { isSpaceType, isSpaceTier } from '@mana/shared-types'; import { authFetch } from './auth-fetch'; +import { runSpaceSeeds } from './per-space-seeds'; export interface ActiveSpace { id: string; @@ -86,12 +87,32 @@ export function getActiveSpaceError(): string | null { return lastError; } -export function setActiveSpace(space: ActiveSpace | null): void { +/** + * Internal: mutate the active-space state and fan out side effects in + * one place. Both `setActiveSpace` (UI / tests) and `loadActiveSpace` + * (boot path) funnel through here so notifications and per-Space seeds + * fire identically regardless of which entry point updated the state. + */ +function applyActiveSpace(space: ActiveSpace | null): void { const prevId = active?.id; active = space; status = space ? 'ready' : 'idle'; lastError = null; - if (space?.id !== prevId) notifyHandlers(space); + if (space?.id === prevId) return; + notifyHandlers(space); + // Drive per-Space seeders (workbench Home scene + future module + // seeds) on every Space activation. Fire-and-forget — seeders are + // individually idempotent (deterministic ids + Dexie `put`), so a + // missed run self-heals on the next Space switch. Centralising into + // applyActiveSpace replaces the racy ad-hoc paths the scenes store + // used to drive itself. + if (space) { + void runSpaceSeeds(space.id); + } +} + +export function setActiveSpace(space: ActiveSpace | null): void { + applyActiveSpace(space); } /** @@ -157,14 +178,11 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise { + __resetSpaceSeedsForTests(); + vi.restoreAllMocks(); +}); + +describe('runSpaceSeeds', () => { + it('runs every registered seeder with the supplied spaceId', async () => { + const a = vi.fn().mockResolvedValue(undefined); + const b = vi.fn().mockResolvedValue(undefined); + registerSpaceSeed('a', a); + registerSpaceSeed('b', b); + + await runSpaceSeeds('space-xyz'); + + expect(a).toHaveBeenCalledWith('space-xyz'); + expect(b).toHaveBeenCalledWith('space-xyz'); + }); + + it('is a no-op with no seeders registered', async () => { + await expect(runSpaceSeeds('space-xyz')).resolves.toBeUndefined(); + }); + + it('overwrites a seeder when registered under the same name (HMR friendliness)', async () => { + const first = vi.fn().mockResolvedValue(undefined); + const second = vi.fn().mockResolvedValue(undefined); + registerSpaceSeed('module-x', first); + registerSpaceSeed('module-x', second); + + await runSpaceSeeds('space-xyz'); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith('space-xyz'); + }); + + it('isolates errors so one bad seeder does not stop the others', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const broken = vi.fn().mockRejectedValue(new Error('boom')); + const ok = vi.fn().mockResolvedValue(undefined); + registerSpaceSeed('broken', broken); + registerSpaceSeed('ok', ok); + + await runSpaceSeeds('space-xyz'); + + expect(broken).toHaveBeenCalled(); + expect(ok).toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining("'broken' failed"), + expect.any(Error) + ); + }); + + it('awaits each seeder sequentially in registration order', async () => { + const order: string[] = []; + registerSpaceSeed('first', async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push('first'); + }); + registerSpaceSeed('second', async () => { + order.push('second'); + }); + + await runSpaceSeeds('space-xyz'); + + expect(order).toEqual(['first', 'second']); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/scope/per-space-seeds.ts b/apps/mana/apps/web/src/lib/data/scope/per-space-seeds.ts new file mode 100644 index 000000000..c83c8ee2c --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/per-space-seeds.ts @@ -0,0 +1,60 @@ +/** + * Per-Space Seeds Registry — single chokepoint for "what does every Space + * get pre-populated with on first visit?". + * + * Why a registry: + * - Replaces the three race-prone ad-hoc seeding paths in the scenes + * store (count==0 init seed + replay-on-register seed + per-Space- + * change seed) with one deterministic call from `setActiveSpace`. + * - Each seeder is responsible for its own idempotency (deterministic + * primary key + Dexie `put` upsert). The registry stays + * pattern-agnostic — it only iterates and isolates errors. + * + * Modules register themselves via side-effect imports (see + * `data/seeds/index.ts`). The +layout boot path imports the seeds + * barrel before `loadActiveSpace`, so by the time `setActiveSpace` + * fires, every seeder is already in the map. + * + * See docs/plans/workbench-seeding-cleanup.md §"Schicht B + C". + */ + +type Seeder = (spaceId: string) => Promise; + +const seeders = new Map(); + +/** + * Register a per-Space seeder. The `name` is used purely for diagnostic + * logging — duplicate names overwrite, so re-registering the same + * seeder during HMR is safe. + */ +export function registerSpaceSeed(name: string, fn: Seeder): void { + seeders.set(name, fn); +} + +/** + * Run every registered seeder against the given Space id. Errors are + * caught per-seeder and logged — a failure in one module's seed must + * not prevent the others from running. + * + * Fire-and-forget by convention: callers shouldn't block UI on seeds. + * The active-space switch propagates immediately; seeds catch up + * asynchronously. + */ +export async function runSpaceSeeds(spaceId: string): Promise { + for (const [name, fn] of seeders) { + try { + await fn(spaceId); + } catch (err) { + console.error(`[per-space-seeds] '${name}' failed for space ${spaceId}:`, err); + } + } +} + +/** + * Test-only: drop every registered seeder. Production code never needs + * this — vitest suites that exercise the registry use it to keep + * tests independent. + */ +export function __resetSpaceSeedsForTests(): void { + seeders.clear(); +} diff --git a/apps/mana/apps/web/src/lib/data/seeds/index.ts b/apps/mana/apps/web/src/lib/data/seeds/index.ts new file mode 100644 index 000000000..7b06143f0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/seeds/index.ts @@ -0,0 +1,18 @@ +/** + * Per-Space-Seeds barrel — side-effect imports register every module's + * seeder into the per-space-seeds registry at module-load time. + * + * Boot order matters: this file must be imported BEFORE the first + * `loadActiveSpace` call, otherwise `setActiveSpace` will fire + * `runSpaceSeeds` against an empty registry and the user's Space + * starts blank. + * + * The +layout.svelte boot path imports this barrel near the top of its + * import block so registration completes before any reactive effect + * has a chance to drive the Space lifecycle. + * + * See docs/plans/workbench-seeding-cleanup.md. + */ + +// Side-effect: registers `workbench-home` in the per-space-seeds map. +import './workbench-home'; diff --git a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts new file mode 100644 index 000000000..4e14a9bfa --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts @@ -0,0 +1,127 @@ +/** + * Unit tests for the workbench-home seeder. Exercises the pure + * `seedWorkbenchHomeOn(table, spaceId)` against an isolated Dexie db + * so the test never has to mount the full `database.ts` module. + * + * The module under test side-effect-imports `db` from `../database` + * and calls `registerSpaceSeed` at top level — both stubbed so the + * import doesn't fail during test bootstrap. + */ + +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Dexie, { type Table } from 'dexie'; +import type { LocalWorkbenchScene } from '$lib/types/workbench-scenes'; + +// Stub `../database` so importing the seeder doesn't try to bring in +// the real Dexie singleton (which transitively imports auth stores, +// triggers, funnel-tracking, …). The seeder uses `db` only inside the +// registered closure; the pure function under test takes its table +// argument directly. +vi.mock('../database', () => ({ + db: { + table: () => ({ + get: vi.fn(), + add: vi.fn(), + }), + }, +})); + +import { seedWorkbenchHomeOn, workbenchHomeSeedId, DEFAULT_HOME_APPS } from './workbench-home'; + +interface FixtureDb extends Dexie { + workbenchScenes: Table; +} + +let db: FixtureDb; + +function makeDb(): FixtureDb { + const fresh = new Dexie(`seeder-test-${crypto.randomUUID()}`) as FixtureDb; + fresh.version(1).stores({ workbenchScenes: 'id, order' }); + return fresh; +} + +beforeEach(async () => { + db = makeDb(); + await db.open(); +}); + +afterEach(async () => { + db.close(); + await Dexie.delete(db.name); +}); + +describe('workbenchHomeSeedId', () => { + it('produces a deterministic id keyed by spaceId', () => { + expect(workbenchHomeSeedId('space-abc')).toBe('seed-home-space-abc'); + expect(workbenchHomeSeedId('space-abc')).toBe(workbenchHomeSeedId('space-abc')); + expect(workbenchHomeSeedId('space-abc')).not.toBe(workbenchHomeSeedId('space-xyz')); + }); +}); + +describe('seedWorkbenchHomeOn', () => { + it('inserts a Home scene with the deterministic id and default apps', async () => { + const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); + expect(inserted).toBe(true); + + const row = await db.workbenchScenes.get('seed-home-space-abc'); + expect(row).toMatchObject({ + id: 'seed-home-space-abc', + name: 'Home', + order: 0, + openApps: DEFAULT_HOME_APPS, + spaceId: 'space-abc', + }); + expect(row?.createdAt).toBeTruthy(); + expect(row?.updatedAt).toBeTruthy(); + }); + + it('is a no-op when the seeded row already exists', async () => { + await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); + const second = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); + expect(second).toBe(false); + + const all = await db.workbenchScenes.toArray(); + expect(all).toHaveLength(1); + }); + + it('does not overwrite a customized survivor', async () => { + // Simulate the user having added apps to their Home scene. + await db.workbenchScenes.add({ + id: 'seed-home-space-abc', + name: 'Home', + order: 0, + openApps: [{ appId: 'todo' }, { appId: 'mood' }, { appId: 'meditate' }], + createdAt: '2026-04-25T08:00:00.000Z', + updatedAt: '2026-04-25T11:00:00.000Z', + } as LocalWorkbenchScene); + + await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); + + const row = await db.workbenchScenes.get('seed-home-space-abc'); + // The user's openApps survive — seeder respects existing state. + expect(row?.openApps).toEqual([{ appId: 'todo' }, { appId: 'mood' }, { appId: 'meditate' }]); + }); + + it('seeds independently per Space (no cross-pollination)', async () => { + await seedWorkbenchHomeOn(db.workbenchScenes, 'space-A'); + await seedWorkbenchHomeOn(db.workbenchScenes, 'space-B'); + + const all = await db.workbenchScenes.toArray(); + expect(all.map((r) => r.id).sort()).toEqual(['seed-home-space-A', 'seed-home-space-B']); + }); + + it('survives concurrent calls for the same Space without producing duplicates', async () => { + // The structural promise: deterministic id + Dexie `add` on a + // PK-uniqueness violation will throw, but the get-then-add + // guard should short-circuit. Either way: no two rows. + await Promise.allSettled([ + seedWorkbenchHomeOn(db.workbenchScenes, 'space-race'), + seedWorkbenchHomeOn(db.workbenchScenes, 'space-race'), + seedWorkbenchHomeOn(db.workbenchScenes, 'space-race'), + ]); + + const all = await db.workbenchScenes.where('id').equals('seed-home-space-race').toArray(); + expect(all).toHaveLength(1); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts new file mode 100644 index 000000000..3fd5e3548 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts @@ -0,0 +1,84 @@ +/** + * Per-Space "Home" Workbench-Scene seeder. + * + * Registers a single seeder under the per-space-seeds registry. On + * every active-Space switch, `setActiveSpace` invokes + * `runSpaceSeeds(space.id)` which calls into here. + * + * Idempotency is structural: the row id is `seed-home-${spaceId}` + * (deterministic) and the seeder no-ops if the row already exists. + * Re-running for the same Space is a no-op — no duplicate possible + * regardless of how the boot/replay timing shakes out. This is the + * structural answer to the bug Schicht D-soft cleaned up after. + * + * The actual write is split out into `seedWorkbenchHomeOn(table, ...)` + * so unit tests can drive it against a fixture Dexie instance without + * pulling in `database.ts`'s side-effect imports. + * + * See docs/plans/workbench-seeding-cleanup.md §"Schicht B + C". + */ + +import type { Table } from 'dexie'; +import { db } from '../database'; +import { registerSpaceSeed } from '../scope/per-space-seeds'; +import type { LocalWorkbenchScene, WorkbenchSceneApp } from '$lib/types/workbench-scenes'; + +const TABLE = 'workbenchScenes'; + +/** + * Default app list a fresh "Home" scene starts with. Three modules the + * majority of users open on day one — keeps the workbench non-empty + * without making decisions on the user's behalf. Exported so tests can + * assert the seed shape. + */ +export const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ + { appId: 'todo' }, + { appId: 'calendar' }, + { appId: 'notes' }, +]; + +/** + * Deterministic id for a Space's seeded Home scene. Exported so + * consumers can detect "this is the auto-seeded row" if they ever need + * to. + */ +export function workbenchHomeSeedId(spaceId: string): string { + return `seed-home-${spaceId}`; +} + +/** + * Pure-ish: takes a Dexie Table reference, ensures a Home scene exists + * for the given Space. Returns true when a new row was inserted, false + * when the row was already there. The creating-hook stamps the actor / + * timestamps fields; this function only owns the deterministic-id + + * default-shape contract. + */ +export async function seedWorkbenchHomeOn( + table: Table, + spaceId: string +): Promise { + const id = workbenchHomeSeedId(spaceId); + const existing = await table.get(id); + if (existing) return false; + + const now = new Date().toISOString(); + const row: LocalWorkbenchScene & { spaceId: string } = { + id, + name: 'Home', + openApps: DEFAULT_HOME_APPS, + order: 0, + createdAt: now, + updatedAt: now, + spaceId, + }; + await table.add(row); + return true; +} + +registerSpaceSeed('workbench-home', async (spaceId) => { + // `db.table('workbenchScenes')` returns `Table`, which is + // assignable to the seeder's `Table` + // signature. The explicit generic from earlier (`db.table<...>`) + // resolved to `Table<…, IndexableType>` which TS rejects. + await seedWorkbenchHomeOn(db.table(TABLE) as Table, spaceId); +}); diff --git a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts index ec0247bcf..3dc3580bd 100644 --- a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts @@ -44,12 +44,6 @@ function mruKey(spaceId: string | null): string { return spaceId ? `${MRU_LS_KEY_BASE}:${spaceId}` : MRU_LS_KEY_BASE; } -const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ - { appId: 'todo' }, - { appId: 'calendar' }, - { appId: 'notes' }, -]; - // ─── Reactive state ─────────────────────────────────────────── let scenesState = $state([]); @@ -124,21 +118,6 @@ function nowIso() { return new Date().toISOString(); } -async function ensureSeedScene(): Promise { - const id = crypto.randomUUID(); - const now = nowIso(); - const seed: LocalWorkbenchScene = { - id, - name: 'Home', - openApps: DEFAULT_HOME_APPS, - order: 0, - createdAt: now, - updatedAt: now, - }; - await db.table(TABLE).add(seed); - return id; -} - /** Exported for unit tests — resolves the active scene id against the * available list, falling back to per-device MRU and finally to the * first sort-ordered scene. */ @@ -285,44 +264,22 @@ export const workbenchScenesStore = { async initialize() { if (!browser || initializedState) return; - // Seed a Home scene on first run so the UI never has zero scenes. - // We can't safely check "none in this Space" until the active- - // space handler replays — the guard is "no scenes anywhere", same - // as before. Per-Space seeding happens inside onSpaceChanged - // below. - const count = await db.table(TABLE).count(); - if (count === 0) { - await ensureSeedScene(); - } + // Default-Home seeding moved to `data/seeds/workbench-home.ts`, + // driven by `setActiveSpace` via the per-space-seeds registry. + // The store no longer owns seed lifecycle — it just renders + // whatever rows the liveQuery surfaces. + // See docs/plans/workbench-seeding-cleanup.md. activeSceneIdState = readActiveIdFromStorage(); openSubscription(); - // Register a handler that refreshes per-Space state whenever the - // active Space flips. Replay-on-register fires once immediately if - // a Space is already loaded, so the initial state is correct - // regardless of which store finishes initializing first. - onActiveSpaceChanged(async (space) => { - // Update activeSceneIdState from the new Space's LS key. The - // liveQuery is already re-running because getInScopeSpaceIds() - // returns a different set, but the local activeSceneIdState - // needs an explicit re-read. + // Register a handler that refreshes the per-Device LS hint + // whenever the active Space flips. The liveQuery itself is + // already re-running because `getInScopeSpaceIds()` returns a + // different set; this handler only re-reads the active-scene id + // from the new Space's localStorage key. + onActiveSpaceChanged(() => { activeSceneIdState = readActiveIdFromStorage(); - - // Seed a default scene for this Space if none exists yet. Runs - // on the first visit to every Shared/Brand/Family/Team Space a - // user joins, so the workbench never shows empty. - if (space) { - const anyInSpace = await db - .table(TABLE) - .filter((r) => { - if (r.deletedAt) return false; - const spaceId = (r as { spaceId?: unknown }).spaceId; - return spaceId === space.id; - }) - .first(); - if (!anyInSpace) await ensureSeedScene(); - } }); }, diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index cf9ef6225..bc4e1eeb2 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -1,4 +1,10 @@