10 KiB
Workbench-Seeding — Cleanup & Architektur-Hardening
Status (2026-04-25)
Rückwirkende Aufräumarbeit für workbenchScenes. Adressiert einen Race-Condition-Bug, der seit dem Space-Migration-Sweep (2026-04-22) bei jedem Login zusätzliche "Home"-Scenes anlegt — und nimmt die Gelegenheit, die ganze Bug-Klasse strukturell zu eliminieren.
Symptom
User-Reports: "viele Home-Scenes mit den immer gleichen Apps offen". Im IndexedDB der Personal-Space-Workbench kumulieren sich name='Home'-Scenes über die Sessions hinweg.
Bug-Analyse — Warum es passiert
Drei Ursachen-Schichten greifen ineinander:
1. Footgun im Creating-Hook (database.ts:1330)
Wenn ein neuer Record auf einer space-scoped Tabelle ohne spaceId geschrieben wird, stempelt der Hook automatisch spaceId = '_personal:<userId>' (Sentinel). Ursprünglich als Migrations-Brücke für v28 gedacht, verschluckt das heute jeden Code-Pfad, der vergisst die spaceId explizit zu setzen.
2. Drei verteilte Seeding-Pfade (workbench-scenes.svelte.ts)
- Z. 293-296:
count === 0→ensureSeedScene()ininitialize() - Z. 305-326:
onActiveSpaceChangedReplay-on-Register feuert sofort → checkr.spaceId === space.id→ensureSeedScene() - Z. 305-326 erneut: Bei jedem späteren Space-Wechsel
ensureSeedScene() setzt kein spaceId → der Hook stempelt Sentinel → der Dedup-Filter sucht aber nach echter Space-UUID → schlägt immer fehl → seedet immer wieder.
3. Idempotenz auf zufälligen UUIDs
Jeder Seed bekommt crypto.randomUUID(). Das macht "ist schon da?"-Checks von Inhalts-Vergleichen abhängig statt von Primary-Key-Constraints. Jeder Race produziert eine neue Row, weil die DB nichts zu blocken hat.
Race-Mechanik
+layout.svelte:611 startet loadActiveSpace().then(reconcileSentinels) parallel zu +page.svelte:69 workbenchScenesStore.initialize(). reconcileSentinels rewrited Sentinel-Rows pro Boot einmal zur echten Personal-Space-UUID. Mid-Session-Seeds (nach Reconcile geschrieben) bleiben bei Sentinel und werden erst beim NÄCHSTEN Boot reconciled. Resultat: jede Session fügt Duplikate hinzu.
Brand/Family/Team-Spaces verstärken den Effekt: jeder Wechsel dorthin findet keine Scene unter der Brand-UUID → seedet — landet aber per Sentinel-Stamping unter Personal-Space. Personal-Workbench füllt sich bei jedem Wechsel in einen anderen Space.
Best-Practice-Lösung in vier Schichten
Statt nur den Symptom-Patch (spaceId durchreichen) werden die unterliegenden Footguns alle adressiert, sodass die ganze Bug-Klasse strukturell unmöglich wird.
Schicht D-soft — Bestehende Duplikate aufräumen
Ziel: alle bereits angesammelten Home-Duplikate auf eine Survivor-Row pro Space reduzieren, ohne user-customisierte Scenes anzutasten.
- Neue Datei
data/scope/dedup-workbench-scenes.tsexportiertdedupHomeScenes(): Promise<number>. - Logik:
- Alle nicht-tombstoned Rows lesen, gruppieren nach
(spaceId, name). - Nur Gruppen mit
name === 'Home'UNDlength > 1UND keiner Row mitdescription/wallpaper/viewingAsAgentId/scopeTagIds(User-Customisierungen). - Survivor-Pick: meiste
openApps, dann jüngstesupdatedAt. - Merge: alle
openAppsder Verlierer (perappIddeduped) in den Survivor übernehmen — nichts geht verloren. - Verlierer soft-deleten (
deletedAt = now) damit mana-sync den Cleanup an andere Geräte propagiert.
- Alle nicht-tombstoned Rows lesen, gruppieren nach
- Aufruf-Stellen (idempotent — doppelter Lauf ist no-op):
- Dexie v48 upgrade in
database.ts. Läuft genau einmal pro Device beim Schema-Bump. +layout.sveltehandleAuthReadydirekt nachreconcileSentinels(). Fängt den Edge-Case wo Sentinel-Rows nach dem Reconcile in die UUID-Gruppe wandern und dort neue Duplikate bilden.
- Dexie v48 upgrade in
- Tests:
data/scope/dedup-workbench-scenes.test.tsdeckt: identische Duplikate → 1 Survivor; openApps-Merge dedupt nachappId; verschiedene Spaces bleiben getrennt; user-customisierte Scenes (customdescription,wallpaper,viewingAsAgentId) bleiben unangetastet; non-Home-Namen bleiben unangetastet.
Schicht B + C — Zentrale Per-Space-Seeder-Registry mit deterministischen IDs
Ziel: alle Race-Pfade durch einen idempotenten Seeding-Eintrittspunkt ersetzen.
- Neues Modul
data/scope/per-space-seeds.ts:type Seeder = (spaceId: string) => Promise<void>; const seeders = new Map<string, Seeder>(); export function registerSpaceSeed(name: string, fn: Seeder): void; export async function runSpaceSeeds(spaceId: string): Promise<void>; - Aufrufer-Hook:
setActiveSpace()inactive-space.svelte.tsruft nachnotifyHandlers(space)ein einzigesvoid runSpaceSeeds(space.id). - Workbench-Modul registriert sich per Side-Effect-Import (bestehendes Muster wie bei
seed-registry.ts):registerSpaceSeed('workbench-home', async (spaceId) => { const id = `seed-home-${spaceId}`; await db.workbenchScenes.put({ id, spaceId, name: 'Home', openApps: DEFAULT_HOME_APPS, ... }); }); - Deterministische ID
seed-home-${spaceId}macht den Seed nativ idempotent: zweite Ausführung überschreibt Bit-für-Bit identisch, kein Duplikat möglich. Race-Conditions strukturell ausgeschlossen. - Aus
workbench-scenes.svelte.tsentfernen:- Z. 293-296
count === 0Block ininitialize(). - Z. 305-326
onActiveSpaceChanged-Handler (nur den Seed-Block, der LS-Read bleibt). ensureSeedScene()Funktion (nicht mehr nötig).
- Z. 293-296
Schicht A — Hook wirft statt Sentinel zu stempeln
Ziel: vergessene spaceId-Sets als hard-fail statt silent-corruption.
database.ts:1330umstellen: wennspaceIdundefined/null AND Tabelle nicht inUSER_LEVEL_TABLES:throw new Error( `[scope] write to space-scoped table '${tableName}' without spaceId. ` + `Set spaceId explicitly or move the table to USER_LEVEL_TABLES.` );reconcileSentinelsdarf bleiben (rewriten historischer Sentinel-Daten weiter, falls vorhanden) — neue Writes sehen den Sentinel-Pfad nie mehr.- Erwartet: deckt 2-3 stille Bugs in anderen Modulen auf, die seit der v28-Migration unbemerkt durchgelaufen sind. Genau deshalb ist die Schicht wertvoll.
- Risk-Mitigation: vor Schicht A einen Audit-Lauf (
grep+ Code-Review) der bestehenden.add(-Stellen über alle Module — wer setztspaceIdnicht? Diese Stellen vorab fixen.
Schicht D-hard — Cleanup als Schema-Invariante festschreiben
Ziel: den deterministischen Seed-ID-Vertrag im Code als fest erwarteten Zustand verankern.
- 1-2 Tage nach Schicht B+C+A. Soak-Zeit, damit alle Devices via Sync den dedup'ten State sehen.
- Dexie v49 (oder höher) Migration: alle
workbenchScenesmitname === 'Home'und ohne ID-Prefixseed-home-umbenennen aufseed-home-${spaceId}. Falls Konflikt mit existierendem deterministischen Survivor: alte Row löschen. - Code-Annahme: queries dürfen ab hier
db.workbenchScenes.get(\seed-home-${spaceId}`)` direkt benutzen, ohne By-Name-Filter-Fallback.
Reihenfolge — final state (alles SHIPPED)
Die ursprüngliche Vier-Schichten-Sequenz wurde während der Umsetzung zu einer einfacheren Architektur kondensiert: weil der Hook smart sein kann (statt nur Sentinel zu stempeln), entfällt die "throw on missing"-Variante und damit auch der Etappe-2-Soak.
- ✅ Schicht D-soft —
d62ae8f1e(Dexie v48 dedup + +layout post-reconcile dedup) - ✅ Schicht B + C —
c73f93ff1(per-space-seeds Registry, deterministic Home id, store-stripped) +568d79dc1(transitional legacy-Home check + wiring integration test) - ✅ Schicht A Etappe 1 —
43bef2b24(Helper + 16 explicit stamps, soft phase) - ✅ Schicht A — final —
a6c5397d1(smart hook stempelt aktive Space-UUID statt Sentinel; 16 explicit stamps reverted weil redundant) - ✅ Schicht D-hard + Cleanup —
fa71269fc(v50 löscht legacy non-deterministic Home rows, transitional check aus Seeder raus, post-reconcile dedup aus +layout raus) - ✅ Polish-Pass 2026-04-26 —
e930a66ff(v48 dedup-Logik inline, dedup-Helper-Modul + 220 Test-Zeilen gelöscht;seedWorkbenchHomeOn → Promise<void>; comment-vocab bereinigt) +8c5f064b0(direkter Smart-Hook-Test indata/space-stamping.test.ts+ Per-Space-Seeds-Pattern inapps/mana/CLAUDE.mddokumentiert)
Endzustand: eine Stelle entscheidet über Tenancy-Stamping (der Hook), deterministische IDs garantieren Idempotenz, kein transitional code mehr, kein post-reconcile fallback mehr, kein Dead-Code-Helper mehr, direkter Test für den Hook — exakt das was "saubere Lösung ohne legacy reste" verlangt.
Erfolgskriterien
- Nach D-soft: User sieht in jedem Space genau eine
Home-Scene mit allen openApps gemerged. Andere Custom-Scenes unverändert. Sync propagiert Cleanup an alle Devices. - Nach B+C: Login → keine neuen Duplikate, egal welche Race-Reihenfolge. Space-Wechsel zu fremdem Space erstellt Home-Scene dort, nicht im Personal-Space.
- Nach A: jeder unbeabsichtigte
add()ohnespaceIdschlägt mit klarer Error-Message fehl. - Nach D-hard: deterministische Seed-IDs sind der einzige Weg "Home" in DB zu finden.
Risiken & Mitigations
- D-soft soft-deletes durch sync gepullt → andere Devices sehen plötzlich weniger Scenes. Erwünscht — dedup ist genau der Zweck. Sync handelt soft-deletes via
deletedAtkorrekt. - D-soft falsch-positiv: User hat zwei legitime "Home"-Scenes manuell angelegt → die Heuristik ("kein description/wallpaper/agent/scope") schließt customisierte Rows aus. Reine Default-Duplikate werden gemerged. Edge-Case: User hat zwei identische Scenes "Home" beide mit Default-Apps absichtlich angelegt — sehr unwahrscheinlich, und Konsequenz (Merge auf eine Row mit Union der Apps) ist benign.
- B+C: Schicht-A Wirkung vorgezogen — wenn B+C zuerst kommt, fixed der Workbench-Path den Bug; aber andere Module könnten weiterhin still falsch stempeln. Akzeptabel, weil B+C den User-sichtbaren Bug schließt.
- A: Bestehende Tests, die ohne
spaceIdschreiben → Audit-Schritt vor A.vitest rundeckt's auf.
Out-of-Scope
- Server-truthed Scene-Creation (mana-sync seedet auf Space-Create direkt in PG). Bricht local-first für nur einen Use-Case — nicht der richtige Tradeoff fürs Daten-Modell.
- Vereinheitlichung mit Workbench-Templates-Apply-Pattern (bereits ähnliche Seed-Handler-Registry in
apply-template.ts). Spannend, aber nicht Teil dieses Plans.