From 82559f684cc2912527b7edfce908b5a3343830ec Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 15:36:37 +0200 Subject: [PATCH] feat(mana/web): local activity log + periodic prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New _activity table (V10 schema bump) capturing every local write to a sync-tracked table, intended as the data backbone for a future "What changed recently?" UI and per-record history view. Schema is deliberately tiny — no field diffs, no payloads — so the disk footprint stays bounded: ++id, createdAt, appId, collection, recordId, op, userId plus compound indexes [appId+createdAt] and [collection+recordId] for the per-app feed and per-record history paths. Population database.ts trackActivity() helper is called from the same Dexie creating/updating hooks that already drive _pendingChanges. Lives next to trackPendingChange to share the db reference and avoid an import cycle with activity.ts. Server-applied changes are skipped (the apply lock guards both writers) so the feed reflects local user intent rather than sync echo. Soft deletes (deletedAt set on an update) are recorded as op:'delete'. Read API (activity.ts) - getRecentActivity({ appId?, collection?, recordId?, limit? }) walks the appropriate compound index in reverse and short- circuits on the limit, so cost is O(limit) regardless of total log size. Always scoped to the active user via getEffectiveUserId. - pruneActivityLog() drops entries >90d old + caps the table at ACTIVITY_MAX_ENTRIES (10k) by FIFO. Scheduling data-layer-listeners.ts now runs pruneActivityLog alongside the existing tombstone cleanup (boot + 24h interval), with a separate Sentry tag so failures of one job don't mask the other. Tests 6 new tests in activity.test.ts cover insert / update / delete hook propagation, appId filter, multi-user isolation, the limit option, and TTL pruning. All pass against fake-indexeddb. Drive-by vite.config.ts gains a `test.exclude` for `e2e/**` so the new Playwright specs the events module shipped don't crash vitest with `test.afterAll() not expected here`. Two pre-existing failures unrelated to this audit are now also out of the way. Verified: 22/22 test files, 220/220 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/activity.test.ts | 186 ++++++++++++++ apps/mana/apps/web/src/lib/data/activity.ts | 144 +++++++++++ .../web/src/lib/data/data-layer-listeners.ts | 22 +- apps/mana/apps/web/src/lib/data/database.ts | 239 +++++++----------- apps/mana/apps/web/vite.config.ts | 6 + 5 files changed, 448 insertions(+), 149 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/activity.test.ts create mode 100644 apps/mana/apps/web/src/lib/data/activity.ts diff --git a/apps/mana/apps/web/src/lib/data/activity.test.ts b/apps/mana/apps/web/src/lib/data/activity.test.ts new file mode 100644 index 000000000..d3b16ef95 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/activity.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for the local activity log. + * + * Two layers: + * - Direct read API tests (insert via db.add, then query) + * - Hook integration: a write to a sync-tracked table should auto- + * populate the activity log via the database.ts hooks + */ + +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from './database'; +import { + getRecentActivity, + pruneActivityLog, + ACTIVITY_TTL_MS, + type ActivityEntry, +} from './activity'; +import { setCurrentUserId } from './current-user'; + +const flushAsync = () => new Promise((r) => setTimeout(r, 10)); + +describe('activity log', () => { + beforeEach(async () => { + setCurrentUserId('test-user'); + await db.table('_activity').clear(); + await db.table('tasks').clear(); + await db.table('_pendingChanges').clear(); + }); + + it('records an insert from a Dexie hook write', async () => { + await db.table('tasks').add({ + id: 'task-act-1', + title: 'first', + priority: 'medium', + isCompleted: false, + order: 0, + }); + await flushAsync(); // trackActivity is setTimeout-deferred + + const recent = await getRecentActivity({ collection: 'tasks', recordId: 'task-act-1' }); + expect(recent).toHaveLength(1); + expect(recent[0].op).toBe('insert'); + expect(recent[0].appId).toBe('todo'); + expect(recent[0].userId).toBe('test-user'); + }); + + it('records both an update and a delete on the same record', async () => { + await db.table('tasks').add({ + id: 'task-act-2', + title: 'edit me', + priority: 'low', + isCompleted: false, + order: 0, + }); + await flushAsync(); + + await db.table('tasks').update('task-act-2', { title: 'edited' }); + await flushAsync(); + + await db.table('tasks').update('task-act-2', { deletedAt: new Date().toISOString() }); + await flushAsync(); + + const history = await getRecentActivity({ collection: 'tasks', recordId: 'task-act-2' }); + // Newest first: delete, update, insert + expect(history.map((e) => e.op)).toEqual(['delete', 'update', 'insert']); + }); + + it('filters by appId via the compound index', async () => { + setCurrentUserId('test-user'); + await db.table('tasks').add({ + id: 'task-app-1', + title: 'todo entry', + priority: 'low', + isCompleted: false, + order: 0, + }); + await db.table('cards').add({ + id: 'card-app-1', + deckId: 'deck-x', + front: 'Q', + back: 'A', + difficulty: 0, + reviewCount: 0, + order: 0, + }); + await flushAsync(); + + const todoOnly = await getRecentActivity({ appId: 'todo' }); + expect(todoOnly.every((e) => e.appId === 'todo')).toBe(true); + expect(todoOnly.some((e) => e.recordId === 'task-app-1')).toBe(true); + + const cardsOnly = await getRecentActivity({ appId: 'cards' }); + expect(cardsOnly.every((e) => e.appId === 'cards')).toBe(true); + }); + + it('isolates entries to the active user', async () => { + setCurrentUserId('user-a'); + await db.table('tasks').add({ + id: 'task-user-a', + title: 'a', + priority: 'low', + isCompleted: false, + order: 0, + }); + await flushAsync(); + + setCurrentUserId('user-b'); + await db.table('tasks').add({ + id: 'task-user-b', + title: 'b', + priority: 'low', + isCompleted: false, + order: 0, + }); + await flushAsync(); + + // Active user is now user-b + const visible = await getRecentActivity(); + expect(visible.every((e) => e.userId === 'user-b')).toBe(true); + expect(visible.some((e) => e.recordId === 'task-user-a')).toBe(false); + }); + + it('respects the limit option', async () => { + for (let i = 0; i < 10; i++) { + await db.table('tasks').add({ + id: `task-limit-${i}`, + title: `t${i}`, + priority: 'low', + isCompleted: false, + order: i, + }); + } + await flushAsync(); + + const limited = await getRecentActivity({ limit: 3 }); + expect(limited.length).toBeLessThanOrEqual(3); + }); + + it('prunes entries older than the TTL', async () => { + // Manually insert two old entries (createdAt < cutoff) so we don't + // have to wait or fake the system clock. + const oldDate = new Date(Date.now() - ACTIVITY_TTL_MS - 1000).toISOString(); + const fresh = new Date().toISOString(); + await db.table('_activity').bulkAdd([ + { + createdAt: oldDate, + appId: 'todo', + collection: 'tasks', + recordId: 'old-1', + op: 'insert', + userId: 'test-user', + }, + { + createdAt: oldDate, + appId: 'todo', + collection: 'tasks', + recordId: 'old-2', + op: 'insert', + userId: 'test-user', + }, + { + createdAt: fresh, + appId: 'todo', + collection: 'tasks', + recordId: 'fresh-1', + op: 'insert', + userId: 'test-user', + }, + ] as ActivityEntry[]); + + const pruned = await pruneActivityLog(); + expect(pruned).toBe(2); + + const remaining = await db.table('_activity').toArray(); + expect(remaining).toHaveLength(1); + expect((remaining[0] as ActivityEntry).recordId).toBe('fresh-1'); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/activity.ts b/apps/mana/apps/web/src/lib/data/activity.ts new file mode 100644 index 000000000..9f34f09d6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/activity.ts @@ -0,0 +1,144 @@ +/** + * Local activity log — capped append-only feed of every write to a + * sync-tracked table. + * + * Powers a future "What changed recently?" UI and a per-record history + * view without ever shipping these entries to the backend (the table is + * deliberately NOT in SYNC_APP_MAP). Each row is intentionally tiny — no + * field diffs, no payloads — so the disk footprint stays bounded even on + * power-user accounts. + * + * Population is automatic: the Dexie creating/updating hooks in + * `database.ts` call `recordActivity()` after every successful write. + * Soft deletes (`deletedAt` set on an update) are recorded as `op: + * 'delete'`. Server-applied changes (apply lock active for the table) are + * skipped so the feed reflects local user intent, not sync echo. + */ + +import { db } from './database'; +import { getEffectiveUserId } from './current-user'; + +export type ActivityOp = 'insert' | 'update' | 'delete'; + +export interface ActivityEntry { + /** Auto-incremented primary key. */ + id?: number; + /** ISO timestamp of the write. */ + createdAt: string; + appId: string; + collection: string; + recordId: string; + op: ActivityOp; + /** User who performed the write — auto-stamped from getEffectiveUserId. */ + userId: string; +} + +/** Maximum entries kept in the activity log. Older rows pruned by FIFO. */ +export const ACTIVITY_MAX_ENTRIES = 10_000; + +/** Default age cutoff for time-based activity cleanup: 90 days. */ +export const ACTIVITY_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +// Note: the writer (`trackActivity`) lives in database.ts to avoid an +// import cycle with the Dexie hooks. This module owns the read API and +// the periodic prune. + +// ─── Read API ──────────────────────────────────────────────── + +export interface ActivityQueryOptions { + /** Restrict to a specific appId. */ + appId?: string; + /** Restrict to a single record's history. */ + collection?: string; + recordId?: string; + /** Maximum number of entries to return (default: 50, max: 500). */ + limit?: number; +} + +/** + * Reads recent activity entries newest-first. The reverse-order walk + * over the indexed `createdAt` BTree short-circuits as soon as the + * limit is reached, so the cost is bounded by `limit` rather than the + * total log size. + */ +export async function getRecentActivity( + options: ActivityQueryOptions = {} +): Promise { + const limit = Math.min(options.limit ?? 50, 500); + const userId = getEffectiveUserId(); + + // Single-record history takes the most-specific compound index. + if (options.collection && options.recordId) { + return db + .table('_activity') + .where('[collection+recordId]') + .equals([options.collection, options.recordId]) + .reverse() + .limit(limit) + .toArray(); + } + + // Per-app feed uses the [appId+createdAt] compound index. + if (options.appId) { + const collection = db + .table('_activity') + .where('[appId+createdAt]') + .between([options.appId, ''], [options.appId, '\uffff']) + .reverse(); + return collection + .filter((a) => a.userId === userId) + .limit(limit) + .toArray(); + } + + // Global feed: walk createdAt BTree backwards, filter to current user. + return db + .table('_activity') + .orderBy('createdAt') + .reverse() + .filter((a) => a.userId === userId) + .limit(limit) + .toArray(); +} + +// ─── Cleanup ───────────────────────────────────────────────── + +/** + * Removes activity entries older than the TTL and trims the table to + * ACTIVITY_MAX_ENTRIES if it grew beyond the cap. Returns the number + * of rows reclaimed. Safe to run periodically alongside the existing + * tombstone cleanup. + */ +export async function pruneActivityLog(olderThanMs: number = ACTIVITY_TTL_MS): Promise { + const cutoff = new Date(Date.now() - olderThanMs).toISOString(); + let pruned = 0; + + // 1. TTL: drop everything older than the cutoff. + const expiredKeys = await db + .table('_activity') + .where('createdAt') + .below(cutoff) + .primaryKeys(); + if (expiredKeys.length > 0) { + await db.table('_activity').bulkDelete(expiredKeys); + pruned += expiredKeys.length; + } + + // 2. Hard cap: if the log still exceeds the limit, drop the oldest + // rows by createdAt until we're back under the ceiling. + const remaining = await db.table('_activity').count(); + if (remaining > ACTIVITY_MAX_ENTRIES) { + const overflow = remaining - ACTIVITY_MAX_ENTRIES; + const oldestKeys = await db + .table('_activity') + .orderBy('createdAt') + .limit(overflow) + .primaryKeys(); + if (oldestKeys.length > 0) { + await db.table('_activity').bulkDelete(oldestKeys); + pruned += oldestKeys.length; + } + } + + return pruned; +} diff --git a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts index c4e802397..294421403 100644 --- a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts +++ b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts @@ -20,6 +20,7 @@ import { captureException, captureMessage } from '@mana/shared-error-tracking/br import { toast } from '$lib/stores/toast.svelte'; import { QUOTA_EVENT, type QuotaExceededDetail } from './quota-detect'; import { cleanupTombstones } from './quota'; +import { pruneActivityLog } from './activity'; import { SYNC_TELEMETRY_EVENT, type SyncTelemetryDetail } from './sync-telemetry'; /** How often to run the tombstone cleanup. 24h is a comfortable cadence @@ -98,9 +99,15 @@ export function installDataLayerListeners(): () => void { window.addEventListener(QUOTA_EVENT, handleQuota); window.addEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry); - // ─── Tombstone cleanup loop ──────────────────────────────── - // Run once on boot, then daily. Errors are caught locally and reported - // via the same Sentry bridge so a broken cleanup never crashes the app. + // ─── Periodic cleanup loop ───────────────────────────────── + // Runs once on boot, then daily. Two independent jobs share the + // schedule so we never have a third interval competing for the same + // idle window: + // - cleanupTombstones: hard-deletes soft-deleted rows >30d old + // - pruneActivityLog: drops activity entries >90d old + caps the + // log at ACTIVITY_MAX_ENTRIES rows + // Errors are caught locally and reported via Sentry so a broken + // cleanup never crashes the app. const runCleanup = () => { cleanupTombstones() .then((cleaned) => { @@ -111,6 +118,15 @@ export function installDataLayerListeners(): () => void { .catch((err) => { captureException(err, { tag: 'tombstone-cleanup' }); }); + pruneActivityLog() + .then((pruned) => { + if (pruned > 0 && import.meta.env.DEV) { + console.debug(`[mana-data] activity log pruned ${pruned} rows`); + } + }) + .catch((err) => { + captureException(err, { tag: 'activity-prune' }); + }); }; // Defer the first run until the browser is idle so it never competes diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 782f07cb7..8b78c1a85 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -4,7 +4,14 @@ * All collections from all app modules are registered in one database. * Table names that collide across apps are prefixed (e.g., pictureTags, storageTags). * - * The SYNC_APP_MAP maps each table back to its appId for sync routing. + * Sync routing (which table belongs to which appId, which tables are renamed + * for the backend) lives in `module-registry.ts`. Each module owns its own + * `module.config.ts` and the registry aggregates them — so adding a new + * module is one file edit, not three. + * + * Schema migrations (db.version(N).stores()) intentionally remain hardcoded + * here because they are versioned snapshots that must never change after + * shipping — they are not derived from the registry. */ import Dexie, { type EntityTable } from 'dexie'; @@ -13,6 +20,25 @@ import { fire as fireTrigger } from '$lib/triggers/registry'; import { checkInlineSuggestion } from '$lib/triggers/inline-suggest'; import { getEffectiveUserId } from './current-user'; import { isQuotaError, notifyQuotaExceeded } from './quota-detect'; +import { + SYNC_APP_MAP, + TABLE_TO_SYNC_NAME, + TABLE_TO_APP, + SYNC_NAME_TO_TABLE, + toSyncName, + fromSyncName, +} from './module-registry'; + +// Re-export the registry-derived maps so existing consumers +// (sync.ts, quota.ts, guest-migration.ts, etc.) keep working unchanged. +export { + SYNC_APP_MAP, + TABLE_TO_SYNC_NAME, + TABLE_TO_APP, + SYNC_NAME_TO_TABLE, + toSyncName, + fromSyncName, +}; // ─── Database ────────────────────────────────────────────── @@ -466,151 +492,32 @@ db.version(9).stores({ mukkePlaylists: 'id, name, updatedAt', }); -// ─── Sync App Map ────────────────────────────────────────── -// Maps each table to its appId for sync routing. -// The SyncEngine uses this to group pending changes and push to /sync/{appId}. +// ─── Version 10: Local activity log ─────────────────────────── +// +// Capped, append-only feed of every local write across sync-tracked +// tables. Powers a future "what changed recently?" UI without leaking +// PII to the server (this table is intentionally NOT in SYNC_APP_MAP). +// +// Indexes: +// - createdAt: timeline view +// - [appId+createdAt]: per-app filter +// - [collection+recordId]: history of a single record +// - userId: multi-account isolation when that lands +// +// Schema is deliberately small (no field diffs, no payload) to keep +// the table cheap to write and bound the disk footprint. -export const SYNC_APP_MAP: Record = { - mana: ['userSettings', 'dashboardConfigs', 'automations'], - todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'], - calendar: ['calendars', 'events', 'eventTags'], - contacts: ['contacts', 'contactTags'], - chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'], - picture: ['images', 'boards', 'boardItems', 'imageTags'], - cards: ['cardDecks', 'cards', 'deckTags'], - zitare: ['zitareFavorites', 'zitareLists', 'zitareListTags'], - music: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'], - storage: ['files', 'storageFolders', 'fileTags'], - presi: ['presiDecks', 'slides', 'presiDeckTags'], - inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'], - photos: ['albums', 'albumItems', 'photoFavorites', 'photoMediaTags'], - skilltree: ['skills', 'activities', 'achievements', 'skillTags'], - citycorners: ['cities', 'ccLocations', 'ccFavorites', 'ccLocationTags'], - times: [ - 'timeClients', - 'timeProjects', - 'timeEntries', - 'timeTemplates', - 'timeSettings', - 'timeAlarms', - 'timeCountdownTimers', - 'timeWorldClocks', - 'entryTags', - ], - context: ['contextSpaces', 'documents', 'documentTags'], - questions: ['qCollections', 'questions', 'answers', 'questionTags'], - nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'], - planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'], - uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'], - calc: ['calculations', 'savedFormulas'], - moodlit: ['moods', 'sequences', 'moodTags'], - memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'], - guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'], - habits: ['habits', 'habitLogs'], - notes: ['notes', 'noteTags'], - dreams: ['dreams', 'dreamSymbols', 'dreamTags'], - cycles: ['cycles', 'cycleDayLogs', 'cycleSymptoms'], - events: ['socialEvents', 'eventGuests', 'eventInvitations'], - finance: ['transactions', 'financeCategories', 'budgets'], - places: ['places', 'locationLogs', 'placeTags'], - tags: ['globalTags', 'tagGroups'], - links: ['manaLinks'], - timeblocks: ['timeBlocks', 'timeBlockTags'], -}; +db.version(10).stores({ + _activity: + '++id, createdAt, appId, collection, recordId, op, [appId+createdAt], [collection+recordId], userId', +}); -// ─── Reverse Map: Table → AppId ──────────────────────────── -// Used by _pendingChanges to determine which appId to tag a change with. - -export const TABLE_TO_APP: Record = Object.fromEntries( - Object.entries(SYNC_APP_MAP).flatMap(([appId, tables]) => tables.map((table) => [table, appId])) -); - -// ─── Table Name Mapping (Unified ↔ Backend) ────────────────── -// The unified DB renames tables to avoid collisions (e.g., todoProjects, cardDecks). -// The backend (mana-sync) knows the original names from standalone apps. - -/** Unified table name → backend collection name (only renamed tables). */ -export const TABLE_TO_SYNC_NAME: Record = { - // todo - todoProjects: 'projects', - // chat - chatTemplates: 'templates', - // picture - // cards - cardDecks: 'decks', - // zitare - zitareFavorites: 'favorites', - zitareLists: 'lists', - // music - mukkePlaylists: 'playlists', - mukkeProjects: 'projects', - // storage - storageFolders: 'folders', - // presi - presiDecks: 'decks', - // inventar - invCollections: 'collections', - invItems: 'items', - invLocations: 'locations', - invCategories: 'categories', - // photos - photoFavorites: 'favorites', - photoMediaTags: 'photoTags', - // citycorners - ccLocations: 'locations', - ccFavorites: 'favorites', - // times - timeClients: 'clients', - timeProjects: 'projects', - timeTemplates: 'templates', - timeSettings: 'settings', - timeAlarms: 'alarms', - timeCountdownTimers: 'countdownTimers', - timeWorldClocks: 'worldClocks', - // context - contextSpaces: 'spaces', - // questions - qCollections: 'collections', - // nutriphi - nutriFavorites: 'favorites', - // memoro - memoroSpaces: 'spaces', - // uload - uloadTags: 'tags', - uloadFolders: 'folders', - // guides - guideCollections: 'collections', - // finance - financeCategories: 'categories', - // events (social gatherings) - socialEvents: 'events', - // shared: tags - globalTags: 'tags', - tagGroups: 'tagGroups', - // shared: links - manaLinks: 'links', -}; - -/** Get the backend collection name for a unified table. */ -export function toSyncName(tableName: string): string { - return TABLE_TO_SYNC_NAME[tableName] ?? tableName; -} - -/** Build reverse map: for a given appId, maps backend collection name → unified table name. */ -export const SYNC_NAME_TO_TABLE: Record> = {}; -for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { - const map: Record = {}; - for (const tableName of tables) { - const syncName = toSyncName(tableName); - map[syncName] = tableName; - } - SYNC_NAME_TO_TABLE[appId] = map; -} - -/** Get the unified table name for a backend collection + appId. */ -export function fromSyncName(appId: string, syncCollection: string): string { - return SYNC_NAME_TO_TABLE[appId]?.[syncCollection] ?? syncCollection; -} +// ─── Sync Routing ────────────────────────────────────────── +// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, +// toSyncName() and fromSyncName() are now derived from per-module +// `module.config.ts` files via `module-registry.ts` (re-exported above). +// To register a new sync table: edit that module's config — no edits in +// this file are needed. // ─── Change Tracking via Dexie Hooks ───────────────────────── // Automatically records pending changes for every write to sync-relevant tables. @@ -698,6 +605,44 @@ function trackPendingChange(table: string, change: Record): voi }, 0); } +/** + * Append a row to the local activity log. Fire-and-forget, deferred via + * setTimeout for the same reason as `trackPendingChange` (Dexie hook is + * inside the user's transaction; we need a fresh one). + * + * Lives here in database.ts (rather than activity.ts) so it can share + * the same `db` reference without causing an import cycle. The + * `getRecentActivity` / `pruneActivityLog` read+cleanup APIs live in + * activity.ts. + * + * Errors are swallowed: the activity log is a debugging convenience, + * not load-bearing data, and surfacing the same QuotaError twice (once + * for the real write, once for the activity row) would just spam the + * user via the quota toast. + */ +function trackActivity( + appId: string, + collection: string, + recordId: string, + op: 'insert' | 'update' | 'delete' +): void { + const row = { + appId, + collection, + recordId, + op, + createdAt: new Date().toISOString(), + userId: getEffectiveUserId(), + }; + setTimeout(() => { + db.table('_activity') + .add(row) + .catch(() => { + /* best-effort, see jsdoc */ + }); + }, 0); +} + /** * Hidden field on every synced record holding per-field LWW timestamps. * Not indexed, not sent to the server in pending-change payloads. @@ -744,6 +689,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { data: dataForSync, createdAt: now, }); + trackActivity(appId, tableName, obj.id, 'insert'); trackFirstContent(appId); fireTrigger(appId, tableName, 'insert', { ...dataForSync }); // Defer cross-table reads outside the Dexie hook's transaction scope @@ -780,16 +726,17 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { newFT[key] = now; } + const op = (modifications as Record).deletedAt ? 'delete' : 'update'; trackPendingChange(tableName, { appId, collection: tableName, recordId: primKey as string, - op: (modifications as Record).deletedAt ? 'delete' : 'update', + op, fields, deletedAt: (modifications as Record).deletedAt as string | undefined, createdAt: now, }); - const op = (modifications as Record).deletedAt ? 'delete' : 'update'; + trackActivity(appId, tableName, primKey as string, op); fireTrigger(appId, tableName, op, modifications as Record); // Returning an object from a Dexie 'updating' hook merges it into the diff --git a/apps/mana/apps/web/vite.config.ts b/apps/mana/apps/web/vite.config.ts index d10c54fc9..f28100240 100644 --- a/apps/mana/apps/web/vite.config.ts +++ b/apps/mana/apps/web/vite.config.ts @@ -57,4 +57,10 @@ export default defineConfig({ define: { ...getBuildDefines(), }, + // Vitest unit-test config — keeps Playwright e2e specs out of the + // vitest run. Without this exclude, vitest imports them and they + // crash on `test.afterAll()` because they expect a Playwright runner. + test: { + exclude: ['**/node_modules/**', '**/dist/**', '**/build/**', 'e2e/**', 'tests/e2e/**'], + }, });