From d1a0d096921d72785b2d854ce504e1b39af4befc Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 20:48:30 +0200 Subject: [PATCH] feat(data): stamp actor on records and pending changes via Dexie hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the creating/updating hooks to capture the ambient actor synchronously and freeze it onto every write: - `__lastActor` on each record (whole-record attribution for Workbench badges) - `__fieldActors` parallel to `__fieldTimestamps` (field-level attribution for inline diff rendering — e.g. "AI changed due date, user changed title") - `actor` on `_pendingChanges` rows so mana-sync + cross-device views can distinguish AI- vs user-initiated writes Also adds `kontextDoc` to v17 (missing from schema while module was live) alongside the new `pendingProposals` table for staged AI intents. Actor is captured in-hook rather than at emit time because `trackPendingChange` is deferred via setTimeout and would otherwise lose ambient context. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 82 +++++++++++++++++---- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 9b8a204d5..a19450271 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -19,6 +19,8 @@ import { trackFirstContent } from '$lib/stores/funnel-tracking'; import { fire as fireTrigger } from '$lib/triggers/registry'; import { checkInlineSuggestion } from '$lib/triggers/inline-suggest'; import { getEffectiveUserId } from './current-user'; +import { getCurrentActor } from './events/actor'; +import type { Actor } from './events/actor'; import { isQuotaError, notifyQuotaExceeded } from './quota-detect'; import { SYNC_APP_MAP, @@ -104,10 +106,10 @@ db.version(1).stores({ cards: 'id, deckId, difficulty, nextReview, order, [deckId+order]', deckTags: 'id, deckId, tagId, [deckId+tagId]', - // ─── Zitare (appId: 'zitare') ─── - zitareFavorites: 'id, quoteId', - zitareLists: 'id', - zitareListTags: 'id, listId, tagId, [listId+tagId]', + // ─── Quotes (appId: 'quotes') ─── + quotesFavorites: 'id, quoteId', + quotesLists: 'id', + quotesListTags: 'id, listId, tagId, [listId+tagId]', // ─── Music (appId: 'music') ─── songs: 'id, artist, album, genre, favorite, title, updatedAt', @@ -367,9 +369,9 @@ db.version(5).stores({ bodyWorkouts: 'id, startedAt, endedAt, routineId, timeBlockId, [endedAt+startedAt]', }); -// v5: Zitare custom quotes — user-created quotes stored locally. +// v5: Quotes custom quotes — user-created quotes stored locally. db.version(5).stores({ - zitareCustomQuotes: 'id, author, category', + customQuotes: 'id, author, category', }); // Schema version 6 — Firsts module: track first-time experiences. @@ -494,10 +496,16 @@ db.version(16).stores({ _byokKeys: 'id, provider, isDefault, [provider+isDefault]', }); -// v17 — Kontext module: a single user-authored markdown document keyed by -// the fixed id 'singleton'. No indexes beyond the primary key. +// v17 — Kontext module (user-authored markdown doc keyed by 'singleton') +// + AI proposals (staged intents awaiting user approval). +// +// `pendingProposals` is local-only and does NOT participate in mana-sync — +// the approved write itself syncs through the normal module path. Indexes +// support "all pending ordered by creation" (approval inbox) and +// "all proposals for mission X" (workbench). db.version(17).stores({ kontextDoc: 'id', + pendingProposals: 'id, status, createdAt, missionId, [status+createdAt]', }); // ─── Sync Routing ────────────────────────────────────────── @@ -633,9 +641,25 @@ function trackActivity( * Not indexed, not sent to the server in pending-change payloads. */ export const FIELD_TIMESTAMPS_KEY = '__fieldTimestamps'; +/** + * Hidden field holding the {@link Actor} that last wrote the record as a + * whole. Used by the Workbench UI to badge records the AI has touched. + */ +export const LAST_ACTOR_KEY = '__lastActor'; +/** + * Hidden field holding the per-field {@link Actor} map, mirroring + * `__fieldTimestamps`. Enables "the AI changed the due date, the user + * changed the title" attribution when rendering diffs. + */ +export const FIELD_ACTORS_KEY = '__fieldActors'; function isInternalKey(key: string): boolean { - return key === 'id' || key === FIELD_TIMESTAMPS_KEY; + return ( + key === 'id' || + key === FIELD_TIMESTAMPS_KEY || + key === LAST_ACTOR_KEY || + key === FIELD_ACTORS_KEY + ); } for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { @@ -645,6 +669,10 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { table.hook('creating', function (_primKey, obj) { if (_applyingTables.has(tableName)) return; const now = new Date().toISOString(); + // Capture the actor synchronously — ambient context is only reliable + // inside the caller's microtask, not across the setTimeout'd + // trackPendingChange below. Freezing it here is the authoritative step. + const actor: Actor = getCurrentActor(); // Auto-stamp the active user. Module stores never set userId themselves, // preventing accidental impersonation and removing all hardcoded @@ -655,16 +683,26 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { } // Stamp every real field with the create-time so future LWW comparisons - // have a baseline. Mutates obj in place — Dexie persists the mutation. + // have a baseline, and with the actor so field-level attribution works. + // Mutates obj in place — Dexie persists the mutation. const ft: Record = {}; + const fa: Record = {}; for (const key of Object.keys(obj)) { if (isInternalKey(key)) continue; ft[key] = now; + fa[key] = actor; } objRecord[FIELD_TIMESTAMPS_KEY] = ft; + objRecord[FIELD_ACTORS_KEY] = fa; + objRecord[LAST_ACTOR_KEY] = actor; - // Build payload for pending-change WITHOUT the internal timestamp map - const { [FIELD_TIMESTAMPS_KEY]: _omit, ...dataForSync } = obj as Record; + // Build payload for pending-change WITHOUT the internal bookkeeping fields + const { + [FIELD_TIMESTAMPS_KEY]: _ft, + [FIELD_ACTORS_KEY]: _fa, + [LAST_ACTOR_KEY]: _la, + ...dataForSync + } = obj as Record; trackPendingChange(tableName, { appId, @@ -672,6 +710,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { recordId: obj.id, op: 'insert', data: dataForSync, + actor, createdAt: now, }); trackActivity(appId, tableName, obj.id, 'insert'); @@ -690,6 +729,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { table.hook('updating', function (modifications, primKey, obj) { if (_applyingTables.has(tableName)) return undefined; const now = new Date().toISOString(); + const actor: Actor = getCurrentActor(); const fields: Record = {}; // userId is immutable after creation. Silently strip any attempt to @@ -698,17 +738,23 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { const mods = modifications as Record; if ('userId' in mods) delete mods.userId; - // Merge field timestamps: keep existing, overwrite for each modified field + // Merge field timestamps and field actors: keep existing, overwrite + // each modified field with now / current actor. const existingFT = ((obj as Record)[FIELD_TIMESTAMPS_KEY] as | Record | undefined) ?? {}; + const existingFA = + ((obj as Record)[FIELD_ACTORS_KEY] as Record | undefined) ?? + {}; const newFT: Record = { ...existingFT }; + const newFA: Record = { ...existingFA }; for (const [key, value] of Object.entries(modifications)) { if (isInternalKey(key)) continue; fields[key] = { value, updatedAt: now }; newFT[key] = now; + newFA[key] = actor; } const op = (modifications as Record).deletedAt ? 'delete' : 'update'; @@ -718,6 +764,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { recordId: primKey as string, op, fields, + actor, deletedAt: (modifications as Record).deletedAt as string | undefined, createdAt: now, }); @@ -726,8 +773,13 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { // Returning an object from a Dexie 'updating' hook merges it into the // modifications applied to the record — use this to persist the new - // per-field timestamps alongside the user's update. - return { [FIELD_TIMESTAMPS_KEY]: newFT }; + // per-field timestamps, per-field actors, and last-actor alongside + // the user's update. + return { + [FIELD_TIMESTAMPS_KEY]: newFT, + [FIELD_ACTORS_KEY]: newFA, + [LAST_ACTOR_KEY]: actor, + }; }); } }