feat(auth): bootstrap per-Space kontextDoc on Space-creation (F4 follow-up)

Symmetrically extends the F4 server-side singleton bootstrap to the
per-Space `kontextDoc`. Every Space-creation — Personal at signup and
brand/club/family/team/practice via the org plugin — now writes an empty
kontextDoc row straight into mana_sync.sync_changes with origin='system',
client_id='system:bootstrap'. Fresh clients pull the row instead of
racing on a local insert that the next pull would clobber.

- New `bootstrapSpaceSingletons(spaceId, ownerUserId, syncSql)` in
  services/mana-auth/src/services/bootstrap-singletons.ts; shared
  `buildFieldMeta` helper extracted.
- `createBetterAuth(databaseUrl, syncDatabaseUrl, webauthn)` now takes
  the sync-DB URL and lazy-creates a module-scoped postgres pool for
  the bootstrap inserts.
- Hook into `databaseHooks.user.create.after` (only on `created: true`
  from createPersonalSpaceFor) and `organizationHooks.afterCreateOrganization`.
- Webapp `kontextStore.ensureDoc()` made private as `getOrCreateLocalDoc()` —
  same fallback role as userContextStore's after F5. Public API is now just
  setContent + appendContent.

Plan: docs/plans/sync-field-meta-overhaul.md (F4-fu row in Shipping Log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 01:21:31 +02:00
parent bcf150ea16
commit 3df7391905
8 changed files with 197 additions and 66 deletions

View file

@ -102,7 +102,7 @@ The four bug-roots that made the conflict-toast fire spuriously have all been cl
- **`__fieldMeta`** (single hidden field per record, replaces the older `__fieldTimestamps` / `__fieldActors` / `__lastActor` triple). Shape: `{ [field]: { at, actor, origin } }`. The Dexie creating/updating hook stamps it on every write; consumers read it via `readFieldMeta()` and `deriveUpdatedAt()` from `$lib/data/sync`.
- **Origin-tracking**: `originFromActor(actor)` in `@mana/shared-ai` maps `actor.kind` onto `'user' | 'agent' | 'system' | 'migration' | 'server-replay'`. The conflict surface fires only when `localFieldMeta.origin === 'user'` — replay-deltas from server pulls, agent writes, migration helpers, and bootstrap inserts never surface as toasts.
- **`updatedAt` is no longer a synced data field.** Type-converters compute `updatedAt` on read as `max(__fieldMeta[*].at)` via `deriveUpdatedAt(local)`. For Dexie-indexed sort, every record carries a non-synced `_updatedAtIndex` shadow column that the hook stamps automatically — `orderBy('_updatedAtIndex')` instead of `orderBy('updatedAt')`.
- **Server-side singleton bootstrap**: mana-auth's `/register` flow writes the `userContext` singleton straight into `mana_sync.sync_changes` with `origin: 'system'`. The webapp's `getOrCreateLocalDoc()` survives only as a fallback for the rare race where the first pull hasn't landed yet.
- **Server-side singleton bootstrap**: mana-auth writes per-user and per-Space singletons straight into `mana_sync.sync_changes` with `origin: 'system'`. `userContext` (per-user) is bootstrapped from the `/register` flow; `kontextDoc` (per-Space) is bootstrapped from the personal-space hook in `databaseHooks.user.create.after` and from `organizationHooks.afterCreateOrganization` for every non-personal Space. The webapp's `getOrCreateLocalDoc()` survives in both stores only as a fallback for the rare race where the first pull hasn't landed yet.
- **Stable `client_id`**: Dexie table `_clientIdentity` (single row keyed by `id='self'`) is the canonical source of the per-device sync identity. `restoreClientIdFromDexie()` runs once at boot and reconciles localStorage ↔ Dexie — a localStorage wipe gets restored from Dexie, the server keeps seeing the same client.
When writing new code:

View file

@ -201,6 +201,7 @@ Sieben Phasen, die vier strukturelle Bugs in der Conflict-Detection abgeräumt h
- **F2** (`ad5e04a55`) Conflict-Trigger gated auf `localFieldMeta.origin === 'user' && !options.isInitialHydration`. `originFromActor()` in shared-ai mappt actor.kind → `'user' | 'agent' | 'system' | 'migration'`. applyServerChanges stempelt alle Replays als `'server-replay'`. Initial-Pull-Hydration suppress alle Conflicts (Belt-and-Suspenders).
- **F3** (`6bb9d77be`) `updatedAt` raus aus dem Wire. Read-side computed via `deriveUpdatedAt(record) = max(__fieldMeta[*].at)`. Lokale `_updatedAtIndex`-Schatten-Spalte für Dexie-`orderBy`-Sortierung. v53 Dexie-Migration kopiert `updatedAt``_updatedAtIndex` für existing rows. ~382 stamping-sites über 121 Files entfernt.
- **F4** (`c07db300b`) Server-side Singleton-Bootstrap in mana-auth's `/register`. `bootstrapUserSingletons(userId)` schreibt `userContext` direkt in `mana_sync.sync_changes` mit `client_id='system:bootstrap'` + `origin='system'`. Default-Inhalt mirror't `emptyUserContext()`.
- **F4-fu (kontextDoc)** (_pending_) Symmetrische Variante für `kontextDoc` (per-Space, nicht per-user). `bootstrapSpaceSingletons(spaceId, ownerUserId)` läuft aus `databaseHooks.user.create.after` (Personal-Space) und aus `organizationHooks.afterCreateOrganization` (brand/club/family/team/practice). `createBetterAuth(databaseUrl, syncDatabaseUrl, webauthn)` bekommt jetzt eine zweite URL für den Sync-DB-Pool. Webapp `kontextStore.ensureDoc()` privat zu `getOrCreateLocalDoc()` umbenannt (Public-API: `setContent` + `appendContent`).
- **F5** (`d78f57c04`) `userContextStore.ensureDoc()` Public-API entfernt. Internal `getOrCreateLocalDoc()` bleibt als Fallback für brand-new clients deren First-Pull noch nicht durch ist. UI mountet ohne ensureDoc-Race.
- **F6** (`a031493fe`) Stable `client_id` in Dexie-Tabelle `_clientIdentity`. `restoreClientIdFromDexie()` läuft im (app)-Layout vor `createUnifiedSync` und reconciliated Dexie ↔ localStorage. Dexie ist canonical, localStorage ist fast-read-cache. Survives clear-site-data und incognito flush.
- **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.

View file

@ -1,8 +1,11 @@
/**
* Kontext module reactive query for the active-Space document.
*
* Content is encrypted at rest. Returns null until first write; the
* view calls kontextStore.ensureDoc() on mount to materialise the row.
* Content is encrypted at rest. Returns the row as soon as it's been
* pulled from mana-sync (every Space-creation server-bootstraps an empty
* kontextDoc see `bootstrap-singletons.ts`); returns null only during
* the brief window before the first pull lands or for legacy Spaces
* created before the bootstrap shipped.
*
* Per-Space since Phase 2d.2: each Space has its own kontextDoc;
* Personal-Space's legacy singleton row is matched by the in-scope

View file

@ -2,9 +2,16 @@
* Kontext Store per-Space markdown document.
*
* Since Phase 2d.2 the module is Space-scoped: each Space has its own
* kontextDoc. The store finds the row via `getInScopeSpaceIds()` (which
* matches the active Space plus the legacy `_personal:<userId>` sentinel
* so Personal-Space's pre-migration singleton row still renders).
* kontextDoc. Since 2026-04-26 (sync-field-meta-overhaul Punkt 2) every
* Space-creation also bootstraps an empty kontextDoc server-side via
* `bootstrapSpaceSingletons` fresh clients pull the row from mana-sync
* instead of racing on a local insert. `getOrCreateLocalDoc()` below is
* kept as a fallback for the brief window before the first pull lands
* (and for legacy Spaces created before the bootstrap shipped).
*
* The store finds the row via `getInScopeSpaceIds()` (which matches the
* active Space plus the legacy `_personal:<userId>` sentinel so
* Personal-Space's pre-migration singleton row still renders).
*
* `content` is encrypted at rest. The Dexie creating hook stamps
* `spaceId` on new rows automatically we just pick a fresh UUID.
@ -20,29 +27,31 @@ async function findForActiveSpace(): Promise<LocalKontextDoc | undefined> {
return rows.find((r) => !r.deletedAt);
}
export const kontextStore = {
/**
* Ensure a kontextDoc exists for the active Space. No-op if one
* already exists. Returns the row so callers can read + write the
* same id.
*/
async ensureDoc(): Promise<LocalKontextDoc> {
const existing = await findForActiveSpace();
if (existing) return existing;
const newLocal: LocalKontextDoc = {
id: crypto.randomUUID(),
content: '',
};
await encryptRecord('kontextDoc', newLocal);
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');
return created;
},
/**
* 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.
*/
async function getOrCreateLocalDoc(): Promise<LocalKontextDoc> {
const existing = await findForActiveSpace();
if (existing) return existing;
const newLocal: LocalKontextDoc = {
id: crypto.randomUUID(),
content: '',
};
await encryptRecord('kontextDoc', newLocal);
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');
return created;
}
export const kontextStore = {
async setContent(content: string): Promise<void> {
const row = await this.ensureDoc();
const row = await getOrCreateLocalDoc();
const diff: Partial<LocalKontextDoc> = {
content,
};
@ -51,7 +60,7 @@ export const kontextStore = {
},
async appendContent(chunk: string): Promise<void> {
const row = await this.ensureDoc();
const row = await getOrCreateLocalDoc();
const [decrypted] = await decryptRecords('kontextDoc', [row]);
const current = decrypted?.content ?? '';
const separator = current.trim() ? '\n\n---\n\n' : '';