From ae6a14fb766c823d86216df82716903bf165d172 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 27 Apr 2026 01:44:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(shared-ai):=20SYSTEM=5FBOOTSTRAP=20system?= =?UTF-8?q?=20source=20=E2=80=94=20fallback=20inserts=20now=20stamp=20orig?= =?UTF-8?q?in=3D'system'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The race-window `getOrCreateLocalDoc()` fallback in userContextStore + kontextStore stays (without it, a write that lands between "endpoint provisioned the singleton in mana_sync" and "first pull landed it in IndexedDB" would hit `update(missing-id, diff)` — a Dexie no-op that silently swallows the user's edit). But it was semantically lying: the insert stamped `origin='user'` even though the row is logically a client-side replica of the server-side bootstrap. This commit adds `SYSTEM_BOOTSTRAP = 'system:bootstrap'` to `@mana/shared-ai` and wraps the two fallback inserts in `runAsAsync(makeSystemActor(SYSTEM_BOOTSTRAP), ...)`. The Dexie hook now stamps `origin: 'system'` on the empty-row insert — structurally identical to the row mana-auth's bootstrap-singletons.ts writes. When the server's pull arrives later both sides carry the same origin and the conflict-gate stays quiet. The user's subsequent writes still stamp `origin: 'user'` on the changed fields. Plan: docs/plans/sync-field-meta-overhaul.md (F4-fu Fallback-Origin row). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/data/DATA_LAYER_AUDIT.md | 1 + .../modules/kontext/stores/kontext.svelte.ts | 20 ++++++--- .../profile/stores/user-context.svelte.ts | 42 +++++++++++++------ docs/plans/sync-field-meta-overhaul.md | 3 +- packages/shared-ai/src/actor.ts | 14 ++++++- packages/shared-ai/src/index.ts | 1 + 6 files changed, 61 insertions(+), 20 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md b/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md index e2d8c39aa..7d0aa5f48 100644 --- a/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md +++ b/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md @@ -207,6 +207,7 @@ Sieben Phasen, die vier strukturelle Bugs in der Conflict-Detection abgeräumt h - **F7** (`2a8e8ff98`) `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht — pre-live, keine Live-Daten brauchen sie. Orphan-localStorage-Flags-Sweep im Boot (`migrations-cleanup.ts`, `119cd2cf8`) räumt die zugehörigen Flags auf. - **F3-fu (v55 cleanup)** (_pending_) Dexie v55 row-rewrite: löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3+F5 liest niemand mehr `row.updatedAt`, also pure waste. Idempotent — rows ohne das Feld sind ein no-op. - **F4-robust (Endpoint)** (_pending_) Bootstrap-Funktionen idempotent (existence-check) + expliziter Endpoint `POST /api/v1/me/bootstrap-singletons`. Webapp callt ihn auf Boot vor `createUnifiedSync`. Signup-Hooks bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders, sodass eine transient mana_sync-Outage beim Signup nicht den User mit `getOrCreateLocalDoc()`-Race auf erstem Write strandet. +- **F4-fu (Fallback-Origin)** (_pending_) `getOrCreateLocalDoc()` in userContextStore + kontextStore in `runAsAsync(SYSTEM_BOOTSTRAP)` gewrappt — Insert stempelt jetzt `origin='system'` statt `origin='user'`, strukturell äquivalent zum Server-Bootstrap. Neue Konstante `SYSTEM_BOOTSTRAP` in `@mana/shared-ai`. Race-Window-Fallback bleibt notwendig (sonst Silent-Loss via `update(missing-id, diff)`), aber ist jetzt nicht mehr semantisch fragwürdig. Die vier Bug-Wurzeln (siehe ursprüngliche Diagnose 2026-04-26): diff --git a/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts b/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts index 97fc00b2b..25f3c72df 100644 --- a/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts @@ -20,19 +20,25 @@ import { kontextDocTable } from '../collections'; import { encryptRecord, decryptRecords } from '$lib/data/crypto'; import { scopedTable } from '$lib/data/scope/scoped-db'; +import { makeSystemActor, SYSTEM_BOOTSTRAP } from '@mana/shared-ai'; +import { runAsAsync } from '$lib/data/events/actor'; import type { LocalKontextDoc } from '../types'; +const BOOTSTRAP_ACTOR = makeSystemActor(SYSTEM_BOOTSTRAP); + async function findForActiveSpace(): Promise { const rows = await scopedTable('kontextDoc').toArray(); return rows.find((r) => !r.deletedAt); } /** - * Fallback path for the rare race where a write happens before the - * server-bootstrap row has reached the client. Materialises the row - * locally so `setContent` / `appendContent` always have something to - * update. The server-bootstrapped row will arrive on the next pull and - * field-LWW collapses any concurrent writes. + * Race-window fallback for the narrow window between "server bootstrap + * provisioned the row in mana_sync" and "first pull landed it in + * IndexedDB". Without this, `setContent` / `appendContent` would hit + * `update(missing-id, diff)` — a Dexie no-op that silently swallows the + * write. Insert is stamped `origin: 'system'` (via SYSTEM_BOOTSTRAP) + * so the server's bootstrap pull won't fight with it. Subsequent + * `setContent` writes stamp `origin: 'user'` as usual. */ async function getOrCreateLocalDoc(): Promise { const existing = await findForActiveSpace(); @@ -42,7 +48,9 @@ async function getOrCreateLocalDoc(): Promise { content: '', }; await encryptRecord('kontextDoc', newLocal); - await kontextDocTable.add(newLocal); + await runAsAsync(BOOTSTRAP_ACTOR, async () => { + await kontextDocTable.add(newLocal); + }); // Reload — the creating-hook stamped spaceId/authorId/actor fields. const created = await kontextDocTable.get(newLocal.id); if (!created) throw new Error('Failed to create kontextDoc'); diff --git a/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts b/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts index 6924d2322..56142dd48 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts @@ -6,17 +6,31 @@ * * Singleton bootstrap (F4 of docs/plans/sync-field-meta-overhaul.md): * the per-user `userContext` row is created server-side by mana-auth at - * `/register` time. The first sync pull lands the row before the UI ever - * tries to read it. The internal `getOrCreateLocalDoc()` helper below is - * a *fallback* — it inserts an empty doc on a brand-new client whose - * pull hasn't caught up yet. Any user edits made in that window stamp - * `origin: 'user'` via the Dexie hook, and the F2 conflict-gate makes - * sure the server's `origin: 'system'` bootstrap row never overwrites - * them silently. + * `/register` time AND reconciled on every boot via + * `POST /api/v1/me/bootstrap-singletons` (Punkt 3 follow-up). The first + * sync pull lands the row before the UI ever tries to read it. + * + * The internal `getOrCreateLocalDoc()` helper below stays as a fallback + * for the narrow race window between "endpoint provisioned the row in + * mana_sync" and "first pull landed the row in IndexedDB". Without it, + * a write that lands inside that window would hit `update(SINGLETON_ID, + * diff)` against a missing key — a Dexie no-op that silently swallows + * the user's edit. Killing the fallback was on the post-overhaul audit + * list (Punkt 4) but kept here because the silent-loss failure mode is + * worse than the cosmetic "client also writes the bootstrap row". + * + * The fallback insert is wrapped in `runAsAsync(makeSystemActor( + * SYSTEM_BOOTSTRAP))` so the Dexie hook stamps `origin: 'system'` + * (mirroring the server bootstrap), not `origin: 'user'`. When the + * server's pull arrives later both rows carry the same origin and the + * conflict-gate stays quiet. The user's subsequent writes stamp + * `origin: 'user'` for the changed fields as usual. */ import { userContextTable } from '../collections'; import { encryptRecord, decryptRecords } from '$lib/data/crypto'; +import { makeSystemActor, SYSTEM_BOOTSTRAP } from '@mana/shared-ai'; +import { runAsAsync } from '$lib/data/events/actor'; import { USER_CONTEXT_SINGLETON_ID, emptyUserContext, @@ -28,16 +42,20 @@ import { type UserContextSocial, } from '../types'; -/** Internal fallback: write a fresh empty doc if neither the server - * bootstrap (F4) nor any prior session has populated the singleton - * yet. Mutating store methods call this first so a brand-new client - * that hasn't completed its first pull can still accept edits. */ +const BOOTSTRAP_ACTOR = makeSystemActor(SYSTEM_BOOTSTRAP); + +/** Race-window fallback: write a fresh empty doc if neither the server + * bootstrap (F4 + Punkt 3 endpoint) nor any prior session has landed + * the singleton yet. The insert is stamped `origin: 'system'` so the + * server's eventual bootstrap pull won't fight with it. */ async function getOrCreateLocalDoc(): Promise { const existing = await userContextTable.get(USER_CONTEXT_SINGLETON_ID); if (existing) return; const doc = emptyUserContext() as LocalUserContext; await encryptRecord('userContext', doc); - await userContextTable.add(doc); + await runAsAsync(BOOTSTRAP_ACTOR, async () => { + await userContextTable.add(doc); + }); } async function readDecrypted(): Promise { diff --git a/docs/plans/sync-field-meta-overhaul.md b/docs/plans/sync-field-meta-overhaul.md index 0498a9c1b..20e8e44bc 100644 --- a/docs/plans/sync-field-meta-overhaul.md +++ b/docs/plans/sync-field-meta-overhaul.md @@ -154,7 +154,8 @@ _Wird befüllt während der Ausführung._ | F7 | `2a8e8ff98` | `repair-silent-twin.ts` + `legacy-avatar.ts` Migrationen ersatzlos gelöscht. Beide existierten nur um die Symptome eines fixed-in-M2.5 Bugs zu cleanen, der pre-live keine echten Daten produziert hat. Mit F2's `origin='migration'` wrapper + F3's drop von synced `updatedAt` würden ihre writes auch nicht mehr als Conflicts auftauchen — sie waren strukturell überflüssig. Caller in MeImagesView + wardrobe/ListView entfernt; leere `migration/` Directory gelöscht. | | F4-fu (kontextDoc) | `3df739190` | F4-Symmetrie: `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in `bootstrap-singletons.ts`, schreibt einen leeren `kontext/kontextDoc` row pro Space-Erstellung in `mana_sync.sync_changes` mit `client_id='system:bootstrap'`, `origin='system'`. Zwei Aufruf-Sites: `databaseHooks.user.create.after` (Personal-Space; nur wenn `createPersonalSpaceFor` `created: true` zurückgibt) + `organizationHooks.afterCreateOrganization` (alle non-personal Spaces). `createBetterAuth` kriegt `syncDatabaseUrl` als zweites Argument; lazy module-scoped postgres-pool. Webapp `kontextStore.ensureDoc()` zu privat `getOrCreateLocalDoc()` umbenannt — Public-API ist nur noch `setContent` + `appendContent`. Kontext content bleibt encrypted at rest auf dem Client (`kontextDoc.content`); der Server-Bootstrap schreibt `''` plaintext, was im Client-`decryptRecord` toleriert wird (non-envelope strings werden durchgereicht). | | F3-fu (v55 cleanup) | `53fecbf4a` | Dexie v55 löscht den Orphan-`updatedAt`-Wert aus jedem Row in `Object.keys(TABLE_TO_APP)`. v53 hatte ihn bewusst stehengelassen (Comment "next-version upgrade can drop it"); nach F3 liest niemand mehr `row.updatedAt` — `deriveUpdatedAt(local)` aus `__fieldMeta` ist die SSOT. Idempotent (delete missing field = no-op), best-effort (try/catch pro Tabelle für unbekannte/missing stores). | -| F4-robust (Endpoint) | _pending_ | F4-Bootstrap robuster gemacht via expliziten Endpoint `POST /api/v1/me/bootstrap-singletons` in mana-auth. Beide Bootstrap-Funktionen (`bootstrapUserSingletons`, `bootstrapSpaceSingletons`) sind jetzt idempotent (existence-check vor INSERT) und geben `boolean` zurück. Endpoint ruft beide auf — userContext für den Caller, kontextDoc für jeden Space, in dem der Caller Member ist. Webapp `(app)/+layout.svelte` callt den Endpoint einmal pro Boot vor `createUnifiedSync`, fire-and-forget am Client. Signup-Hooks (`databaseHooks.user.create.after`, `organizationHooks.afterCreateOrganization`) bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders. | +| F4-robust (Endpoint) | `099cac4a0` | F4-Bootstrap robuster gemacht via expliziten Endpoint `POST /api/v1/me/bootstrap-singletons` in mana-auth. Beide Bootstrap-Funktionen (`bootstrapUserSingletons`, `bootstrapSpaceSingletons`) sind jetzt idempotent (existence-check vor INSERT) und geben `boolean` zurück. Endpoint ruft beide auf — userContext für den Caller, kontextDoc für jeden Space, in dem der Caller Member ist. Webapp `(app)/+layout.svelte` callt den Endpoint einmal pro Boot vor `createUnifiedSync`, fire-and-forget am Client. Signup-Hooks (`databaseHooks.user.create.after`, `organizationHooks.afterCreateOrganization`) bleiben als happy-path; Endpoint ist Reconciliation belt-and-suspenders. | +| F4-fu (Fallback-Origin) | _pending_ | Punkt 4 abgeschwächt: `getOrCreateLocalDoc()` in userContextStore + kontextStore bleibt (Race zwischen "Endpoint provisioniert in mana_sync" und "First-Pull landet in IndexedDB" lässt sich nicht eliminieren — ohne Fallback würden Writes im Race-Window silently in `update(missing-id, diff)` no-ops verloren gehen). Aber: Fallback-Insert ist jetzt in `runAsAsync(makeSystemActor(SYSTEM_BOOTSTRAP), ...)` gewrappt. Neue Konstante `SYSTEM_BOOTSTRAP = 'system:bootstrap'` in `@mana/shared-ai`, mappt via `originFromActor` auf `origin='system'` — strukturell äquivalent zum Server-Bootstrap. Wenn der Server-Pull später ankommt, beide Rows tragen `origin: 'system'`, conflict-gate bleibt ruhig. User-Writes danach stempeln `origin: 'user'` wie immer. | ## F1 — Implementation Notes diff --git a/packages/shared-ai/src/actor.ts b/packages/shared-ai/src/actor.ts index 6f613de1c..2fb90e05d 100644 --- a/packages/shared-ai/src/actor.ts +++ b/packages/shared-ai/src/actor.ts @@ -33,13 +33,23 @@ export const SYSTEM_RULE = 'system:rule'; export const SYSTEM_MIGRATION = 'system:migration'; export const SYSTEM_STREAM = 'system:stream'; export const SYSTEM_MISSION_RUNNER = 'system:mission-runner'; +/** + * Client-side singleton bootstrap. Stamped on the rare race-window + * `getOrCreateLocalDoc()` insert in `userContextStore` / `kontextStore` + * — a structural twin of mana-auth's server-side bootstrap (which uses + * the `'system:bootstrap'` principalId on the wire). Maps to + * `origin='system'` via `originFromActor`, so the conflict-gate exempts + * it from the user-write codepath. + */ +export const SYSTEM_BOOTSTRAP = 'system:bootstrap'; export type SystemSource = | typeof SYSTEM_PROJECTION | typeof SYSTEM_RULE | typeof SYSTEM_MIGRATION | typeof SYSTEM_STREAM - | typeof SYSTEM_MISSION_RUNNER; + | typeof SYSTEM_MISSION_RUNNER + | typeof SYSTEM_BOOTSTRAP; /** Legacy sentinels for records that pre-date the identity-aware actor * shape. Read-path normalization maps missing fields to these. */ @@ -141,6 +151,8 @@ function defaultSystemDisplayName(source: SystemSource): string { return 'Event-Stream'; case SYSTEM_MISSION_RUNNER: return 'Mission-Runner'; + case SYSTEM_BOOTSTRAP: + return 'Bootstrap'; } } diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index 7b607018c..bae608b9c 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -22,6 +22,7 @@ export { SYSTEM_MIGRATION, SYSTEM_STREAM, SYSTEM_MISSION_RUNNER, + SYSTEM_BOOTSTRAP, LEGACY_USER_PRINCIPAL, LEGACY_AI_PRINCIPAL, LEGACY_SYSTEM_PRINCIPAL,