refactor(workbench-seeding): inline v48 dedup, drop dead helper module

The `dedupHomeScenesOn` helper in `data/scope/dedup-workbench-scenes.ts`
existed only to be called once from the v48 Dexie upgrade — outside of
that single usage it was dead code. Inlining the logic directly into
the upgrade callback eliminates a 120-line module + a 220-line test
file (343 lines net) without changing behaviour: the v48 upgrade still
collapses uncustomised "Home" duplicates per (spaceId, name='Home'),
merges openApps, and soft-deletes losers.

Drive-by tightening:

- `seedWorkbenchHomeOn` returns `Promise<void>` instead of
  `Promise<boolean>`. The boolean was only consumed by the
  post-`reconcileSentinels` dedup pass that already got removed; the
  current callers (registry seeder + tests) don't read it. Less
  signature surface, fewer assertions in tests.
- `data/scope/per-space-seeds.ts` comment header drops the
  plan-internal "Schicht B + C" reference for a plain link to the
  cleanup plan. Code-level vocabulary now reads cleanly without the
  rollout-sequencing context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 19:33:46 +02:00
parent 3d30e39ae7
commit e930a66ff3
6 changed files with 74 additions and 370 deletions

View file

@ -1097,24 +1097,74 @@ db.version(47).stores({
augurEntries: 'id, kind, outcome, vibe, sourceCategory, encounteredAt, expectedBy, isArchived',
});
// v48 — One-shot dedup of duplicate "Home" scenes that the seeding race
// in `stores/workbench-scenes.svelte.ts` has been accumulating since the
// Spaces-Foundation migration shipped 2026-04-22. The seeder writes new
// scenes without `spaceId`, so the creating-hook stamps them with the
// `_personal:<userId>` sentinel. The dedup check in
// `onActiveSpaceChanged` filters by the *real* space UUID and never
// finds them — every login adds another Home row.
// v48 — One-shot dedup of duplicate "Home" scenes the seeding race
// accumulated before the per-space-seeds registry shipped. The old
// seeder wrote rows without `spaceId`, so the creating-hook stamped
// them with the personal sentinel and the per-Space dedup check
// (filtering by real Space UUID) never found them — every login added
// another Home row.
//
// This upgrade is the soft cleanup. The structural fix (per-space-seeds
// registry + deterministic ids + creating-hook hardening) ships in
// follow-up commits — see docs/plans/workbench-seeding-cleanup.md.
//
// No schema/index change. The upgrade only soft-deletes the loser rows
// (sets `deletedAt`) so mana-sync propagates the cleanup to other
// devices instead of resurrecting them on next pull.
// Collapses survivors per (spaceId, name='Home') by merging openApps
// (dedup by appId) and soft-deleting losers. User-customised Homes
// (description / wallpaper / agent / scope tags) are excluded from
// grouping so a deliberate two-Home setup stays intact. Survivor pick:
// most openApps wins, ties break by most-recent updatedAt. Mana-sync
// propagates the soft-deletes to other devices.
// See docs/plans/workbench-seeding-cleanup.md.
db.version(48).upgrade(async (tx) => {
const { dedupHomeScenesOn } = await import('./scope/dedup-workbench-scenes');
const removed = await dedupHomeScenesOn(tx.table('workbenchScenes'));
type Row = Record<string, unknown> & { id: string };
const rows = (await tx.table('workbenchScenes').toArray()) as Row[];
const groups = new Map<string, Row[]>();
for (const row of rows) {
if (row.deletedAt) continue;
if (row.name !== 'Home') continue;
if (row.description) continue;
if (row.wallpaper) continue;
if (row.viewingAsAgentId) continue;
const scope = row.scopeTagIds;
if (Array.isArray(scope) && scope.length > 0) continue;
const spaceId = row.spaceId;
if (typeof spaceId !== 'string' || !spaceId) continue;
let group = groups.get(spaceId);
if (!group) groups.set(spaceId, (group = []));
group.push(row);
}
const now = new Date().toISOString();
let removed = 0;
for (const group of groups.values()) {
if (group.length <= 1) continue;
group.sort((a, b) => {
const aLen = Array.isArray(a.openApps) ? a.openApps.length : 0;
const bLen = Array.isArray(b.openApps) ? b.openApps.length : 0;
if (aLen !== bLen) return bLen - aLen;
return String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''));
});
const [survivor, ...losers] = group;
const merged: unknown[] = Array.isArray(survivor.openApps) ? [...survivor.openApps] : [];
const seen = new Set(merged.map((a) => (a as { appId: string }).appId));
for (const loser of losers) {
const apps = Array.isArray(loser.openApps) ? loser.openApps : [];
for (const app of apps) {
const appId = (app as { appId: string }).appId;
if (!seen.has(appId)) {
seen.add(appId);
merged.push(app);
}
}
}
const survivorAppCount = Array.isArray(survivor.openApps) ? survivor.openApps.length : 0;
if (merged.length !== survivorAppCount) {
await tx.table('workbenchScenes').update(survivor.id, { openApps: merged, updatedAt: now });
}
for (const loser of losers) {
await tx.table('workbenchScenes').update(loser.id, { deletedAt: now, updatedAt: now });
removed += 1;
}
}
if (removed > 0) {
console.info(`[workbench-scenes v48] deduped ${removed} duplicate Home scenes`);
}

View file

@ -1,219 +0,0 @@
/**
* Unit tests for `dedupHomeScenesOn` the soft-cleanup pass that
* collapses duplicate "Home" scenes accumulated by the seeding race
* (see docs/plans/workbench-seeding-cleanup.md).
*
* Uses an isolated Dexie db with just a `workbenchScenes` table so the
* test doesn't drag in `database.ts`'s side-effect imports (auth store,
* triggers, funnel tracking, ) the function under test only needs a
* Table reference, so a one-table fixture is enough.
*/
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Dexie, { type Table } from 'dexie';
import type { LocalWorkbenchScene } from '$lib/types/workbench-scenes';
import { dedupHomeScenesOn } from './dedup-workbench-scenes';
// Public LocalWorkbenchScene doesn't carry the runtime-stamped scope
// fields (spaceId/authorId/visibility) — they're added by the creating
// hook. Tests need to set spaceId explicitly to drive grouping, so we
// model the row as the public shape plus an optional spaceId override.
type SceneRow = LocalWorkbenchScene & { spaceId?: string };
interface FixtureDb extends Dexie {
workbenchScenes: Table<SceneRow, string>;
}
let db: FixtureDb;
function makeDb(): FixtureDb {
const fresh = new Dexie(`dedup-test-${crypto.randomUUID()}`) as FixtureDb;
fresh.version(1).stores({ workbenchScenes: 'id, order' });
return fresh;
}
function makeScene(overrides: Partial<SceneRow>): SceneRow {
return {
id: 'scene-default',
name: 'Home',
openApps: [{ appId: 'todo' }, { appId: 'calendar' }, { appId: 'notes' }],
order: 0,
createdAt: '2026-04-25T10:00:00.000Z',
updatedAt: '2026-04-25T10:00:00.000Z',
spaceId: 'space-personal',
...overrides,
};
}
beforeEach(async () => {
db = makeDb();
await db.open();
});
afterEach(async () => {
db.close();
await Dexie.delete(db.name);
});
describe('dedupHomeScenesOn', () => {
it('returns 0 and changes nothing when there are no duplicates', async () => {
await db.workbenchScenes.add(makeScene({ id: 's1' }));
await db.workbenchScenes.add(makeScene({ id: 's2', spaceId: 'space-other' }));
const removed = await dedupHomeScenesOn(db.workbenchScenes);
expect(removed).toBe(0);
const remaining = await db.workbenchScenes
.toArray()
.then((rows) => rows.filter((r) => !r.deletedAt));
expect(remaining).toHaveLength(2);
});
it('keeps one survivor per (spaceId) group and soft-deletes the rest', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 's1', updatedAt: '2026-04-25T09:00:00.000Z' }),
makeScene({ id: 's2', updatedAt: '2026-04-25T10:00:00.000Z' }),
makeScene({ id: 's3', updatedAt: '2026-04-25T11:00:00.000Z' }),
]);
const removed = await dedupHomeScenesOn(db.workbenchScenes);
expect(removed).toBe(2);
const all = await db.workbenchScenes.toArray();
const alive = all.filter((r) => !r.deletedAt);
const dead = all.filter((r) => r.deletedAt);
expect(alive).toHaveLength(1);
expect(dead).toHaveLength(2);
});
it('picks the survivor with the most openApps, then most recent updatedAt', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({
id: 'older-richer',
openApps: [{ appId: 'todo' }, { appId: 'calendar' }, { appId: 'notes' }],
updatedAt: '2026-04-25T09:00:00.000Z',
}),
makeScene({
id: 'newer-leaner',
openApps: [{ appId: 'todo' }],
updatedAt: '2026-04-25T11:00:00.000Z',
}),
]);
await dedupHomeScenesOn(db.workbenchScenes);
const alive = await db.workbenchScenes
.toArray()
.then((rows) => rows.filter((r) => !r.deletedAt));
expect(alive.map((r) => r.id)).toEqual(['older-richer']);
});
it('merges openApps from losers into the survivor (dedup by appId)', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({
id: 'survivor',
openApps: [{ appId: 'todo' }, { appId: 'calendar' }, { appId: 'notes' }],
}),
makeScene({
id: 'loser-extra',
openApps: [{ appId: 'notes' }, { appId: 'mood' }],
}),
]);
await dedupHomeScenesOn(db.workbenchScenes);
const survivor = await db.workbenchScenes.get('survivor');
expect(survivor?.openApps?.map((a) => a.appId).sort()).toEqual([
'calendar',
'mood',
'notes',
'todo',
]);
});
it('keeps groups separate by spaceId — no cross-space merging', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 'a1', spaceId: 'space-A' }),
makeScene({ id: 'a2', spaceId: 'space-A' }),
makeScene({ id: 'b1', spaceId: 'space-B' }),
]);
const removed = await dedupHomeScenesOn(db.workbenchScenes);
expect(removed).toBe(1);
const alive = await db.workbenchScenes
.toArray()
.then((rows) => rows.filter((r) => !r.deletedAt));
expect(alive).toHaveLength(2);
expect(alive.map((r) => r.spaceId).sort()).toEqual(['space-A', 'space-B']);
});
it('leaves user-customized scenes alone (description / wallpaper / agent / scope)', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 's1' }),
makeScene({ id: 's2', description: 'Mein Workspace' }),
makeScene({ id: 's3', viewingAsAgentId: 'agent-1' }),
makeScene({ id: 's4', scopeTagIds: ['tag-1'] }),
]);
const removed = await dedupHomeScenesOn(db.workbenchScenes);
// s1 is the only mergeable row in its group of 1 → no removal.
expect(removed).toBe(0);
const alive = await db.workbenchScenes
.toArray()
.then((rows) => rows.filter((r) => !r.deletedAt));
expect(alive).toHaveLength(4);
});
it('leaves non-Home scenes alone even when duplicated by name', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 'd1', name: 'Deep Work' }),
makeScene({ id: 'd2', name: 'Deep Work' }),
]);
const removed = await dedupHomeScenesOn(db.workbenchScenes);
expect(removed).toBe(0);
});
it('skips already-tombstoned rows', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 's1' }),
makeScene({ id: 's2', deletedAt: '2026-04-24T10:00:00.000Z' }),
]);
const removed = await dedupHomeScenesOn(db.workbenchScenes);
// Only one live row in the group → no removal.
expect(removed).toBe(0);
const stillDeleted = await db.workbenchScenes.get('s2');
expect(stillDeleted?.deletedAt).toBe('2026-04-24T10:00:00.000Z');
});
it('is idempotent — running twice produces the same end state', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 's1' }),
makeScene({ id: 's2' }),
makeScene({ id: 's3' }),
]);
const firstRemoved = await dedupHomeScenesOn(db.workbenchScenes);
const secondRemoved = await dedupHomeScenesOn(db.workbenchScenes);
expect(firstRemoved).toBe(2);
expect(secondRemoved).toBe(0);
});
it('skips rows without a string spaceId (ambiguous group key)', async () => {
await db.workbenchScenes.bulkAdd([
makeScene({ id: 's1', spaceId: undefined }),
makeScene({ id: 's2', spaceId: undefined }),
]);
const removed = await dedupHomeScenesOn(db.workbenchScenes);
expect(removed).toBe(0);
});
});

View file

@ -1,123 +0,0 @@
/**
* Dedup pass for the `workbenchScenes` table collapses the duplicate
* "Home" scenes the seeding race in `workbench-scenes.svelte.ts` has been
* accumulating since the Spaces-Foundation migration shipped 2026-04-22.
*
* Background: the seeder writes rows without `spaceId`, so the Dexie
* creating-hook stamps `_personal:<userId>` (sentinel). The dedup check
* in `onActiveSpaceChanged` filters by the *real* space UUID and never
* finds them every login adds duplicates. Full root-cause + the
* upcoming structural fix (per-space-seeds registry + deterministic
* ids + creating-hook hardening) live in
* `docs/plans/workbench-seeding-cleanup.md`.
*
* This file is the soft cleanup: idempotent, content-aware, takes
* `name === 'Home'` rows that look like default seeds (no description /
* wallpaper / viewingAsAgentId / scopeTagIds i.e. nothing the user
* has customised), groups them by `spaceId`, picks one survivor per
* group, merges every loser's `openApps` into it, and soft-deletes the
* rest so mana-sync propagates the cleanup to other devices.
*
* Pure: takes a Dexie Table reference, never reaches into the live
* `db`. That keeps it import-cycle-free so it can run inside a
* `db.version(N).upgrade()` callback (where it gets `tx.table(...)`)
* AND from app-runtime callers (where they pass `db.table(...)`).
*/
import type { Table } from 'dexie';
import type { LocalWorkbenchScene, WorkbenchSceneApp } from '$lib/types/workbench-scenes';
const HOME_NAME = 'Home';
/**
* A scene is a candidate for merging when it looks like a fresh default
* "Home" seed anything the user might have set themselves disqualifies
* the row so we never destroy custom layouts.
*/
function isDefaultHomeSeed(row: LocalWorkbenchScene): boolean {
if (row.deletedAt) return false;
if (row.name !== HOME_NAME) return false;
if (row.description) return false;
if (row.wallpaper) return false;
if (row.viewingAsAgentId) return false;
if (row.scopeTagIds && row.scopeTagIds.length > 0) return false;
return true;
}
/**
* Run dedup on the given `workbenchScenes` table. Returns the number of
* rows soft-deleted. Idempotent safe to invoke repeatedly.
*
* The caller is expected to wrap this in a transaction when called
* outside of a Dexie `upgrade()` callback (upgrade callbacks already
* give a transaction-bound `tx.table()` reference).
*/
export async function dedupHomeScenesOn(
table: Table<LocalWorkbenchScene, string>
): Promise<number> {
const rows = await table.toArray();
// Bucket by spaceId. Rows without a spaceId can't be safely grouped
// (their target space is ambiguous) — skip them. Rows that look like
// user-customised scenes are also out, even if they happen to be
// named "Home", so a deliberate two-Home setup stays intact.
const groups = new Map<string, LocalWorkbenchScene[]>();
for (const row of rows) {
if (!isDefaultHomeSeed(row)) continue;
const spaceId = (row as { spaceId?: unknown }).spaceId;
if (typeof spaceId !== 'string' || !spaceId) continue;
let group = groups.get(spaceId);
if (!group) {
group = [];
groups.set(spaceId, group);
}
group.push(row);
}
const now = new Date().toISOString();
let removed = 0;
for (const group of groups.values()) {
if (group.length <= 1) continue;
// Survivor pick: the row with the most openApps wins (it's the
// most likely to carry the user's accumulated app additions),
// breaking ties by most-recent updatedAt.
group.sort((a, b) => {
const aLen = a.openApps?.length ?? 0;
const bLen = b.openApps?.length ?? 0;
if (aLen !== bLen) return bLen - aLen;
const aTime = a.updatedAt ?? '';
const bTime = b.updatedAt ?? '';
return bTime.localeCompare(aTime);
});
const [survivor, ...losers] = group;
// Merge every loser's openApps into the survivor, dedupe by
// appId so the user doesn't end up with two `todo` panels.
const merged: WorkbenchSceneApp[] = [...(survivor.openApps ?? [])];
const seen = new Set(merged.map((a) => a.appId));
for (const loser of losers) {
for (const app of loser.openApps ?? []) {
if (!seen.has(app.appId)) {
seen.add(app.appId);
merged.push(app);
}
}
}
const survivorAppCount = survivor.openApps?.length ?? 0;
if (merged.length !== survivorAppCount) {
await table.update(survivor.id, { openApps: merged, updatedAt: now });
}
// Soft-delete the losers via deletedAt so the unified sync engine
// propagates the dedup to other devices instead of resurrecting
// the rows on next pull.
for (const loser of losers) {
await table.update(loser.id, { deletedAt: now, updatedAt: now });
removed++;
}
}
return removed;
}

View file

@ -15,7 +15,7 @@
* 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".
* See docs/plans/workbench-seeding-cleanup.md.
*/
type Seeder = (spaceId: string) => Promise<void>;

View file

@ -61,8 +61,7 @@ describe('workbenchHomeSeedId', () => {
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);
await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc');
const row = await db.workbenchScenes.get('seed-home-space-abc');
expect(row).toMatchObject({
@ -78,8 +77,7 @@ describe('seedWorkbenchHomeOn', () => {
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);
await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc');
const all = await db.workbenchScenes.toArray();
expect(all).toHaveLength(1);

View file

@ -47,17 +47,16 @@ 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 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.
* for the given Space. No-ops when the deterministic-id row is already
* there. The creating-hook stamps actor + timestamps; this function
* only owns the deterministic-id + default-shape contract.
*/
export async function seedWorkbenchHomeOn(
table: Table<LocalWorkbenchScene, string>,
spaceId: string
): Promise<boolean> {
): Promise<void> {
const id = workbenchHomeSeedId(spaceId);
if (await table.get(id)) return false;
if (await table.get(id)) return;
const now = new Date().toISOString();
const row: LocalWorkbenchScene & { spaceId: string } = {
@ -70,7 +69,6 @@ export async function seedWorkbenchHomeOn(
spaceId,
};
await table.add(row);
return true;
}
registerSpaceSeed('workbench-home', async (spaceId) => {