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-<spaceId>`. 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:50:53 +02:00
parent a6c5397d10
commit fa71269fc8
4 changed files with 41 additions and 115 deletions

View file

@ -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-<spaceId>`. 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-<spaceId>` 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<string, unknown>) => {
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

View file

@ -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');

View file

@ -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<LocalWorkbenchScene, string>,
@ -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,

View file

@ -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);
}