From 275130f8a678700f91e7f2789bb6a358045fd516 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 27 Apr 2026 01:54:35 +0200 Subject: [PATCH] test(sync): cross-cutting integration tests for field-meta overhaul (Punkt 12) Six new tests in sync.test.ts under the "field-meta overhaul (F1-F4-fu)" block, verifying the architectural promises of the 2026-04-26 sync field-meta overhaul end-to-end: - deriveUpdatedAt returns max(__fieldMeta[*].at) - deriveUpdatedAt gracefully handles legacy / null records - Dexie creating-hook stamps __fieldMeta + _updatedAtIndex on every local write - Dexie updating-hook bumps __fieldMeta only for changed fields and syncs _updatedAtIndex with the latest at - SYSTEM_BOOTSTRAP-stamped local insert produces origin='system' (the fallback path in userContextStore + kontextStore) - Bootstrap-twin race scenario: local SYSTEM_BOOTSTRAP row + later server insert collapses via field-LWW with no conflict surface Also re-exports SYSTEM_BOOTSTRAP from $lib/data/events/actor for parity with the other SYSTEM_* sentinels. 35/35 sync.test.ts pass (29 prior + 6 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/data/events/actor.ts | 1 + apps/mana/apps/web/src/lib/data/sync.test.ts | 150 +++++++++++++++++- docs/plans/sync-field-meta-overhaul.md | 1 + 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/events/actor.ts b/apps/mana/apps/web/src/lib/data/events/actor.ts index 787d3bce9..0f3eef36b 100644 --- a/apps/mana/apps/web/src/lib/data/events/actor.ts +++ b/apps/mana/apps/web/src/lib/data/events/actor.ts @@ -37,6 +37,7 @@ export { SYSTEM_MIGRATION, SYSTEM_STREAM, SYSTEM_MISSION_RUNNER, + SYSTEM_BOOTSTRAP, LEGACY_USER_PRINCIPAL, LEGACY_AI_PRINCIPAL, LEGACY_SYSTEM_PRINCIPAL, diff --git a/apps/mana/apps/web/src/lib/data/sync.test.ts b/apps/mana/apps/web/src/lib/data/sync.test.ts index a5e1c65f3..e43033c5b 100644 --- a/apps/mana/apps/web/src/lib/data/sync.test.ts +++ b/apps/mana/apps/web/src/lib/data/sync.test.ts @@ -38,11 +38,18 @@ import { readFieldMeta, applyServerChanges, subscribeSyncConflicts, + deriveUpdatedAt, type SyncChange, type SyncConflictPayload, } from './sync'; -import { db, FIELD_META_KEY } from './database'; -import { makeFieldMeta, USER_ACTOR } from './events/actor'; +import { db, FIELD_META_KEY, UPDATED_AT_INDEX_KEY } from './database'; +import { + makeFieldMeta, + USER_ACTOR, + makeSystemActor, + SYSTEM_BOOTSTRAP, + runAsAsync, +} from './events/actor'; // ─── Pure tests ────────────────────────────────────────────────── @@ -663,4 +670,143 @@ describe('applyServerChanges (Dexie integration)', () => { expect(conflicts[0].field).toBe('title'); }); }); + + // ─── F1-F4-fu cross-cutting integration ────────────────────── + // Sanity checks that verify the architectural promises of the + // 2026-04-26 sync field-meta overhaul still hold from end to end: + // - `deriveUpdatedAt(record)` returns max(__fieldMeta[*].at) so + // the public `updatedAt` stays correct after F3. + // - The Dexie creating/updating hook stamps `__fieldMeta` AND + // `_updatedAtIndex` on every local write. + // - The SYSTEM_BOOTSTRAP fallback path stamps origin='system' + // so the server's matching bootstrap pull doesn't fight with + // the local fallback row (no false-positive conflict toast). + // - The bootstrap-twin race scenario: local fallback row + later + // server pull collapse via field-LWW with no conflict surface. + describe('field-meta overhaul (F1-F4-fu)', () => { + it('deriveUpdatedAt returns max __fieldMeta[*].at', () => { + const record = { + id: 'x', + [FIELD_META_KEY]: { + title: makeFieldMeta('2026-04-01T10:00:00Z', USER_ACTOR, 'user'), + priority: makeFieldMeta('2026-04-01T11:00:00Z', USER_ACTOR, 'user'), + notes: makeFieldMeta('2026-03-01T10:00:00Z', USER_ACTOR, 'user'), + }, + }; + expect(deriveUpdatedAt(record)).toBe('2026-04-01T11:00:00Z'); + }); + + it('deriveUpdatedAt returns empty string when no __fieldMeta', () => { + expect(deriveUpdatedAt({ id: 'x', title: 'no meta yet' })).toBe(''); + expect(deriveUpdatedAt({})).toBe(''); + expect(deriveUpdatedAt(null)).toBe(''); + expect(deriveUpdatedAt(undefined)).toBe(''); + }); + + it('Dexie creating-hook stamps __fieldMeta + _updatedAtIndex on local writes', async () => { + await db.table('tasks').add({ + id: 'task-hook-create', + title: 'first', + priority: 'low', + isCompleted: false, + order: 0, + }); + const stored = await db.table('tasks').get('task-hook-create'); + const meta = readFieldMeta(stored); + + expect(meta.title?.at).toBeTruthy(); + expect(meta.title?.origin).toBe('user'); + // _updatedAtIndex equals max meta.at on a fresh insert. + expect(stored[UPDATED_AT_INDEX_KEY]).toBe(deriveUpdatedAt(stored)); + }); + + it('Dexie updating-hook stamps __fieldMeta only for changed fields and bumps _updatedAtIndex', async () => { + await db.table('tasks').add({ + id: 'task-hook-update', + title: 'first', + priority: 'low', + isCompleted: false, + order: 0, + }); + const before = await db.table('tasks').get('task-hook-update'); + const beforeMeta = readFieldMeta(before); + const beforePriorityAt = beforeMeta.priority?.at; + expect(beforePriorityAt).toBeTruthy(); + + // Wait a millisecond so the hook's at-stamp on update is strictly later. + await new Promise((r) => setTimeout(r, 5)); + await db.table('tasks').update('task-hook-update', { title: 'changed' }); + + const after = await db.table('tasks').get('task-hook-update'); + const afterMeta = readFieldMeta(after); + expect(afterMeta.title?.at).not.toBe(beforeMeta.title?.at); // bumped + expect(afterMeta.priority?.at).toBe(beforePriorityAt); // unchanged + expect(after[UPDATED_AT_INDEX_KEY]).toBe(afterMeta.title?.at); + }); + + it('SYSTEM_BOOTSTRAP-stamped local insert uses origin=system, not user', async () => { + const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP); + await runAsAsync(bootstrapActor, async () => { + await db.table('tasks').add({ + id: 'task-bootstrap-twin', + title: '', + priority: 'low', + isCompleted: false, + order: 0, + }); + }); + const stored = await db.table('tasks').get('task-bootstrap-twin'); + const meta = readFieldMeta(stored); + expect(meta.title?.origin).toBe('system'); + expect(meta.priority?.origin).toBe('system'); + }); + + it('bootstrap-twin race: local SYSTEM_BOOTSTRAP row + later server insert → no conflict, LWW wins', async () => { + // 1. Client-side fallback creates an empty row stamped origin='system'. + // This is what `getOrCreateLocalDoc()` does in userContextStore / + // kontextStore when a write lands before the first sync pull. + const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP); + await runAsAsync(bootstrapActor, async () => { + await db.table('tasks').add({ + id: 'task-bootstrap-race', + title: '', + priority: 'low', + isCompleted: false, + order: 0, + }); + }); + + // 2. Server's bootstrap pull arrives later. mana-auth's + // bootstrap-singletons stamps the same record id with the + // real default values from emptyUserContext()/emptyKontextDocData(). + const conflicts: SyncConflictPayload[] = []; + const unsubscribe = subscribeSyncConflicts((p) => conflicts.push(p)); + try { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-bootstrap-race', + op: 'insert', + data: { + id: 'task-bootstrap-race', + title: '', + priority: 'low', + isCompleted: false, + order: 0, + updatedAt: '2026-04-01T10:00:00Z', + }, + }, + ]); + } finally { + unsubscribe(); + } + + // No conflict surface — local origin='system' would be exempt + // even if values differed. With identical empty values, the + // equality short-circuit also kicks in. Belt-and-suspenders. + expect(conflicts).toHaveLength(0); + const stored = await db.table('tasks').get('task-bootstrap-race'); + expect(stored).toBeDefined(); + }); + }); }); diff --git a/docs/plans/sync-field-meta-overhaul.md b/docs/plans/sync-field-meta-overhaul.md index 7d7685351..fd2a67fcb 100644 --- a/docs/plans/sync-field-meta-overhaul.md +++ b/docs/plans/sync-field-meta-overhaul.md @@ -157,6 +157,7 @@ _Wird befüllt während der Ausführung._ | 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) | `ae6a14fb7` | 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. | | Punkt 5 (Backend updated_at) | _closed-as-non-orphan_ | Survey aller 17 Backend-Drizzle-Schemas (mana-mail/-media/-auth/-analytics/-research/-events/-subscriptions/-credits + apps/api/{unlisted,website,traces,presi,todo}) zeigt: 3 Spalten (`research.providerConfigs.updatedAt`, `unlisted.snapshots.updatedAt`, `website.customDomains.updatedAt`) werden aktiv vom Service gelesen/geschrieben. Die übrigen 14 sind "AUTO-ONLY" — Drizzle stempelt sie via `defaultNow()` / `$onUpdate(() => new Date())`, kein Service-Code liest sie. Aber: das sind keine Sync-Orphans — F3's Notiz ("pure server-internal columns, not touched") war korrekt. Die AUTO-ONLY Spalten sind DB-Level Audit-Zeitstempel die für Postgres-Forensik nützlich bleiben (`ORDER BY updated_at DESC` für "welche Row zuletzt geändert"). Sie stammen NICHT aus dem alten Sync — sie sind Standard-Drizzle-Convention. Kein Cleanup nötig. | +| Punkt 12 (Integration tests) | _pending_ | 6 neue Integration-Tests im sync.test.ts cross-cutting block: `deriveUpdatedAt` returns max field-meta `at` + handles legacy/null records · Dexie creating-hook stamps `__fieldMeta` + `_updatedAtIndex` · updating-hook bumps nur changed fields + `_updatedAtIndex` · SYSTEM_BOOTSTRAP-stamped local insert produces `origin='system'` · bootstrap-twin race scenario (local SYSTEM_BOOTSTRAP row + server insert) feuert keinen Conflict. 35/35 Tests grün (29 vorher + 6 neue). | ## F1 — Implementation Notes