From fa71269fc8b67c5c7dcc0515ed355c097a6a6062 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 15:50:53 +0200 Subject: [PATCH] refactor(workbench-seeding): drop transitional code paths, finalise via v50 With the smart hook always stamping the active-Space id and the seeder running the deterministic-id contract, the transitional concessions introduced for the soft-cleanup window are no longer load-bearing: - `seedWorkbenchHomeOn` no longer scans for a legacy random-uuid Home in the same Space and defers to it. It just no-ops on a present deterministic-id row, otherwise inserts. The corresponding three transitional unit tests are dropped. - `(app)/+layout.svelte` no longer runs a post-`reconcileSentinels` dedup sweep. The sweep was belt-and-suspenders for an edge case that the smart hook + deterministic id structurally prevent. D-hard via Dexie v50: soft-deletes every uncustomised "Home" row whose id is NOT `seed-home-`. The per-space-seeds registry recreates a fresh deterministic-id row on the next `setActiveSpace` for any Space that lost its uncustomised Home, so the system self- heals. Customised Homes (description / wallpaper / agent / scope tags) are preserved. Combined with v48 this leaves zero legacy duplicates and zero random-UUID seeds in `workbenchScenes` once the upgrade runs. New devices coming up against an empty IndexedDB walk through both upgrades as no-ops and land on the clean state directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 37 +++++++++++ .../src/lib/data/seeds/workbench-home.test.ts | 62 ------------------- .../web/src/lib/data/seeds/workbench-home.ts | 40 ++---------- .../apps/web/src/routes/(app)/+layout.svelte | 17 ----- 4 files changed, 41 insertions(+), 115 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index ec2db680c..02c65f22c 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1144,6 +1144,43 @@ db.version(49).stores({ comicCharacters: 'id, createdAt, style, isFavorite, isArchived', }); +// v50 — Final normalisation step for the workbench-Home seeding +// cleanup. Soft-deletes every uncustomised "Home" row whose id is NOT +// the deterministic `seed-home-`. After this upgrade runs, +// the only surviving Home rows in any Space are either user-customised +// (description / wallpaper / agent / scope) or already on the +// deterministic-id contract. The per-space-seeds registry recreates a +// fresh `seed-home-` row on the next `setActiveSpace` for +// any Space that lost its uncustomised Home, making the system self- +// healing. Together with v48 this leaves zero legacy duplicates or +// random-UUID seeds in the table. See +// docs/plans/workbench-seeding-cleanup.md. +db.version(50).upgrade(async (tx) => { + const now = new Date().toISOString(); + let removed = 0; + await tx + .table('workbenchScenes') + .toCollection() + .modify((row: Record) => { + if (row.deletedAt) return; + if (row.name !== 'Home') return; + if (typeof row.id !== 'string' || row.id.startsWith('seed-home-')) return; + if (row.description) return; + if (row.wallpaper) return; + if (row.viewingAsAgentId) return; + const scope = row.scopeTagIds; + if (Array.isArray(scope) && scope.length > 0) return; + row.deletedAt = now; + row.updatedAt = now; + removed += 1; + }); + if (removed > 0) { + console.info( + `[workbench-scenes v50] retired ${removed} legacy Home rows to deterministic-id contract` + ); + } +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module 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 index a5e907ba6..4e14a9bfa 100644 --- 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 @@ -103,68 +103,6 @@ describe('seedWorkbenchHomeOn', () => { expect(row?.openApps).toEqual([{ appId: 'todo' }, { appId: 'mood' }, { appId: 'meditate' }]); }); - it('defers to a legacy random-uuid Home in the same Space (transitional)', async () => { - // Simulates a user coming from the pre-deterministic-id world — - // e.g. a Schicht D-soft dedup survivor with a random UUID and - // the default openApps shape. The new seeder must NOT create a - // second deterministic-id row alongside it, otherwise +layout's - // dedup pass would just churn through soft-deleting one of them. - await db.workbenchScenes.add({ - id: 'legacy-random-uuid-1234', - name: 'Home', - order: 0, - openApps: [{ appId: 'todo' }, { appId: 'calendar' }, { appId: 'notes' }], - createdAt: '2026-04-23T08:00:00.000Z', - updatedAt: '2026-04-23T08:00:00.000Z', - spaceId: 'space-abc', - } as LocalWorkbenchScene & { spaceId: string }); - - const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); - expect(inserted).toBe(false); - - const all = await db.workbenchScenes.toArray(); - expect(all).toHaveLength(1); - expect(all[0].id).toBe('legacy-random-uuid-1234'); - }); - - it('still seeds when the existing Home is in a different Space', async () => { - await db.workbenchScenes.add({ - id: 'legacy-random-uuid-other-space', - name: 'Home', - order: 0, - openApps: DEFAULT_HOME_APPS, - createdAt: '2026-04-23T08:00:00.000Z', - updatedAt: '2026-04-23T08:00:00.000Z', - spaceId: 'space-different', - } as LocalWorkbenchScene & { spaceId: string }); - - const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); - expect(inserted).toBe(true); - - const seeded = await db.workbenchScenes.get('seed-home-space-abc'); - expect(seeded).toBeDefined(); - }); - - it('still seeds when the existing Home in this Space is customised', async () => { - // A user-customised "Home" (renamed-back, with a description or - // wallpaper) shouldn't block fresh seeds — the dedup heuristic - // already excludes such rows from merging, so the symmetric - // behaviour here is to seed anyway. Schicht D-hard will normalise. - await db.workbenchScenes.add({ - id: 'user-custom-home', - name: 'Home', - description: 'My personal layout', - order: 0, - openApps: [{ appId: 'todo' }], - createdAt: '2026-04-23T08:00:00.000Z', - updatedAt: '2026-04-23T08:00:00.000Z', - spaceId: 'space-abc', - } as LocalWorkbenchScene & { spaceId: string }); - - const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc'); - expect(inserted).toBe(true); - }); - it('seeds independently per Space (no cross-pollination)', async () => { await seedWorkbenchHomeOn(db.workbenchScenes, 'space-A'); await seedWorkbenchHomeOn(db.workbenchScenes, 'space-B'); 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 index 450665d0c..8838780e4 100644 --- a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts +++ b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts @@ -8,14 +8,13 @@ * 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. + * regardless of how the boot/replay timing shakes out. * * 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". + * See docs/plans/workbench-seeding-cleanup.md. */ import type { Table } from 'dexie'; @@ -49,18 +48,9 @@ export function workbenchHomeSeedId(spaceId: string): string { /** * 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 an existing Home is honoured. The creating-hook stamps the - * actor / timestamps fields; this function only owns the + * when the deterministic-id row was already there. The creating-hook + * stamps the actor / timestamps fields; this function only owns the * deterministic-id + default-shape contract. - * - * Two reasons we may skip the insert: - * 1. The deterministic-id row already exists — the structural - * idempotency case. Re-running for the same Space is a no-op. - * 2. A legacy random-uuid Home already exists for this Space — the - * transitional case for users coming from the pre-deterministic - * world (Schicht D-soft survivors). We defer to the user's - * existing layout. Schicht D-hard will rename such rows to the - * deterministic id and this branch becomes dead code. */ export async function seedWorkbenchHomeOn( table: Table, @@ -69,28 +59,6 @@ export async function seedWorkbenchHomeOn( const id = workbenchHomeSeedId(spaceId); if (await table.get(id)) return false; - // Transitional check: a Home scene already exists for this Space - // under a different (legacy random) id. Skipping here avoids an - // unnecessary create-then-soft-delete roundtrip via the dedup pass - // in `+layout.svelte`, which would otherwise pick the customised - // legacy row as the survivor and nuke our just-inserted seed. - // Looks at the same "uncustomised default seed" shape the dedup - // function uses, so a deliberately-named "Home" with description / - // wallpaper / agent / scope still triggers a fresh seed. - const legacy = await table - .filter((r) => { - if (r.deletedAt) return false; - if (r.name !== 'Home') return false; - if ((r as { spaceId?: unknown }).spaceId !== spaceId) return false; - if (r.description) return false; - if (r.wallpaper) return false; - if (r.viewingAsAgentId) return false; - if (r.scopeTagIds && r.scopeTagIds.length > 0) return false; - return true; - }) - .first(); - if (legacy) return false; - const now = new Date().toISOString(); const row: LocalWorkbenchScene & { spaceId: string } = { id, diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index bc4e1eeb2..1dada000f 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -620,23 +620,6 @@ if (rewritten > 0) { console.info(`[spaces] reconciled ${rewritten} sentinel records to active space`); } - - // Belt-and-suspenders dedup of duplicate "Home" workbench - // scenes. The Dexie v48 upgrade already does one pass at - // schema-bump time; this second pass covers the edge case - // where reconcileSentinels just collapsed sentinel-stamped - // rows into the same space-id as already-reconciled rows, - // producing fresh duplicates. Idempotent — a no-op when - // nothing matches. The structural fix that prevents new - // duplicates ships separately, see - // docs/plans/workbench-seeding-cleanup.md. - const { dedupHomeScenesOn } = await import('$lib/data/scope/dedup-workbench-scenes'); - const dedupedCount = await db.transaction('rw', 'workbenchScenes', () => - dedupHomeScenesOn(db.table('workbenchScenes')) - ); - if (dedupedCount > 0) { - console.info(`[workbench-scenes] deduped ${dedupedCount} duplicate Home scenes`); - } } catch (err) { console.warn('[spaces] active-space boot failed — sync will use sentinel scope', err); }