From 42c9eb1e178755caf473fe14d40fd078d1906d07 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 14:55:11 +0200 Subject: [PATCH] perf(mana/web): index updatedAt for recent-X dashboard widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema bump V9 adds an updatedAt secondary index to the six tables that the cross-app dashboard widgets use for "recent N" lookups: conversations, images, presiDecks, documents, songs, mukkePlaylists. Dexie builds the index lazily on first open — no migration code, no data touched. Recent-query refactor: useRecentConversations useRecentImages useRecentDecks useRecentDocuments All four switched from `toArray() + JS sort + slice` to `orderBy('updatedAt').reverse().filter().limit()`. Dexie walks the BTree backwards and short-circuits as soon as `limit` matches accumulate, so the cost is O(limit + filtered) instead of O(table). For a dashboard with thousands of stored conversations or images, the dashboard widget previously read every record on every render (liveQuery re-runs on any write). Now it stops after 5–6 hits. Verified: 20/20 sync.test.ts still passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/cross-app-queries.ts | 48 ++++++++++++------- apps/mana/apps/web/src/lib/data/database.ts | 24 ++++++++++ 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts index 1dd921c00..d6cfed1d6 100644 --- a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts +++ b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts @@ -117,11 +117,18 @@ export function useFavoriteContacts(limit = 5) { /** Recent conversations, sorted by updatedAt desc. */ export function useRecentConversations(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await db.table('conversations').toArray(); - return all + // Walk the indexed updatedAt BTree in reverse, filtering archived / + // soft-deleted entries on the fly. Dexie's `.limit()` short-circuits + // the iterator as soon as that many matches accumulate, so the cost + // is O(limit + filtered) instead of the O(table) toArray+sort the + // query used to do. + return db + .table('conversations') + .orderBy('updatedAt') + .reverse() .filter((c) => !c.isArchived && !c.deletedAt) - .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) - .slice(0, limit); + .limit(limit) + .toArray(); }, [] as LocalConversation[]); } @@ -145,11 +152,16 @@ export function useRandomFavorite() { /** Recent generated images. */ export function useRecentImages(limit = 6) { return useLiveQueryWithDefault(async () => { - const all = await db.table('images').toArray(); - return all + // Reverse-walk the indexed updatedAt column. Generated images have + // updatedAt stamped on creation and rarely move afterwards, so this + // is effectively "newest first" for the dashboard widget's purpose. + return db + .table('images') + .orderBy('updatedAt') + .reverse() .filter((i) => !i.isArchived && !i.deletedAt) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')) - .slice(0, limit); + .limit(limit) + .toArray(); }, [] as LocalImage[]); } @@ -231,11 +243,13 @@ export function useMusicStats() { /** Recent presentation decks. */ export function useRecentDecks(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await db.table('presiDecks').toArray(); - return all + return db + .table('presiDecks') + .orderBy('updatedAt') + .reverse() .filter((d) => !d.deletedAt) - .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) - .slice(0, limit); + .limit(limit) + .toArray(); }, [] as LocalPresiDeck[]); } @@ -244,11 +258,13 @@ export function useRecentDecks(limit = 5) { /** Recent documents + spaces. */ export function useRecentDocuments(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await db.table('documents').toArray(); - return all + return db + .table('documents') + .orderBy('updatedAt') + .reverse() .filter((d) => !d.deletedAt) - .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) - .slice(0, limit); + .limit(limit) + .toArray(); }, [] as LocalDocument[]); } diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index c96e6e8fe..782f07cb7 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -442,6 +442,30 @@ db.version(8).stores({ _eventsTombstones: 'id, token, attempts, createdAt', }); +// ─── Version 9: Add updatedAt indexes for "recent X" dashboard widgets ─ +// +// Several cross-app queries (`useRecentConversations`, `useRecentImages`, +// `useRecentDecks`, `useRecentDocuments`) used to load entire tables and +// JS-sort by `updatedAt`. With these indexes Dexie can walk the BTree in +// reverse and stop after N matches. +// +// `++` is NOT used — we are only adding secondary indexes to existing +// stores. The full `stores()` line is repeated because Dexie's upgrade +// API requires the complete schema for the version, even when most +// fields are unchanged. +// +// No data migration needed: indexes are built lazily by Dexie at upgrade +// time without touching record contents. + +db.version(9).stores({ + conversations: 'id, isArchived, isPinned, spaceId, templateId, updatedAt', + images: 'id, isFavorite, isPublic, isArchived, prompt, updatedAt', + presiDecks: 'id, isPublic, updatedAt', + documents: 'id, spaceId, type, pinned, title, [spaceId+type], updatedAt', + songs: 'id, artist, album, genre, favorite, title, updatedAt', + 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}.