diff --git a/src/PageManager.tsx b/src/PageManager.tsx index e66b8689..0e0b61ea 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -454,7 +454,7 @@ function extractValidNoteId(raw: string): string | null { function parseNoteUrl(url: string): { noteId: string; context?: string } | null { // Match patterns like /discussions/notes/{noteId} or /notes/{noteId} const contextualMatch = url.match( - /\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ + /\/(discussions|search|library|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ ) if (contextualMatch) { const noteId = extractValidNoteId(contextualMatch[2]) diff --git a/src/hooks/useReplyIngress.ts b/src/hooks/useReplyIngress.ts index bca7f887..927adf2e 100644 --- a/src/hooks/useReplyIngress.ts +++ b/src/hooks/useReplyIngress.ts @@ -1,5 +1,16 @@ -import { useReply } from '@/providers/ReplyProvider' +import { type TRepliesMap } from '@/lib/reply-index' +import { useReplyOptional } from '@/providers/ReplyProvider' import { useThreadReplyOptional } from '@/providers/ThreadReplyProvider' +import type { Event } from 'nostr-tools' + +const noopAddReplies = (_replies: Event[]) => {} +const EMPTY_REPLIES_MAP: TRepliesMap = new Map() + +const REPLY_INGRESS_FALLBACK = { + repliesMap: EMPTY_REPLIES_MAP, + addReplies: noopAddReplies, + scoped: false as const +} /** * Reply map ingress for the open note panel: prefers per-thread storage when @@ -7,9 +18,12 @@ import { useThreadReplyOptional } from '@/providers/ThreadReplyProvider' */ export function useReplyIngress() { const thread = useThreadReplyOptional() - const global = useReply() if (thread) { return { repliesMap: thread.repliesMap, addReplies: thread.addReplies, scoped: true as const } } - return { repliesMap: global.repliesMap, addReplies: global.addReplies, scoped: false as const } + const global = useReplyOptional() + if (global) { + return { repliesMap: global.repliesMap, addReplies: global.addReplies, scoped: false as const } + } + return REPLY_INGRESS_FALLBACK } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b3c13f85..6d22d7f5 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1651,7 +1651,7 @@ export default { 'Library page title': 'Bibliothek', 'Library search placeholder': 'Publikationen nach Titel, Autor oder Tag suchen…', 'Library show only my publications': 'Nur meine Publikationen', - 'Library empty': 'Noch keine Publikationen mit Interaktionen auf deinen Relays.', + 'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.', 'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.', 'Library loading': 'Publikationen werden von Dokument-Relays geladen…', 'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f4ea8685..8b9dd865 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1674,7 +1674,7 @@ export default { 'Library page title': 'Library', 'Library search placeholder': 'Search publications by title, author, or tag…', 'Library show only my publications': 'Show only my publications', - 'Library empty': 'No engaged publications found on your relays yet.', + 'Library empty': 'No publications found on your relays yet.', 'Library empty filtered': 'No publications match your filters.', 'Library loading': 'Loading publications from document relays…', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', diff --git a/src/lib/library-index-cache-config.test.ts b/src/lib/library-index-cache-config.test.ts new file mode 100644 index 00000000..4d04ed96 --- /dev/null +++ b/src/lib/library-index-cache-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + approxLibraryIndexEventBytes, + getLibraryIndexCacheBudget, + LIBRARY_INDEX_CACHE_DEFAULTS +} from '@/lib/library-index-cache-config' +import { ExtendedKind } from '@/constants' +import type { Event } from 'nostr-tools' + +function sampleIndexEvent(tagCount: number): Event { + const tags: string[][] = [ + ['d', 'sample-book'], + ['title', 'Sample Book'] + ] + for (let i = 0; i < tagCount; i++) { + tags.push(['a', `30041:${'a'.repeat(64)}:chapter-${i}`, 'wss://relay.example', 'b'.repeat(64)]) + } + return { + id: 'c'.repeat(64), + kind: ExtendedKind.PUBLICATION, + pubkey: 'a'.repeat(64), + created_at: 1_700_000_000, + content: '', + tags, + sig: 'd'.repeat(128) + } +} + +describe('library-index-cache-config', () => { + it('approxLibraryIndexEventBytes returns positive size', () => { + const bytes = approxLibraryIndexEventBytes(sampleIndexEvent(3)) + expect(bytes).toBeGreaterThan(100) + }) + + it('getLibraryIndexCacheBudget returns platform defaults', () => { + const budget = getLibraryIndexCacheBudget() + expect(budget.maxEntries).toBeGreaterThanOrEqual(LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesMobile) + expect(budget.maxBytes).toBeGreaterThanOrEqual(LIBRARY_INDEX_CACHE_DEFAULTS.maxMbMobile * 1024 * 1024) + }) + + it('5000 mercury-sized indexes land near documented desktop budget', () => { + const avgMercuryBytes = 14_131 + const est5000Mb = (avgMercuryBytes * LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesDesktop) / (1024 * 1024) + expect(est5000Mb).toBeGreaterThan(50) + expect(est5000Mb).toBeLessThan(120) + expect(LIBRARY_INDEX_CACHE_DEFAULTS.maxMbDesktop).toBeGreaterThanOrEqual(Math.ceil(est5000Mb)) + }) +}) diff --git a/src/lib/library-index-cache-config.ts b/src/lib/library-index-cache-config.ts new file mode 100644 index 00000000..8c803098 --- /dev/null +++ b/src/lib/library-index-cache-config.ts @@ -0,0 +1,39 @@ +import { isImwaldElectron, isMobileBrowserProfile } from '@/lib/client-platform' +import type { Event } from 'nostr-tools' + +/** Platform caps for the dedicated Library kind-30040 index LRU store (separate from EVENT_ARCHIVE). */ +export const LIBRARY_INDEX_CACHE_DEFAULTS = { + maxEntriesMobile: 400, + maxEntriesDesktop: 5000, + maxEntriesElectron: 5000, + maxMbMobile: 40, + maxMbDesktop: 96, + maxMbElectron: 128 +} as const + +export function approxLibraryIndexEventBytes(ev: Event): number { + try { + return new Blob([JSON.stringify(ev)]).size + } catch { + return 2048 + } +} + +export function getLibraryIndexCacheBudget(): { maxEntries: number; maxBytes: number } { + if (isImwaldElectron()) { + return { + maxEntries: LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesElectron, + maxBytes: LIBRARY_INDEX_CACHE_DEFAULTS.maxMbElectron * 1024 * 1024 + } + } + if (isMobileBrowserProfile()) { + return { + maxEntries: LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesMobile, + maxBytes: LIBRARY_INDEX_CACHE_DEFAULTS.maxMbMobile * 1024 * 1024 + } + } + return { + maxEntries: LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesDesktop, + maxBytes: LIBRARY_INDEX_CACHE_DEFAULTS.maxMbDesktop * 1024 * 1024 + } +} diff --git a/src/lib/library-index-idb-cache.ts b/src/lib/library-index-idb-cache.ts new file mode 100644 index 00000000..97e2a248 --- /dev/null +++ b/src/lib/library-index-idb-cache.ts @@ -0,0 +1,50 @@ +import { ExtendedKind } from '@/constants' +import { + approxLibraryIndexEventBytes, + getLibraryIndexCacheBudget +} from '@/lib/library-index-cache-config' +import logger from '@/lib/logger' +import indexedDb from '@/services/indexed-db.service' +import type { Event } from 'nostr-tools' + +export async function loadLibraryIndexCacheEvents(): Promise { + try { + return await indexedDb.getLibraryPublicationIndexCacheEvents() + } catch (e) { + if (import.meta.env.DEV) { + logger.warn('[Library] index IDB read failed', { + message: e instanceof Error ? e.message : String(e) + }) + } + return [] + } +} + +export async function persistLibraryIndexCacheEvents(events: Event[]): Promise { + const kind30040 = events.filter((ev) => ev.kind === ExtendedKind.PUBLICATION) + if (kind30040.length === 0) return + try { + const budget = getLibraryIndexCacheBudget() + await indexedDb.mergeLibraryPublicationIndexCacheEvents(kind30040, budget) + } catch (e) { + if (import.meta.env.DEV) { + logger.warn('[Library] index IDB write failed', { + message: e instanceof Error ? e.message : String(e) + }) + } + } +} + +export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> { + try { + return await indexedDb.getLibraryPublicationIndexCacheFootprint() + } catch { + return { count: 0, bytes: 0 } + } +} + +export async function clearLibraryIndexIdbCache(): Promise { + await indexedDb.clearLibraryPublicationIndexCacheStore() +} + +export { approxLibraryIndexEventBytes, getLibraryIndexCacheBudget } diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index a71334f7..e7a4278d 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest' import { ExtendedKind } from '@/constants' import { buildEngagementMapsFromEvents, + buildRecentPublicationEntries, filterEngagedPublications, - filterLibraryPublicationsBySearch + filterLibraryPublicationsBySearch, + pickLibraryPublicationEntries } from '@/lib/library-publication-index' import { buildIndexByAddress } from '@/lib/publication-index' import type { Event } from 'nostr-tools' @@ -81,4 +83,29 @@ describe('library-publication-index', () => { expect(filterLibraryPublicationsBySearch(entries, 'title book')).toHaveLength(1) expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0) }) + + it('pickLibraryPublicationEntries falls back to newest roots without engagement', () => { + const older = indexEvent('old-book', [`30041:${PK}:a`], '1'.repeat(64)) + older.created_at = 10 + const newer = indexEvent('new-book', [`30041:${PK}:b`], '2'.repeat(64)) + newer.created_at = 20 + const indexByAddress = buildIndexByAddress([older, newer]) + const engagement = buildEngagementMapsFromEvents([], [], []) + + const picked = pickLibraryPublicationEntries([older, newer], indexByAddress, engagement) + + expect(picked).toHaveLength(2) + expect(picked[0].event.id).toBe(newer.id) + expect(picked.every((e) => e.engagementCount === 0)).toBe(true) + }) + + it('buildRecentPublicationEntries caps at limit', () => { + const roots = Array.from({ length: 12 }, (_, i) => { + const ev = indexEvent(`book-${i}`, [`30041:${PK}:ch-${i}`], String(i).padEnd(64, '0').slice(0, 64)) + ev.created_at = i + return ev + }) + expect(buildRecentPublicationEntries(roots, 10)).toHaveLength(10) + expect(buildRecentPublicationEntries(roots, 10)[0].event.created_at).toBe(11) + }) }) diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index 160ad510..62460e62 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -11,6 +11,10 @@ import { hydrateNestedIndexEvents } from '@/lib/publication-index' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' +import { + loadLibraryIndexCacheEvents, + persistLibraryIndexCacheEvents +} from '@/lib/library-index-idb-cache' import { canonicalRelaySessionKey, httpIndexBasesForRelayQuery, @@ -28,6 +32,7 @@ const ENGAGEMENT_ADDRESS_CHUNK = 36 const ENGAGEMENT_EVENT_ID_CHUNK = 44 const MAX_TARGET_ADDRESSES = 480 const HYDRATE_MISSING_CAP = 64 +export const LIBRARY_RECENT_FALLBACK_LIMIT = 10 const QUERY_OPTS = { globalTimeout: 18_000, eoseTimeout: 3_000, @@ -150,6 +155,8 @@ export async function buildLibraryRelayUrls(userPubkey?: string): Promise { const indexRelays = libraryIndexRelayUrls(relayUrls) if (indexRelays.length === 0) return [] + + const cached = await loadLibraryIndexCacheEvents() const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT } const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) @@ -161,13 +168,18 @@ export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise 0 ? dedupeEventsById((await Promise.all(batches)).flat()) : [] + const merged = dedupeEventsById([...cached, ...networkMerged]) const valid = filterValidIndexEvents(merged) + void persistLibraryIndexCacheEvents(valid) if (import.meta.env.DEV) { logger.info('[Library] index fetch', { indexRelays: indexRelays.length, wsRelays: wsRelays.length, httpRelays: httpRelays.length, + cachedCount: cached.length, + networkCount: networkMerged.length, mergedCount: merged.length, validCount: valid.length }) @@ -368,6 +380,33 @@ export function filterEngagedPublications( return out } +export function buildRecentPublicationEntries( + roots: Event[], + limit = LIBRARY_RECENT_FALLBACK_LIMIT +): LibraryPublicationEntry[] { + return [...roots] + .sort((a, b) => b.created_at - a.created_at) + .slice(0, limit) + .map((event) => ({ + event, + hasLabel: false, + hasComment: false, + hasHighlight: false, + engagementCount: 0 + })) +} + +/** Engaged publications first; when none match, show the newest top-level indexes. */ +export function pickLibraryPublicationEntries( + roots: Event[], + indexByAddress: Map, + engagement: PublicationEngagementMaps +): LibraryPublicationEntry[] { + const engaged = sortLibraryPublications(filterEngagedPublications(roots, indexByAddress, engagement)) + if (engaged.length > 0) return engaged + return buildRecentPublicationEntries(roots) +} + export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { return [...entries].sort((a, b) => { if (a.hasLabel !== b.hasLabel) return a.hasLabel ? -1 : 1 @@ -466,7 +505,7 @@ async function buildEngagedFromCache( const targetEventIds = collectPublicationIndexEventIds(indexEvents) maps = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds) } - return sortLibraryPublications(filterEngagedPublications(topLevel, indexByAddress, maps)) + return pickLibraryPublicationEntries(topLevel, indexByAddress, maps) } export async function loadLibraryPublicationIndex( @@ -539,13 +578,14 @@ export async function loadLibraryPublicationIndex( sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement } const topLevel = getTopLevelIndexEvents(indexEvents) - const engaged = sortLibraryPublications(filterEngagedPublications(topLevel, indexByAddress, engagement)) + const engaged = pickLibraryPublicationEntries(topLevel, indexByAddress, engagement) if (import.meta.env.DEV) { logger.info('[Library] load done', { engaged: engaged.length, topLevel: topLevel.length, - allIndexCount: indexEvents.length + allIndexCount: indexEvents.length, + recentFallback: engaged.length > 0 && engaged.every((e) => e.engagementCount === 0) }) } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 7942b790..74262672 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -32,6 +32,7 @@ import { import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' +import { resolveNoteEventSync } from '@/lib/resolve-note-event-sync' import { prewarmArchivesNotePage, profilesFromArchivesNotePageBundle @@ -125,6 +126,26 @@ function eventPointersReferenceSameNote(a: string | undefined, b: string | undef } const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => { + const threadKey = useMemo(() => { + const sync = resolveNoteEventSync(id, initialEvent) + return sync?.id ?? id?.trim() ?? 'pending' + }, [id, initialEvent]) + + return ( + + + + ) +}) +NotePage.displayName = 'NotePage' + +const NotePageBody = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) @@ -538,7 +559,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: } return ( - - ) }) -NotePage.displayName = 'NotePage' +NotePageBody.displayName = 'NotePageBody' export default NotePage function ThreadContextSkeleton() { diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 2633cbdf..30f21345 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -9,8 +9,13 @@ type TReplyContext = { const ReplyContext = createContext(undefined) +/** Returns undefined outside provider (e.g. isolated `createRoot` embeds or HMR context splits). */ +export function useReplyOptional(): TReplyContext | undefined { + return useContext(ReplyContext) +} + export const useReply = () => { - const context = useContext(ReplyContext) + const context = useReplyOptional() if (!context) { throw new Error('useReply must be used within a ReplyProvider') } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index fc7205f4..c468f148 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -142,6 +142,8 @@ export const StoreNames = { TIMELINE_STATE: 'timelineState', /** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */ PIPER_TTS_CACHE: 'piperTtsCache', + /** Library kind-30040 index LRU (rotating cache; separate byte/entry budget from EVENT_ARCHIVE). */ + LIBRARY_PUBLICATION_INDEX: 'libraryPublicationIndex', /** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */ CALENDAR_EVENTS: 'calendarEvents', /** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */ @@ -173,6 +175,7 @@ export type TCalendarRsvpCacheRow = { const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet = new Set([ StoreNames.SETTINGS, StoreNames.PIPER_TTS_CACHE, + StoreNames.LIBRARY_PUBLICATION_INDEX, StoreNames.RELAY_INFOS, StoreNames.NIP66_DISCOVERY, StoreNames.GIF_CACHE, @@ -227,7 +230,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet = new Set([ const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 39 +const DB_VERSION = 40 /** Hint age for profile/payment reads (stale rows still returned; background refresh). */ const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000 @@ -243,6 +246,14 @@ function idbEventToError(ev: Parameters>[0]): return new Error(message) } +type TLibraryPublicationIndexCacheRow = { + key: string + value: Event + addedAt: number + lastAccessAt: number + approxBytes: number +} + /** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */ function ensureMissingObjectStores(db: IDBDatabase): void { for (const storeName of Object.values(StoreNames)) { @@ -269,6 +280,9 @@ function ensureMissingObjectStores(db: IDBDatabase): void { const pa = db.createObjectStore(storeName, { keyPath: 'key' }) pa.createIndex('authorPubkey', 'authorPubkey', { unique: false }) pa.createIndex('targetEventId', 'targetEventId', { unique: false }) + } else if (storeName === StoreNames.LIBRARY_PUBLICATION_INDEX) { + const lib = db.createObjectStore(storeName, { keyPath: 'key' }) + lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false }) } else { db.createObjectStore(storeName, { keyPath: 'key' }) } @@ -509,6 +523,12 @@ class IndexedDbService { pa.createIndex('targetEventId', 'targetEventId', { unique: false }) } } + if (event.oldVersion < 40) { + if (!db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) { + const lib = db.createObjectStore(StoreNames.LIBRARY_PUBLICATION_INDEX, { keyPath: 'key' }) + lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false }) + } + } ensureMissingObjectStores(db) } } @@ -3663,6 +3683,191 @@ class IndexedDbService { } } + private approxLibraryPublicationIndexBytes(ev: Event): number { + try { + return new Blob([JSON.stringify(ev)]).size + } catch { + return 2048 + } + } + + async getLibraryPublicationIndexCacheEvents(): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return [] + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly') + const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor() + const out: Event[] = [] + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve(out) + return + } + const row = cursor.value as TLibraryPublicationIndexCacheRow + if (row?.value?.kind === ExtendedKind.PUBLICATION) out.push(row.value) + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) { + return { count: 0, bytes: 0 } + } + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly') + const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor() + let count = 0 + let bytes = 0 + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve({ count, bytes }) + return + } + const row = cursor.value as TLibraryPublicationIndexCacheRow + count += 1 + bytes += row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value) + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async mergeLibraryPublicationIndexCacheEvents( + events: Event[], + opts: { maxEntries: number; maxBytes: number } + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX) || events.length === 0) { + return + } + + const now = Date.now() + const storeName = StoreNames.LIBRARY_PUBLICATION_INDEX + + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(storeName, 'readwrite') + const store = tx.objectStore(storeName) + let pending = events.length + if (pending === 0) { + tx.commit() + resolve() + return + } + + const finishOne = () => { + pending -= 1 + if (pending === 0) { + tx.commit() + resolve() + } + } + + for (const ev of events) { + const get = store.get(ev.id) + get.onsuccess = () => { + const prev = get.result as TLibraryPublicationIndexCacheRow | undefined + const row: TLibraryPublicationIndexCacheRow = { + key: ev.id, + value: ev, + addedAt: prev?.addedAt ?? now, + lastAccessAt: now, + approxBytes: this.approxLibraryPublicationIndexBytes(ev) + } + const put = store.put(row) + put.onsuccess = () => finishOne() + put.onerror = (e) => { + finishOne() + if (pending === 0) reject(idbEventToError(e)) + } + } + get.onerror = (e) => { + finishOne() + if (pending === 0) reject(idbEventToError(e)) + } + } + }) + + await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes) + } + + async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return + + const rows: Array<{ key: string; lastAccessAt: number; bytes: number }> = [] + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly') + const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve() + return + } + const row = cursor.value as TLibraryPublicationIndexCacheRow + rows.push({ + key: cursor.key as string, + lastAccessAt: row.lastAccessAt ?? row.addedAt, + bytes: row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value) + }) + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + + rows.sort((a, b) => a.lastAccessAt - b.lastAccessAt) + const toDelete = new Set() + let totalBytes = rows.reduce((s, r) => s + r.bytes, 0) + let totalCount = rows.length + while (totalCount > maxEntries || totalBytes > maxBytes) { + const victim = rows.shift() + if (!victim) break + toDelete.add(victim.key) + totalBytes -= victim.bytes + totalCount -= 1 + } + + for (const key of toDelete) { + await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key) + } + } + + async clearLibraryPublicationIndexCacheStore(): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readwrite') + const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).clear() + req.onsuccess = () => { + tx.commit() + resolve() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + /** * Get all tombstoned keys */