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/**'], + }, });