perf(mana/web): index updatedAt for recent-X dashboard widgets

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 14:55:11 +02:00
parent 473b8c0091
commit 42c9eb1e17
2 changed files with 56 additions and 16 deletions

View file

@ -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<LocalConversation>('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<LocalConversation>('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<LocalImage>('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<LocalImage>('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<LocalPresiDeck>('presiDecks').toArray();
return all
return db
.table<LocalPresiDeck>('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<LocalDocument>('documents').toArray();
return all
return db
.table<LocalDocument>('documents')
.orderBy('updatedAt')
.reverse()
.filter((d) => !d.deletedAt)
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit);
.limit(limit)
.toArray();
}, [] as LocalDocument[]);
}

View file

@ -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}.