mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
refactor(workbench): central per-space-seeds registry + deterministic Home id
Replaces three race-prone seeding paths in `workbench-scenes.svelte.ts`
(count==0 init seed, replay-on-register seed, per-Space-change seed)
with a single registry pattern:
- `data/scope/per-space-seeds.ts` — registry of `Seeder` callbacks,
fired by `setActiveSpace` whenever the active Space id changes.
Errors are isolated per seeder so one module's bug can't block the
others.
- `data/seeds/workbench-home.ts` — registers the workbench Home seeder.
Uses the deterministic id `seed-home-${spaceId}` and a get-then-add
guard, making the seed structurally idempotent: re-running for the
same Space is a no-op regardless of timing. Replaces the old
random-UUID + check-by-presence pattern that the seeding race could
defeat.
- `data/seeds/index.ts` — side-effect barrel imported once at the top
of `(app)/+layout.svelte` so every module's seeder is in the
registry before the first `loadActiveSpace` fires.
- `active-space.svelte.ts` — both `setActiveSpace` and `loadActiveSpace`
funnel through a private `applyActiveSpace` helper that calls
`notifyHandlers` AND `runSpaceSeeds` in one place. No more divergent
state-update paths.
- `workbench-scenes.svelte.ts` — `ensureSeedScene` and the two
ad-hoc seed calls deleted. The store now only owns rendering and
user-driven CRUD; seeding lives in the registry.
This is Schicht B + C of the broader cleanup plan
(docs/plans/workbench-seeding-cleanup.md). Schicht D-soft already
collapsed existing duplicates; this PR prevents new ones from forming.
The tests cover the registry contract (register/run/idempotency/error
isolation), the deterministic-id helper, and the seeder against an
isolated Dexie fixture (race-safety, no-overwrite of customised rows,
per-Space isolation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad5987f1dd
commit
c73f93ff12
8 changed files with 411 additions and 64 deletions
|
|
@ -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<A
|
|||
status = 'loading';
|
||||
lastError = null;
|
||||
|
||||
const prevId = active?.id;
|
||||
try {
|
||||
const member = await fetchActiveMember();
|
||||
if (member) {
|
||||
active = member;
|
||||
status = 'ready';
|
||||
writeActiveSpaceHint(member.id);
|
||||
if (member.id !== prevId) notifyHandlers(member);
|
||||
applyActiveSpace(member);
|
||||
return member;
|
||||
}
|
||||
|
||||
|
|
@ -183,11 +201,10 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
|
|||
}
|
||||
|
||||
await setActiveOnServer(chosen.id);
|
||||
active = { ...chosen, role: hinted ? hinted.role : 'owner' };
|
||||
status = 'ready';
|
||||
writeActiveSpaceHint(chosen.id);
|
||||
if (active.id !== prevId) notifyHandlers(active);
|
||||
return active;
|
||||
const resolved = { ...chosen, role: hinted ? hinted.role : 'owner' };
|
||||
applyActiveSpace(resolved);
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
status = 'error';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Unit tests for the per-space-seeds registry. Pure module — no Dexie,
|
||||
* no fake-indexeddb needed. Covers the contract:
|
||||
* - register + run drives every registered seeder with the spaceId
|
||||
* - duplicate-name registration overwrites (HMR friendliness)
|
||||
* - one seeder throwing doesn't stop the others
|
||||
* - empty registry is a benign no-op
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerSpaceSeed, runSpaceSeeds, __resetSpaceSeedsForTests } from './per-space-seeds';
|
||||
|
||||
afterEach(() => {
|
||||
__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']);
|
||||
});
|
||||
});
|
||||
60
apps/mana/apps/web/src/lib/data/scope/per-space-seeds.ts
Normal file
60
apps/mana/apps/web/src/lib/data/scope/per-space-seeds.ts
Normal file
|
|
@ -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<void>;
|
||||
|
||||
const seeders = new Map<string, Seeder>();
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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();
|
||||
}
|
||||
18
apps/mana/apps/web/src/lib/data/seeds/index.ts
Normal file
18
apps/mana/apps/web/src/lib/data/seeds/index.ts
Normal file
|
|
@ -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';
|
||||
127
apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts
Normal file
127
apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts
Normal file
|
|
@ -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<LocalWorkbenchScene, string>;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
84
apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts
Normal file
84
apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts
Normal file
|
|
@ -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<LocalWorkbenchScene, string>,
|
||||
spaceId: string
|
||||
): Promise<boolean> {
|
||||
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<any>`, which is
|
||||
// assignable to the seeder's `Table<LocalWorkbenchScene, string>`
|
||||
// signature. The explicit generic from earlier (`db.table<...>`)
|
||||
// resolved to `Table<…, IndexableType>` which TS rejects.
|
||||
await seedWorkbenchHomeOn(db.table(TABLE) as Table<LocalWorkbenchScene, string>, spaceId);
|
||||
});
|
||||
|
|
@ -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<WorkbenchScene[]>([]);
|
||||
|
|
@ -124,21 +118,6 @@ function nowIso() {
|
|||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function ensureSeedScene(): Promise<string> {
|
||||
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<LocalWorkbenchScene>(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<LocalWorkbenchScene>(TABLE)
|
||||
.filter((r) => {
|
||||
if (r.deletedAt) return false;
|
||||
const spaceId = (r as { spaceId?: unknown }).spaceId;
|
||||
return spaceId === space.id;
|
||||
})
|
||||
.first();
|
||||
if (!anyInSpace) await ensureSeedScene();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
<script lang="ts">
|
||||
// Side-effect: registers every per-Space seeder (workbench Home
|
||||
// scene + future module seeds) into the per-space-seeds map BEFORE
|
||||
// any code path can call `loadActiveSpace` / `setActiveSpace`.
|
||||
// See docs/plans/workbench-seeding-cleanup.md.
|
||||
import '$lib/data/seeds';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue