From 615b1c23c3346e61660c5dcef0243042455336b8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 23:35:08 +0200 Subject: [PATCH] feat(sync): thread actor through webapp sync client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the wire contract the Go server just learned to persist. Every PendingChange now carries the actor through the Dexie row into the POST payload; SyncChange on the receiving side accepts an opaque actor blob. - `sync.ts` - `SyncChange.actor?: Actor` on the wire type; documented as opaque + back-compat with pre-actor clients - `PendingChange.actor?: Actor` — the pending-changes row already gets an actor stamped by the Dexie hook, the type now reflects it - `isValidSyncChange` accepts actor as an object or undefined, never asserts internal shape (the payload is opaque by design) - Push payload includes `actor: p.actor` alongside the other fields - `module-registry.test.ts` — `pendingProposals` added to INTERNAL_TABLES. It's a local-only staging table that intentionally does NOT sync (approved writes run the underlying tool, which syncs normally). Follow-up still open: when applyServerChanges writes a record from an incoming change, stamp `__lastActor` + `__fieldActors` from the incoming actor so the Workbench timeline attributes cross-device writes correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/module-registry.test.ts | 11 ++++++++++- apps/mana/apps/web/src/lib/data/sync.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/mana/apps/web/src/lib/data/module-registry.test.ts b/apps/mana/apps/web/src/lib/data/module-registry.test.ts index c373a0f25..61975053e 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.test.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.test.ts @@ -37,7 +37,16 @@ import { db } from './database'; // ─── Internal Dexie tables that are intentionally NOT in SYNC_APP_MAP ─── // These hold local-only state (sync metadata, retry queues, activity log) // that must never leave the device. -const INTERNAL_TABLES = new Set(['_pendingChanges', '_syncMeta', '_eventsTombstones', '_activity']); +const INTERNAL_TABLES = new Set([ + '_pendingChanges', + '_syncMeta', + '_eventsTombstones', + '_activity', + // Local-only AI Workbench staging; approvals run the underlying tool + // which writes via its module's sync path — proposals themselves never + // leave the device. + 'pendingProposals', +]); describe('module-registry — structural invariants', () => { it('every appId is unique across module configs', () => { diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index bc57faea5..034aba4d1 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -25,6 +25,7 @@ import { } from './database'; import { isQuotaError, cleanupTombstones, notifyQuotaExceeded } from './quota'; import { emitSyncTelemetry, categorizeSyncError } from './sync-telemetry'; +import type { Actor } from './events/actor'; // ─── Types ──────────────────────────────────────────────────── @@ -70,6 +71,13 @@ export interface SyncChange { fields?: Record; data?: Record; deletedAt?: string; + /** + * Attribution of who triggered the write. Opaque structured value + * that survives the round trip through mana-sync / Postgres via a + * JSONB column. Consumers treat a missing actor as `{ kind: 'user' }` + * for back-compat with pre-actor clients. + */ + actor?: Actor; } interface PendingChange { @@ -81,6 +89,7 @@ interface PendingChange { fields?: Record; data?: Record; deletedAt?: string; + actor?: Actor; createdAt: string; } @@ -132,6 +141,10 @@ export function isValidSyncChange(v: unknown): v is SyncChange { if (c.deletedAt !== undefined && typeof c.deletedAt !== 'string') return false; if (c.eventId !== undefined && typeof c.eventId !== 'string') return false; if (c.schemaVersion !== undefined && typeof c.schemaVersion !== 'number') return false; + // `actor` is opaque — we deliberately don't assert its shape here. A + // malformed actor doesn't corrupt data; worst case the Workbench shows + // "unknown" for that change. + if (c.actor !== undefined && (typeof c.actor !== 'object' || c.actor === null)) return false; return true; } @@ -1060,6 +1073,7 @@ export function createUnifiedSync( fields: p.fields, data: p.data, deletedAt: p.deletedAt, + actor: p.actor, })), }; }