Removes `updatedAt` from the wire protocol and from every Local-prefixed
record type. Replaced by two orthogonal mechanisms — deriveUpdatedAt()
for read-side public-facing values, _updatedAtIndex shadow for indexed
sorts.
Local-side:
- New `_updatedAtIndex` shadow column. Stamped by the Dexie creating /
updating hook on every write. Stripped from the pending-change payload
so it never travels to mana-sync. Indexed in Dexie v53 on the 22 tables
that previously indexed `updatedAt`.
- `deriveUpdatedAt(record)` in sync.ts returns max(__fieldMeta[*].at) so
the public-facing Task / Note / etc. shape keeps an `updatedAt: string`
property without holding it as data.
- Type-converters across ~60 module/queries.ts and types.ts files now
call `deriveUpdatedAt(local)` instead of reading `local.updatedAt`.
Module-store sweep:
- Regex codemod removed `updatedAt: new Date().toISOString()` /
`: now` / `: now()` / `: nowIso()` stamping from 121 store files
(~382 call sites total). Single-property update calls
(`{ updatedAt: now }`) collapsed to `{}`; touch-only patterns
(writing/drafts, writing/generations) kept the call as a no-op
because the hook now stamps `_updatedAtIndex` automatically on
any Dexie modification.
- Local* interfaces stripped of `updatedAt: string` (43 types.ts files).
Public-facing types (Task, Note, Mission, Agent, …) keep
`updatedAt: string` as a computed read-side property.
- Companion's chat conversation now sorts on a real
`lastMessageAt` data field instead of touching `updatedAt`.
- Session-only stores (times/session-alarms, session-countdown-timers)
stamp `updatedAt: now` directly because they're not in Dexie and
have no field-meta layer to derive from.
Sync engine:
- applyServerChanges sets `_updatedAtIndex` itself when applying
server changes (max of server-field times for updates, recordTime
for inserts) so server-replays land orderable.
- Dropped the legacy `localUpdatedAt` fallback — every record now has
`__fieldMeta`, the per-field at is the canonical source.
- Soft-delete tombstone path stops stamping `updatedAt: serverTime`,
uses `_updatedAtIndex` instead.
Server-side:
- mana-ai iteration-writer no longer emits `updatedAt` in
sync_changes.data; receivers derive it from the field-meta map.
- mana-sync types: no change (the wire format already uses
`field_meta` / `at` from F1).
Out of scope: backend Drizzle schemas (mana-credits, mana-events, …)
keep their `updated_at` columns. Those are pure server-internal — not
part of the sync_changes / __fieldMeta mechanism F3 cleans up.
Tests + checks:
- 0 svelte-check errors over 7652 files.
- 29/29 sync.test.ts (vitest).
- 61 mana-ai bun tests.
- mana-sync go test ./... cached green.
Plan: docs/plans/sync-field-meta-overhaul.md F3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
Sync Field-Meta Overhaul
Started 2026-04-26.
Pre-live assumption: no production data, no live clients. Hard-cut everywhere — kein Dual-Write, keine Schema-Migration, kein Translation-Layer. mana_sync.sync_changes wird im Zuge des Rollouts truncated, alle Browser-IndexedDBs laufen einmalig durch ein neues Dexie-Versions-Upgrade, fertig.
Problem
Die heutige Conflict-Detection feuert massiv False-Positives, beobachtet konkret als 4 Toasts beim Start einer frischen Dev-Session:
- 3×
meImages überschrieben — Feld updatedAt - 1×
userContext überschrieben — 10 Felder
Diagnose im Detail in der Conversation, die diesen Plan ausgelöst hat. Vier strukturelle Wurzeln:
-
updatedAtist ein syncbares User-Datenfeld. Bei jedem Write wird ein neuer ISO-String geschrieben. Sobald zwei Sessions denselben Record berühren, divergieren ihreupdatedAt-Strings zwingend → Field-LWW erkennt das als Konflikt. Strukturell garantierter Konflikt-Trigger. -
Conflict-Detection unterscheidet nicht zwischen User-Edit und Replay-Delta.
applyServerChanges(apps/mana/apps/web/src/lib/data/sync.ts:469-490) vergleicht den Server-Wert gegen den lokalen Wert. Der lokale Wert kann aus drei Quellen stammen: echter User-Edit, vorherige Iteration desselben Pulls, vorheriger Pull. Conflict-Toast soll nur bei (1) feuern, feuert aber bei allen drei. -
Singletons werden clientseitig race-anfällig erstellt.
userContextStore.ensureDoc()(profile/stores/user-context.svelte.ts:21-27) und analoge Patterns fürkontextDoc. Mehrfach-Inserts derselben ID landen im Insert-as-Update-Merge-Pfad insync.ts:380-432, der für jedes Feld einen Konflikt-Vergleich macht. -
client_idist an localStorage gekoppelt.getOrCreateClientId()insync.ts:1226. Browser-State-Wipe → neue ID. Konkret: 5 distinkteclient_ids für eine User-Identity in der lokalenmana_sync(Query-Beweis: 18.04, 23.04, 24.04, 25.04, 26.04 — jeder Tag ein neuer Client). Aus Sync-Sicht wird derselbe physische Browser zu fünf verschiedenen Clients, deren Schreibhistorien sich gegenseitig als "fremde Sitzung" sehen.
Decision
Eine einzige Wahrheitsquelle für Per-Field-Metadata: __fieldMeta. Origin-Tracking als Pflichtbestandteil. updatedAt wird zum reinen Read-Side-Computed. Singletons werden serverseitig vom mana-auth-Bootstrap angelegt. client_id lebt in der Dexie-DB.
Datenmodell
Vorher (heute):
{
id, ...userFields,
updatedAt: '2026-04-25T11:23:20.212Z', // syncbar, conflict-getrackt
__fieldTimestamps: { [field]: ISO },
__fieldActors: { [field]: Actor },
__lastActor: Actor,
}
Nachher:
{
id, ...userFields,
__fieldMeta: {
[field]: {
at: ISO,
actor: Actor,
origin: 'user' | 'agent' | 'system' | 'migration' | 'server-replay',
}
}
}
updatedAt ist nicht mehr im Datensatz, nicht mehr im Sync, nicht mehr in den Drizzle-Schemas. UI-Layer bekommt updatedAt als Computed: max(__fieldMeta[*].at), exposed im Type-Converter.
__lastActor entfällt — gleichbedeutend zu __fieldMeta[max(at)].actor, on-demand berechnet.
Conflict-Trigger
Neu in applyServerChanges:
notifyConflict() feuert nur, wenn ALLE gelten:
serverTime > localFieldMeta.at
localFieldMeta.origin === 'user'
localValue != null
!valuesEqual(localValue, serverValue)
Sobald der lokale Schreibvorgang aus einem Server-Replay kam, ist origin === 'server-replay' → kein Conflict-Toast. Sobald das Feld aus einer Migration oder einem System-Bootstrap stammt, ebenfalls kein Toast.
Sortier-Indizes
db.table('tasks').orderBy('updatedAt') ist heute über die ganze Codebase verstreut. Da updatedAt nicht mehr persistiert wird, wäre Dexie-Sortierung kaputt. Ersatz: ein lokaler, nicht-syncbarer Schatten-Index _updatedAtIndex: ISO, der vom Dexie-updating/creating-Hook auf jeden Modify-Vorgang automatisch auf now gesetzt wird. Reine Lokal-Spalte ohne Sync-Bedeutung; landet nicht in pendingChange. UI-Code, der orderBy('updatedAt') macht, switcht auf orderBy('_updatedAtIndex') (Sed-Codemod).
Singleton-Bootstrap
userContext, kontextDoc und alle anderen Singletons werden vom mana-auth-Service beim First-Login eines Users in mana_sync.sync_changes als op='insert' mit client_id='system:bootstrap' geschrieben. Inhalt: das Schema-Default (emptyUserContext() etc., extrahiert in ein shared Package, das Server + Client teilen).
Clients pullen den Singleton beim First-Sync. Wenn lokal noch nicht angekommen, blockiert die Profile-View mit einem Loader-State. Kein lokaler add() mehr.
Stable client_id
Neue Dexie-Tabelle _clientIdentity mit genau einem Row { id: 'self', clientId: UUID, createdAt: ISO }. Identity überlebt localStorage-Wipes; nur ein vollständiger IndexedDB-Reset vergibt eine neue ID. localStorage bleibt als Sync-Read-Cache (vermeidet async-Block in Sync-Pfad), wird bei Miss aus Dexie rehydriert.
First-Pull als privilegierter Modus (Belt-and-Suspenders)
Der allererste Pull eines Clients (Cursor '') hat per Definition keine User-Edits, die überschrieben werden könnten. applyServerChanges bekommt einen isInitialHydration: boolean-Parameter, der jegliche notifyConflict()-Aufrufe gatet. Doppelte Sicherheit gegenüber dem Origin-Check.
Phasen
Je Phase ein PR. Pre-live, also pro PR: Code, Tests, manueller Smoke-Test, ohne Soft/Hard-Stages, ohne Backwards-Compat.
| Phase | Scope | Done-Kriterien |
|---|---|---|
F1 — __fieldMeta Hard-Cut |
Neue Dexie-Version mit __fieldMeta. __fieldTimestamps, __fieldActors, __lastActor aus database.ts und allen Lesepfaden gelöscht. Hooks in database.ts:1497-1610 umgeschrieben. apps/api/mana-sync Drizzle-Schemas: field_timestamps JSONB-Spalte umbenannt zu field_meta, neuer Eintragstyp {at, actor, origin}. Truncate mana_sync.sync_changes. |
Alle Reads und Writes nutzen __fieldMeta. validate:all grün. _pendingChanges schreibt field_meta statt field_timestamps. |
| F2 — Origin-Tracking + Conflict-Gate | Origin-Werte in alle Write-Pfade einsetzen: regulärer User-Write 'user', Seeds 'system', Repair-Migrationen 'migration', AI-Mission-Runner 'agent', applyServerChanges schreibt 'server-replay'. Conflict-Trigger in sync.ts:476-489 umstellen. isInitialHydration durch alle Aufrufketten reichen. |
sync.test.ts umfasst alle Origin-Kombinationen. False-Positive-Replay-Tests grün (10 Server-Changes hintereinander auf demselben Record → 0 Conflicts). User-Edit-vs-Server-Push-Test grün (1 Conflict). |
F3 — updatedAt Hard-Drop |
Aus jedem Local*-Type, jedem Type-Converter, jedem Module-Store-Patch, jedem Drizzle-Schema, jedem crypto/registry.ts-Eintrag. _updatedAtIndex als lokale Schatten-Spalte einführen. Dexie-Hook stempelt _updatedAtIndex = now auf jedem Insert/Update. Sed-Codemod: orderBy('updatedAt') → orderBy('_updatedAtIndex'). Type-Converter: updatedAt: max(__fieldMeta[*].at). |
Kein Treffer mehr für updatedAt:.*new Date in apps/mana/apps/web/src/lib/modules/. Keine updated_at-Spalte mehr in den Drizzle-Schemas außer mana-auth (User-Tabelle, dort legitim). Sortier-Verhalten in den UI-Listen unverändert (Smoke-Test über /todo, /notes, /cards, /profile/me-images). |
| F4 — Server-Side Singleton Bootstrap | Neuer Endpoint in mana-auth (oder eigenes mana-bootstrap-Modul) der beim First-Login eines Users die Singleton-Inserts für userContext + kontextDoc + sonstige id='singleton'/id='self'-Records in mana_sync.sync_changes mit client_id='system:bootstrap' und origin='system' schreibt. Default-Inhalt aus einem geteilten Package @mana/data-defaults (extrahiert aus profile/types.ts:emptyUserContext + analogen). |
Postcondition mana-auth.users-create: für jeden userContext-/kontextDoc-Singleton existiert genau eine Insert-Row in sync_changes. Test: Frische User-Identity → erster Pull bringt vollständigen Singleton ohne lokalen ensureDoc()-Aufruf. |
F5 — ensureDoc() Hard-Drop |
Methoden aus userContextStore, kontextStore etc. löschen. UI-Views (ContextOverview.svelte, ContextInterview.svelte, ContextFreeform.svelte, KontextView.svelte, MissionGrantDialog.svelte, AiMissionsListView.svelte) lesen über useLiveQuery ohne ensureDoc()-Vorlauf. Wenn Singleton lokal noch nicht da: Loader-State. |
Keine ensureDoc-Aufrufe mehr im Code. Frische Browser-DB → Profile-View zeigt Loader → erster Pull kommt → View rendert. |
| F6 — Stable client_id | Dexie-Version mit _clientIdentity-Tabelle. getOrCreateClientId() umstellen auf Dexie-Read mit localStorage-Cache. Boot-Pfad: erst Dexie öffnen, dann clientId lesen, dann sync starten. |
localStorage komplett clearen → client_id bleibt stabil. IndexedDB löschen → neuer client_id. |
| F7 — Repair-Migrationen löschen | apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts und legacy-avatar.ts löschen, alle Aufrufer in MeImagesView.svelte und wardrobe/ListView.svelte entfernen. Begründung: F1-F3 machen die zugrundeliegenden Probleme (silent-twin, legacy-avatar-Spillover) strukturell unmöglich, weil updatedAt nicht mehr explizit gepatched wird und Origin-Tracking Replay-Konflikte unterdrückt. |
Grep nach repairSilentTwin/migrateLegacyAvatar leer. Frische User-Identity → kein meImages-Toast mehr. |
Test-Plan
Drei Test-Stufen pro Phase:
-
Unit:
apps/mana/apps/web/src/lib/data/sync.test.tsunddatabase.test.tsdecken Origin-Kombinationen, First-Pull-Hydration,__fieldMeta-Hook-Stempel ab. -
Integration: Ein neuer Test
sync-replay-no-false-positives.test.tsmit fake-indexeddb: schreibt 10 sequenzielleupdate-Server-Changes für denselben Record (verschiedeneclient_ids, monoton steigende Timestamps), erwartet 0 Conflict-Notifications. -
End-to-End-Smoke (manuell): Browser-DB löschen →
pnpm run mana:dev→ Login → Profile-View → Wardrobe → Workbench. Erwartet: keine Conflict-Toasts, alle Singletons da, alle Sortierungen korrekt.
Stolperfallen
-
__fieldMetaund Encryption. Aktuell ist__fieldTimestampsplaintext (Dexie-Hook stempelt nachencryptRecord).__fieldMeta.actorenthältdisplayName— potenziell sensibel. Entscheidung: Origin undatbleiben plaintext (für LWW nötig);actor.displayNamewird im selben Encryption-Pass mit-encrypted. Wenn das zu kompliziert wird, fallback aufactor.principalIdonly unddisplayNameüber separate Read-Side-Lookup. -
mana-syncHub-Notify und SSE-Push. Beide spiegeln das Change-Format 1:1. F1 muss den Go-Code inservices/mana-sync/internal/sync/types.goundhandler.gomit umstellen. Tests inhandler_test.goundspaces_test.goanpassen. -
@mana/shared-aiundmana-aiServer. Beide schreiben in_pendingChangesbzw. direkt insync_changesmitactor: { kind: 'agent' }. F2 muss sicherstellen, dass diese Pfadeorigin: 'agent'setzen. Sonst landen Agent-Writes als'user'und triggern wieder Conflicts. -
Type-Converter-Sweep. Etwa 40+ Module haben
to<Module>()-Funktionen, dieupdatedAtaus dem Record lesen. Codemod-fähig (immer dasselbe Pattern), aber muss gewissenhaft validiert werden, weil einige Module den Wert nicht nur fürs Sortieren, sondern auch für UI-Darstellung nutzen ("zuletzt geändert vor 5 Min"). -
_pendingChanges-Format. Heute trägt jede Pending-Rowfields: {[key]: {value, updatedAt}}. Im neuen Format wird das zufields: {[key]: {value, at, actor, origin}}. Server muss matchen. -
AI Workbench Activity-Log.
_activity-Tabelle und Workbench-Timeline rendern Actor-Strings. Sicherstellen, dass die Read-Pfade weiterhin funktionieren wenn__lastActorweg ist (statt: aus__fieldMetaableiten).
Open Questions
-
Brauchen wir
origin: 'migration'als eigene Kategorie, oder reicht'system'für Bootstrap-Repair-Calls? Argument für separate Kategorie: Audit-Trail im Workbench. Argument gegen: nochmal eine Origin-Variante mehr für wenig Mehrwert. Default:'system'reicht, kann später erweitert werden. -
Soll
_updatedAtIndexindexiert werden? Ja — sonst sind dieorderBy()-Pfade O(n). Dexie-Schema, _updatedAtIndexhinzufügen für jede Tabelle, die heute aufupdatedAtsortiert. Inventar in F3. -
Was mit
createdAt? Symmetrisches Problem? Nein —createdAtist immutable nach dem Insert, kann also nicht durch Folge-Pulls "überschrieben" werden, also nie Conflict-Trigger. Bleibt als reguläres Feld. -
mana-authals Owner des Bootstrap-Schritts vs. einem separatenmana-bootstrap-Service? Default: einbauen in mana-auth, weil dort der "User wird erstmalig gesehen"-Hook sowieso lebt. Kann später extrahiert werden. -
Wann genau truncaten wir
mana_sync.sync_changes? Vor F1 oder am Ende. Default: am Ende von F1 (sobald die neue Schemaversion existiert), dann ist die Tabelle leer und alle Folge-Phasen schreiben direkt im Zielformat.
Shipping Log
Wird befüllt während der Ausführung.
| Phase | Commit | Notiz |
|---|---|---|
| F1 | 7766ea502 |
Web + mana-sync (Go) + mana-ai + apps/api/mcp + tests + DB schema reset. Note: Commit-Titel docs(plans): mark llm-fallback-aliases SHIPPED ist irreführend — Multi-Terminal-Race hat F1 in einem fremden Commit zusammengeführt. Code ist trotzdem korrekt drin (27 F1 Files + Plan). Tests grün, DB migriert. |
| F2 | ad5e04a55 |
Origin-Gate aktiviert. originFromActor() in shared-ai/field-meta.ts maps actor.kind → 'user'/'agent'/'system'/'migration'. Hooks nutzen es. Repair-Migrations (repair-silent-twin, legacy-avatar) wrappen ihre Writes in runAsAsync(systemMigrationActor, ...). applyServerChanges bekommt ApplyServerChangesOptions.isInitialHydration Parameter, beide Caller (push-response + pull) setzen ihn aus dem Cursor-State. Conflict-Trigger feuert nur noch wenn localMeta[k]?.origin === 'user' && !options.isInitialHydration. 29 Tests grün inkl. 4 neuer (replay-burst no-conflict, agent-origin no-conflict, hydration no-conflict, user-edit fires-conflict). |
| F3 | staged, uncommitted | updatedAt aus dem Sync-Wire entfernt, durch zwei orthogonale Mechanismen ersetzt: (1) _updatedAtIndex als lokale, nicht-syncbare Schatten-Spalte die der Dexie-Hook (creating/updating) auf jedem Write stempelt — Dexie v53 indexiert sie auf 22 Tabellen die früher updatedAt indexiert hatten. (2) deriveUpdatedAt(record) als read-side Computed = max(__fieldMeta[*].at) für 60+ Type-Converter die einen public updatedAt: string zurückliefern. Module-Stores: 121 Files, ~382 Stamping-Sites entfernt (regex-codemod). Local-Types: 43 Files, updatedAt: string aus Local-prefixed Interfaces entfernt (Public-Types behalten ihn als computed). applyServerChanges setzt _updatedAtIndex selbst beim Replay (mit max(serverFields.at) oder dem record-time). mana-ai/iteration-writer schreibt kein updatedAt mehr ins Wire-data. Skipped: Drizzle-Schemas der 11 Backend-Services — die updated_at Spalten dort sind nicht sync-relevant (pure server-internal columns) und wurden nicht in F3 angetastet. 0 svelte-check errors über 7652 Files, 29/29 sync.test.ts grün, 61 mana-ai bun tests grün, mana-sync go tests grün. |
| F4 | pending | |
| F5 | pending | |
| F6 | pending | |
| F7 | pending |
F1 — Implementation Notes
Wire-Format-Entscheidung: FieldChange wurde von { value, updatedAt } auf { value, at } umbenannt. Per-Field-Actor + Origin werden NICHT pro-Field transmitted — sie leben am Row-Level auf SyncChange.actor + SyncChange.origin, weil jeder Push genau eine (actor, origin)-Kombination beschreibt. Per-Field-Differenzierung wäre redundant.
Client-vs-Server-Asymmetrie bewusst: lokale IndexedDB hält per-Field das volle FieldMeta = { at, actor, origin }-Triple, weil ein Record über mehrere Schreibzyklen mit unterschiedlichen Actors/Origins entstanden sein kann. Server-side ist field_meta JSONB nur eine {[k]: at}-Map — Actor + Origin liegen Row-Level. Die Asymmetrie ist saubere Trennung "transmit minimum" vs "track everything".
Origin-Werte in F1:
- Web Dexie creating/updating Hook: hardcoded
'user'(F2 differenziert per actor.kind) applyServerChanges: hardcoded'server-replay'(Belt-and-suspenders gegen False-Positive-Conflicts beim History-Replay)- mana-ai
iteration-writer:'agent'(server-side iteration writes ARE agent writes) - apps/api MCP
writeRecord:'agent'(MCP-Tool-Calls sind by definition Agent-driven) - conflict-store
restore: implicit'user'(vom Hook gesetzt)
__lastActor und __fieldActors wurden ersatzlos gelöscht — __fieldMeta enthält dieselbe Information per-Field. Konsumenten, die "wer hat zuletzt was angefasst" brauchen, leiten das ab über argmax(__fieldMeta[k].at).actor.
updatedAt als syncbares Datenfeld bleibt in F1 noch erhalten — F3 entfernt es.
Conflict-Detection-Trigger in sync.ts:476-489 ist strukturell unverändert: noch derselbe serverTime > localFieldTime && localValue != null && !valuesEqual(...)-Test. F2 fügt das Origin-Gate hinzu (localFieldMeta.origin === 'user').