From 90b16e845c7a1977b07a80fd41cd0db0e2e14d57 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 8 Jun 2026 13:25:42 +0200 Subject: [PATCH] merge publication stores auto-clear cache on new versions --- package-lock.json | 4 +- package.json | 2 +- .../CacheBrowser/CacheBrowserDialog.tsx | 44 +- .../InBrowserCacheSetting/index.tsx | 75 +- src/components/VersionUpdateBanner/index.tsx | 25 +- src/i18n/locales/en.ts | 4 +- src/lib/app-cache-maintenance.ts | 98 +++ src/lib/library-index-idb-cache.ts | 33 +- src/lib/library-publication-index.ts | 2 +- src/services/indexed-db.service.ts | 740 +++++++++--------- 10 files changed, 552 insertions(+), 475 deletions(-) create mode 100644 src/lib/app-cache-maintenance.ts diff --git a/package-lock.json b/package-lock.json index 19d04102..655e4de2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.21.3", + "version": "23.21.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.21.3", + "version": "23.21.5", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 823ce7fb..658bbce0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.21.4", + "version": "23.21.5", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/CacheBrowser/CacheBrowserDialog.tsx b/src/components/CacheBrowser/CacheBrowserDialog.tsx index d5aebaba..4036dd11 100644 --- a/src/components/CacheBrowser/CacheBrowserDialog.tsx +++ b/src/components/CacheBrowser/CacheBrowserDialog.tsx @@ -6,7 +6,6 @@ import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy, import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' -import { clearAllLibraryIndexCaches } from '@/lib/library-publication-index' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' @@ -42,6 +41,7 @@ export default function CacheBrowserDialog({ const [globalSearchLoading, setGlobalSearchLoading] = useState(false) const [globalSearchTruncated, setGlobalSearchTruncated] = useState(false) const [publicationListFull, setPublicationListFull] = useState(false) + const [publicationStoreTotalRows, setPublicationStoreTotalRows] = useState(null) const globalSearchRequestId = useRef(0) const loadCacheInfo = async () => { @@ -63,6 +63,7 @@ export default function CacheBrowserDialog({ setGlobalSearchHits([]) setGlobalSearchTruncated(false) setPublicationListFull(false) + setPublicationStoreTotalRows(null) void loadCacheInfo() }, [open]) @@ -101,13 +102,20 @@ export default function CacheBrowserDialog({ setSelectedStore(storeName) setSearchQuery('') setPublicationListFull(false) + setPublicationStoreTotalRows(null) setLoadingItems(true) try { - const items = - storeName === 'publicationEvents' - ? await indexedDb.getPublicationStoreItems(storeName) - : await indexedDb.getStoreItems(storeName) - setStoreItems(items) + if (storeName === StoreNames.PUBLICATION_EVENTS) { + const [items, allRows] = await Promise.all([ + indexedDb.getPublicationStoreItems(storeName), + indexedDb.getStoreItems(storeName) + ]) + setPublicationStoreTotalRows(allRows.length) + setStoreItems(items) + } else { + const items = await indexedDb.getStoreItems(storeName) + setStoreItems(items) + } } catch (error) { logger.error('Failed to load store items', { error }) toast.error(t('Failed to load store items')) @@ -178,11 +186,7 @@ export default function CacheBrowserDialog({ if (!selectedStore) return if (!confirm(t('Are you sure you want to delete all items from this store?'))) return try { - if (selectedStore === StoreNames.LIBRARY_PUBLICATION_INDEX) { - await clearAllLibraryIndexCaches() - } else { - await indexedDb.clearStore(selectedStore) - } + await indexedDb.clearStore(selectedStore) setStoreItems([]) void loadCacheInfo() toast.success(t('All items deleted successfully')) @@ -199,12 +203,14 @@ export default function CacheBrowserDialog({ setLoadingItems(true) try { const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore) - const items = await indexedDb.getStoreItems(selectedStore) - setStoreItems(items) setSearchQuery('') void loadCacheInfo() - const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore) - const actualCount = itemsAfterCleanup.length + const items = + selectedStore === StoreNames.PUBLICATION_EVENTS + ? await indexedDb.getPublicationStoreItems(selectedStore) + : await indexedDb.getStoreItems(selectedStore) + setStoreItems(items) + const actualCount = items.length if (actualCount !== result.kept) { toast.success( t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', { @@ -218,7 +224,8 @@ export default function CacheBrowserDialog({ } } catch (error) { logger.error('Failed to cleanup duplicates', { error }) - if (error instanceof Error && error.message === 'Not a replaceable event store') { + const message = error instanceof Error ? error.message : String(error) + if (message === 'Not a replaceable event store') { toast.error(t('This store does not contain replaceable events')) } else { toast.error(t('Failed to cleanup duplicates')) @@ -465,6 +472,11 @@ export default function CacheBrowserDialog({
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} + {selectedStore === StoreNames.PUBLICATION_EVENTS && + publicationStoreTotalRows != null && + publicationStoreTotalRows > storeItems.length + ? ` (${publicationStoreTotalRows} rows incl. nested sections)` + : ''} {searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
diff --git a/src/components/InBrowserCacheSetting/index.tsx b/src/components/InBrowserCacheSetting/index.tsx index 12cd6e7e..4eff378b 100644 --- a/src/components/InBrowserCacheSetting/index.tsx +++ b/src/components/InBrowserCacheSetting/index.tsx @@ -1,4 +1,8 @@ import { Button } from '@/components/ui/button' +import { + clearAppServiceWorkerAndCaches, + refreshAppBrowserCache +} from '@/lib/app-cache-maintenance' import { clearConsoleLogBuffer } from '@/lib/console-log-buffer' import { useConsoleLogBuffer } from '@/hooks/useConsoleLogBuffer' import logger from '@/lib/logger' @@ -15,7 +19,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { toast } from 'sonner' -import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useCacheBrowser } from '../../contexts/cache-browser-context' export default function InBrowserCacheSetting() { @@ -93,11 +96,11 @@ export default function InBrowserCacheSetting() { const handleRefreshCache = async () => { try { setCacheRefreshBusy(true) - await indexedDb.forceDatabaseUpgrade() - if (pubkey) { - await requestAccountNetworkHydrate() - await syncUserDeletionTombstones(pubkey, relayList) - } + await refreshAppBrowserCache({ + pubkey, + relayList, + requestAccountNetworkHydrate + }) toast.success(t('Cache refreshed successfully')) } catch (error) { logger.error('Failed to refresh cache', { error }) @@ -113,65 +116,7 @@ export default function InBrowserCacheSetting() { } try { - const currentOrigin = window.location.origin - let unregisteredCount = 0 - let cacheClearedCount = 0 - - if (window.isSecureContext && 'serviceWorker' in navigator) { - let registrations: readonly ServiceWorkerRegistration[] = [] - try { - registrations = await navigator.serviceWorker.getRegistrations() - } catch (error) { - logger.warn('Failed to get service worker registrations', { error }) - } - - if (registrations.length > 0) { - const unregisterPromises = registrations.map(async (registration) => { - try { - const scope = registration.scope - if (scope.startsWith(currentOrigin)) { - const result = await registration.unregister() - if (result) unregisteredCount++ - return result - } - return false - } catch (error) { - logger.warn('Failed to unregister a service worker', { error }) - return false - } - }) - await Promise.all(unregisterPromises) - } - } - - if ('caches' in window) { - try { - const cacheNames = await caches.keys() - - const appCacheNames = [ - 'nostr-images', - 'satellite-images', - 'external-images' - ] - - const appCaches = cacheNames.filter(name => { - if (appCacheNames.includes(name)) return true - if (name.startsWith('workbox-') || name.startsWith('precache-')) return true - if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) return true - return false - }) - - await Promise.all(appCaches.map(name => { - cacheClearedCount++ - return caches.delete(name).catch(error => { - logger.warn(`Failed to delete cache: ${name}`, { error }) - cacheClearedCount-- - }) - })) - } catch (error) { - logger.warn('Failed to clear some caches', { error }) - } - } + const { unregisteredCount, cacheClearedCount } = await clearAppServiceWorkerAndCaches() if (unregisteredCount > 0 || cacheClearedCount > 0) { const message = unregisteredCount > 0 && cacheClearedCount > 0 diff --git a/src/components/VersionUpdateBanner/index.tsx b/src/components/VersionUpdateBanner/index.tsx index c179b1e8..3daa3b17 100644 --- a/src/components/VersionUpdateBanner/index.tsx +++ b/src/components/VersionUpdateBanner/index.tsx @@ -1,11 +1,13 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { refreshAppBrowserCacheAndClearServiceWorker } from '@/lib/app-cache-maintenance' +import logger from '@/lib/logger' import { - getPwaApplyUpdate, initPwaUpdate, probePwaWaitingWorker, subscribePwaNeedRefresh } from '@/lib/pwa-update' +import { useNostrOptional } from '@/providers/nostr-context' import { RefreshCw, X } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -21,6 +23,7 @@ function readVersionUpdateDismissed(): boolean { export default function VersionUpdateBanner() { const { t } = useTranslation() + const nostr = useNostrOptional() const [updateAvailable, setUpdateAvailable] = useState(false) const [isDismissed, setIsDismissed] = useState(readVersionUpdateDismissed) const [isUpdating, setIsUpdating] = useState(false) @@ -65,16 +68,18 @@ export default function VersionUpdateBanner() { setIsDismissed(true) setIsUpdating(true) - const reload = () => { + void (async () => { + try { + await refreshAppBrowserCacheAndClearServiceWorker({ + pubkey: nostr?.pubkey, + relayList: nostr?.relayList, + requestAccountNetworkHydrate: nostr?.requestAccountNetworkHydrate + }) + } catch (error) { + logger.warn('[VersionUpdateBanner] Pre-update cache refresh failed', { error }) + } window.location.reload() - } - - const apply = getPwaApplyUpdate() - if (apply) { - void apply().catch(reload) - return - } - reload() + })() } const handleDismiss = () => { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 70496ee2..4386742c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1708,7 +1708,7 @@ export default { 'Read this book': 'Read this book', 'libraryIndexCache.sectionTitle': 'Library publication index', 'libraryIndexCache.sectionBlurb': - 'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.', + 'Kind-30040 catalog rows in your publication cache (same store as opened books). Clearing removes relay-discovered titles only—publications you have opened stay cached with their sections.', 'libraryIndexCache.defaultsMobile': 'Default on mobile web: up to {{entries}} indexes, ~{{mb}} MB.', 'libraryIndexCache.defaultsElectron': @@ -1720,7 +1720,7 @@ export default { 'libraryIndexCache.clear': 'Clear library index cache', 'libraryIndexCache.clearing': 'Clearing…', 'libraryIndexCache.clearConfirm': - 'Clear the Library index cache? The Library tab will reload indexes from relays on next visit. Opened publications stay in your publication reading cache.', + 'Clear relay-discovered library catalog entries? Opened publications and their sections stay in the publication cache.', 'libraryIndexCache.clearedToast': 'Library index cache cleared.', 'libraryIndexCache.clearFailed': 'Failed to clear library index cache.', 'Search page clear': 'Clear', diff --git a/src/lib/app-cache-maintenance.ts b/src/lib/app-cache-maintenance.ts new file mode 100644 index 00000000..5c24d306 --- /dev/null +++ b/src/lib/app-cache-maintenance.ts @@ -0,0 +1,98 @@ +import logger from '@/lib/logger' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' +import indexedDb from '@/services/indexed-db.service' +import type { TRelayList } from '@/types' + +const APP_CACHE_NAMES = ['nostr-images', 'satellite-images', 'external-images'] as const + +function isAppRelatedCacheName(name: string, origin: string): boolean { + if ((APP_CACHE_NAMES as readonly string[]).includes(name)) return true + if (name.startsWith('workbox-') || name.startsWith('precache-')) return true + const host = origin.replace(/https?:\/\//, '').split('/')[0] + return name.includes(host) +} + +export type ClearAppServiceWorkerResult = { + unregisteredCount: number + cacheClearedCount: number +} + +export async function clearAppPrecacheCaches(): Promise { + if (typeof window === 'undefined' || !('caches' in window)) return 0 + + let cleared = 0 + try { + const cacheNames = await caches.keys() + const origin = window.location.origin + const appCaches = cacheNames.filter((name) => isAppRelatedCacheName(name, origin)) + await Promise.all( + appCaches.map((name) => + caches + .delete(name) + .then((ok) => { + if (ok) cleared++ + }) + .catch((error) => { + logger.warn('[app-cache] Failed to delete cache', { name, error }) + }) + ) + ) + } catch (error) { + logger.warn('[app-cache] Failed to clear precache caches', { error }) + } + return cleared +} + +export async function unregisterAppServiceWorkers(): Promise { + if (typeof window === 'undefined' || !window.isSecureContext || !('serviceWorker' in navigator)) { + return 0 + } + + let count = 0 + try { + const registrations = await navigator.serviceWorker.getRegistrations() + const origin = window.location.origin + await Promise.all( + registrations.map(async (registration) => { + if (!registration.scope.startsWith(origin)) return + try { + if (await registration.unregister()) count++ + } catch (error) { + logger.warn('[app-cache] Failed to unregister service worker', { error }) + } + }) + ) + } catch (error) { + logger.warn('[app-cache] Failed to get service worker registrations', { error }) + } + return count +} + +export async function clearAppServiceWorkerAndCaches(): Promise { + const cacheClearedCount = await clearAppPrecacheCaches() + const unregisteredCount = await unregisterAppServiceWorkers() + return { unregisteredCount, cacheClearedCount } +} + +export type RefreshAppBrowserCacheOptions = { + pubkey?: string | null + relayList?: TRelayList | null + requestAccountNetworkHydrate?: () => Promise +} + +export async function refreshAppBrowserCache(options?: RefreshAppBrowserCacheOptions): Promise { + await indexedDb.forceDatabaseUpgrade() + const pubkey = options?.pubkey?.trim() + if (pubkey && options?.requestAccountNetworkHydrate) { + await options.requestAccountNetworkHydrate() + await syncUserDeletionTombstones(pubkey, options.relayList ?? null) + } +} + +/** IndexedDB refresh + service worker unregister + Cache API clear (settings / update banner). */ +export async function refreshAppBrowserCacheAndClearServiceWorker( + options?: RefreshAppBrowserCacheOptions +): Promise { + await refreshAppBrowserCache(options) + return clearAppServiceWorkerAndCaches() +} diff --git a/src/lib/library-index-idb-cache.ts b/src/lib/library-index-idb-cache.ts index 7eca27f9..0e217933 100644 --- a/src/lib/library-index-idb-cache.ts +++ b/src/lib/library-index-idb-cache.ts @@ -12,30 +12,22 @@ import indexedDb from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' type PersistLibraryIndexCacheOptions = { - /** When false, merge rows only — skip reconcile so partial batches cannot wipe unrelated cache rows. */ + /** Accepted for API compat; reconcile is a no-op after v43 store consolidation. */ reconcile?: boolean } let persistQueue: Promise = Promise.resolve() +/** Load kind-30040 catalog masters from {@link StoreNames.PUBLICATION_EVENTS}. */ export async function loadLibraryIndexCacheEvents(): Promise { try { - const cached = await indexedDb.getLibraryPublicationIndexCacheEvents() - // Structural re-check + address dedupe only — avoid ~5k verifyEvent on read (main-thread hang). + const cached = await indexedDb.getPublicationCatalogIndexEvents() const structural = filterStructuralIndexEvents(cached) const map = buildStructuralPublicationIndexMap(structural) - const normalized = publicationIndexMapValues(map) - if (structural.length < cached.length) { - void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {}) - } - const hasLegacyKeys = await indexedDb.libraryPublicationIndexCacheHasLegacyKeys() - if (normalized.length !== cached.length || hasLegacyKeys) { - void persistLibraryIndexCacheEvents(normalized, { reconcile: true }).catch(() => {}) - } - return normalized + return publicationIndexMapValues(map) } catch (e) { if (import.meta.env.DEV) { - logger.warn('[Library] index IDB read failed', { + logger.warn('[Library] publication catalog IDB read failed', { message: e instanceof Error ? e.message : String(e) }) } @@ -43,25 +35,22 @@ export async function loadLibraryIndexCacheEvents(): Promise { } } +/** Persist kind-30040 catalog masters into {@link StoreNames.PUBLICATION_EVENTS}. */ export async function persistLibraryIndexCacheEvents( events: Event[], - options?: PersistLibraryIndexCacheOptions + _options?: PersistLibraryIndexCacheOptions ): Promise { const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events)) const normalized = publicationIndexMapValues(map) if (normalized.length === 0) return - const reconcile = options?.reconcile !== false const run = async () => { try { const budget = getLibraryIndexCacheBudget() - await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget) - if (reconcile) { - await indexedDb.reconcileLibraryPublicationIndexCache(map) - } + await indexedDb.mergePublicationCatalogIndexEvents(normalized, budget) } catch (e) { if (import.meta.env.DEV) { - logger.warn('[Library] index IDB write failed', { + logger.warn('[Library] publication catalog IDB write failed', { message: e instanceof Error ? e.message : String(e) }) } @@ -74,14 +63,14 @@ export async function persistLibraryIndexCacheEvents( export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> { try { - return await indexedDb.getLibraryPublicationIndexCacheFootprint() + return await indexedDb.getPublicationCatalogFootprint() } catch { return { count: 0, bytes: 0 } } } export async function clearLibraryIndexIdbCache(): Promise { - await indexedDb.clearLibraryPublicationIndexCacheStore() + await indexedDb.clearPublicationCatalogDiscoveryOnly() } export { approxLibraryIndexEventBytes, getLibraryIndexCacheBudget } diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index 309e40f3..2fcafacd 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -2210,7 +2210,7 @@ export function clearLibraryPublicationIndexCache(): void { clearLibrarySearchSessionCache() } -/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */ +/** Clears Library tab session cache and relay-discovered catalog masters (opened publications stay). */ export async function clearAllLibraryIndexCaches(): Promise { sessionCache = null indexLoadJob = null diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 0367ccd1..f52b0c02 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1,7 +1,11 @@ import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' /** Legacy object store names removed in DB migrations (do not re-add to {@link StoreNames}). */ -const LEGACY_DELETED_OBJECT_STORES = ['relayInfoEvents', 'spellListSourceEvents'] as const +const LEGACY_DELETED_OBJECT_STORES = [ + 'relayInfoEvents', + 'spellListSourceEvents', + 'libraryPublicationIndex' +] as const import { publicationCoordinateLookupKeys, splitPublicationCoordinate @@ -28,9 +32,7 @@ import logger from '@/lib/logger' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { - eventTagAddress, isStructuralPublicationIndex, - isVerifiedPublicationIndex, pickNewerPublicationIndexEvent, type PublicationIndexMap } from '@/lib/publication-index' @@ -71,6 +73,12 @@ type TValue = { value: T | null addedAt: number masterPublicationKey?: string // For nested publication events, link to master publication + /** 1 when `value` is a kind-30040 library catalog master (indexed for Library tab). */ + catalogMaster?: 0 | 1 + /** LRU touch time for catalog masters in {@link StoreNames.PUBLICATION_EVENTS}. */ + lastAccessAt?: number + /** Approximate JSON size for catalog-master LRU pruning. */ + catalogBytes?: number } /** One matching row from {@link IndexedDbService.searchAllCachedEventsFullText}. */ @@ -150,7 +158,6 @@ export const StoreNames = { /** 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). */ @@ -182,7 +189,6 @@ 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, @@ -237,7 +243,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 = 41 +const DB_VERSION = 43 /** Hint age for profile/payment reads (stale rows still returned; background refresh). */ const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000 @@ -253,7 +259,7 @@ function idbEventToError(ev: Parameters>[0]): return new Error(message) } -type TLibraryPublicationIndexCacheRow = { +type TLegacyLibraryPublicationIndexRow = { key: string value: Event addedAt: number @@ -261,7 +267,7 @@ type TLibraryPublicationIndexCacheRow = { approxBytes: number } -function approxLibraryPublicationIndexRowBytes(ev: Event): number { +function approxPublicationCatalogMasterBytes(ev: Event): number { try { return new Blob([JSON.stringify(ev)]).size } catch { @@ -269,53 +275,125 @@ function approxLibraryPublicationIndexRowBytes(ev: Event): number { } } -/** v41: re-key library index rows from event id to kind:pubkey:d; dedupe by address. */ -function migrateLibraryPublicationIndexCacheToAddressKeys(transaction: IDBTransaction): void { - const store = transaction.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX) - const rows: TLibraryPublicationIndexCacheRow[] = [] +function publicationStoreRowKeyForEvent(event: Event): string { + const [, d] = event.tags.find(tagNameEquals('d')) ?? [] + const trimmed = event.pubkey.trim() + const canonPk = /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed.toLowerCase() : trimmed + return d === undefined ? canonPk : `${canonPk}:${d}` +} + +function buildPublicationStoreRow( + key: string, + event: Event, + prev: TValue | undefined, + masterPublicationKey?: string +): TValue { + const now = Date.now() + const isCatalogMaster = event.kind === ExtendedKind.PUBLICATION && !masterPublicationKey + const row: TValue = { + key, + value: event, + addedAt: prev?.addedAt ?? now, + ...(masterPublicationKey ? { masterPublicationKey } : {}) + } + if (isCatalogMaster) { + row.catalogMaster = 1 + row.lastAccessAt = Math.max(prev?.lastAccessAt ?? 0, now) + row.catalogBytes = approxPublicationCatalogMasterBytes(event) + } else if (storeRowIsPublicationEvent(event)) { + row.catalogMaster = 0 + } + return row +} + +function storeRowIsPublicationEvent(event: Event): boolean { + return ( + event.kind === ExtendedKind.PUBLICATION || + event.kind === ExtendedKind.PUBLICATION_CONTENT || + event.kind === ExtendedKind.WIKI_ARTICLE || + event.kind === ExtendedKind.NOSTR_SPECIFICATION || + event.kind === kinds.LongFormArticle + ) +} + +function ensurePublicationEventsCatalogIndexes(store: IDBObjectStore): void { + if (!store.indexNames.contains('catalogMaster')) { + store.createIndex('catalogMaster', 'catalogMaster', { unique: false }) + } +} + +function backfillPublicationCatalogMetadata(store: IDBObjectStore): void { + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) return + const row = cursor.value as TValue + const event = row?.value + if (!event || !storeRowIsPublicationEvent(event)) { + cursor.continue() + return + } + const isCatalogMaster = event.kind === ExtendedKind.PUBLICATION && !row.masterPublicationKey + const next: TValue = { ...row } + if (isCatalogMaster) { + next.catalogMaster = 1 + next.lastAccessAt = row.lastAccessAt ?? row.addedAt ?? Date.now() + next.catalogBytes = row.catalogBytes ?? approxPublicationCatalogMasterBytes(event) + } else { + next.catalogMaster = 0 + } + if ( + next.catalogMaster !== row.catalogMaster || + next.lastAccessAt !== row.lastAccessAt || + next.catalogBytes !== row.catalogBytes + ) { + const updateReq = cursor.update(next) + updateReq.onsuccess = () => cursor.continue() + updateReq.onerror = () => cursor.continue() + } else { + cursor.continue() + } + } +} + +/** v43: merge legacy libraryPublicationIndex into publicationEvents; catalogMaster index. */ +function migrateLegacyLibraryPublicationIndexStore(transaction: IDBTransaction, db: IDBDatabase): void { + const legacyName = 'libraryPublicationIndex' + if (!db.objectStoreNames.contains(legacyName) || !db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { + return + } + const legacyStore = transaction.objectStore(legacyName) + const pubStore = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) + const legacyRows: TLegacyLibraryPublicationIndexRow[] = [] - const readReq = store.openCursor() + const readReq = legacyStore.openCursor() readReq.onsuccess = () => { - const cursor = readReq.result + const cursor = readReq.result as IDBCursorWithValue | null if (cursor) { - rows.push(cursor.value as TLibraryPublicationIndexCacheRow) + legacyRows.push(cursor.value as TLegacyLibraryPublicationIndexRow) cursor.continue() return } - const byAddress = new Map() - for (const row of rows) { + for (const row of legacyRows) { const ev = row?.value if (!ev || ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue - const addr = eventTagAddress(ev) - if (!addr) continue - - const existing = byAddress.get(addr) - if (!existing) { - byAddress.set(addr, { - key: addr, - value: ev, - addedAt: row.addedAt ?? Date.now(), - lastAccessAt: row.lastAccessAt ?? row.addedAt ?? Date.now(), - approxBytes: row.approxBytes ?? approxLibraryPublicationIndexRowBytes(ev) - }) - continue - } - - const winner = pickNewerPublicationIndexEvent(existing.value, ev) - byAddress.set(addr, { - key: addr, - value: winner, - addedAt: Math.min(existing.addedAt, row.addedAt ?? existing.addedAt), - lastAccessAt: Math.max(existing.lastAccessAt, row.lastAccessAt ?? row.addedAt ?? 0), - approxBytes: approxLibraryPublicationIndexRowBytes(winner) - }) - } - - const clearReq = store.clear() - clearReq.onsuccess = () => { - for (const row of byAddress.values()) { - store.put(row) + const key = publicationStoreRowKeyForEvent(ev) + const getReq = pubStore.get(key) + getReq.onsuccess = () => { + const prev = getReq.result as TValue | undefined + const winner = + prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id + ? prev.value + : ev + const merged: TValue = buildPublicationStoreRow(key, winner, prev) + merged.addedAt = Math.min(prev?.addedAt ?? row.addedAt ?? Date.now(), row.addedAt ?? Date.now()) + merged.lastAccessAt = Math.max( + prev?.lastAccessAt ?? 0, + row.lastAccessAt ?? row.addedAt ?? 0 + ) + merged.catalogBytes = approxPublicationCatalogMasterBytes(winner) + pubStore.put(merged) } } } @@ -347,9 +425,11 @@ 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 if (storeName === StoreNames.PUBLICATION_EVENTS) { + const store = db.createObjectStore(storeName, { keyPath: 'key' }) + ensurePublicationEventsCatalogIndexes(store) + } else if (storeName === 'libraryPublicationIndex') { + /* dropped in v43 — do not recreate */ } else { db.createObjectStore(storeName, { keyPath: 'key' }) } @@ -514,7 +594,8 @@ class IndexedDbService { db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' }) } if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { - db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' }) + const pub = db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' }) + ensurePublicationEventsCatalogIndexes(pub) } if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) { db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' }) @@ -591,15 +672,29 @@ class IndexedDbService { } } if (event.oldVersion < 40) { - if (!db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) { - const lib = db.createObjectStore(StoreNames.LIBRARY_PUBLICATION_INDEX, { keyPath: 'key' }) + if (!db.objectStoreNames.contains('libraryPublicationIndex')) { + const lib = db.createObjectStore('libraryPublicationIndex', { keyPath: 'key' }) lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false }) } } if (event.oldVersion < 41) { const tx = (event.target as IDBOpenDBRequest).transaction - if (tx && db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) { - migrateLibraryPublicationIndexCacheToAddressKeys(tx) + if (tx && db.objectStoreNames.contains('libraryPublicationIndex')) { + // v41 migration superseded by v43 consolidation into publicationEvents + } + } + if (event.oldVersion < 43) { + const tx = (event.target as IDBOpenDBRequest).transaction + if (tx && db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { + const pubStore = tx.objectStore(StoreNames.PUBLICATION_EVENTS) + ensurePublicationEventsCatalogIndexes(pubStore) + backfillPublicationCatalogMetadata(pubStore) + } + if (tx) { + migrateLegacyLibraryPublicationIndexStore(tx, db) + } + if (db.objectStoreNames.contains('libraryPublicationIndex')) { + db.deleteObjectStore('libraryPublicationIndex') } } ensureMissingObjectStores(db) @@ -690,7 +785,11 @@ class IndexedDbService { return resolve(oldValue.value) } - const putRequest = store.put(this.formatValue(key, cleanEvent)) + const putRequest = store.put( + storeName === StoreNames.PUBLICATION_EVENTS + ? buildPublicationStoreRow(key, cleanEvent, oldValue) + : this.formatValue(key, cleanEvent) + ) putRequest.onsuccess = () => { transaction.commit() resolve(cleanEvent) @@ -1385,16 +1484,14 @@ class IndexedDbService { if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) { // Update master key link even if event is not newer if (oldValue.masterPublicationKey !== masterKey) { - const value = this.formatValue(key, oldValue.value) - value.masterPublicationKey = masterKey + const value = buildPublicationStoreRow(key, oldValue.value, oldValue, masterKey) store.put(value) } transaction.commit() return resolve(oldValue.value) } // Store with master key link - const value = this.formatValue(key, cleanEvent) - value.masterPublicationKey = masterKey + const value = buildPublicationStoreRow(key, cleanEvent, oldValue, masterKey) const putRequest = store.put(value) putRequest.onsuccess = () => { transaction.commit() @@ -1442,8 +1539,7 @@ class IndexedDbService { // For non-replaceable events, use event ID as key const key = event.id // For non-replaceable events, always update with master key link - const value = this.formatValue(key, event) - value.masterPublicationKey = masterKey + const value = buildPublicationStoreRow(key, event, undefined, masterKey) const putRequest = store.put(value) putRequest.onsuccess = () => { transaction.commit() @@ -2080,7 +2176,7 @@ class IndexedDbService { storeInfo[storeName] = req.result pending-- if (pending === 0) { - resolve(storeInfo) + void this.normalizePublicationStoreInfoCount(storeInfo).then(resolve).catch(reject) } } req.onerror = (ev) => { @@ -2090,6 +2186,116 @@ class IndexedDbService { }) } + /** Master kind-30040 catalog rows in {@link StoreNames.PUBLICATION_EVENTS}. */ + async countPublicationStoreMasterEvents(): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return 0 + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') + const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS) + if (store.indexNames.contains('catalogMaster')) { + const req = store.index('catalogMaster').count(IDBKeyRange.only(1)) + req.onsuccess = () => { + tx.commit() + resolve(req.result) + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + return + } + const req = store.openCursor() + let count = 0 + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve(count) + return + } + const item = cursor.value as TValue | undefined + if (item?.catalogMaster === 1 || (item?.value?.kind === ExtendedKind.PUBLICATION && !item.masterPublicationKey)) { + count += 1 + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + /** Kind-30040 library catalog masters in {@link StoreNames.PUBLICATION_EVENTS}. */ + async getPublicationCatalogIndexEvents(): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return [] + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') + const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS) + const out: Event[] = [] + + const collectRow = (row: TValue | undefined) => { + const event = row?.value + if (!event || event.kind !== ExtendedKind.PUBLICATION) return + if (row?.catalogMaster === 1 || !row.masterPublicationKey) out.push(event) + } + + if (store.indexNames.contains('catalogMaster')) { + const req = store.index('catalogMaster').openCursor(IDBKeyRange.only(1)) + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve(out) + return + } + collectRow(cursor.value as TValue) + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + return + } + + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve(out) + return + } + collectRow(cursor.value as TValue) + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + /** @deprecated Use {@link getPublicationCatalogIndexEvents}. */ + async getMasterPublicationIndexEventsFromReadingCache(): Promise { + return this.getPublicationCatalogIndexEvents() + } + + private async normalizePublicationStoreInfoCount(storeInfo: Record): Promise> { + if (!storeInfo[StoreNames.PUBLICATION_EVENTS]) return storeInfo + try { + storeInfo[StoreNames.PUBLICATION_EVENTS] = await this.countPublicationStoreMasterEvents() + } catch (e) { + logger.warn('[indexedDb] countPublicationStoreMasterEvents failed', { e }) + } + return storeInfo + } + async getStoreItems(storeName: string): Promise[]> { await this.initPromise if (!this.db || !this.db.objectStoreNames.contains(storeName)) { @@ -2287,13 +2493,27 @@ class IndexedDbService { return { deleted: 0, kept: 0 } } + if (storeName === StoreNames.PUBLICATION_EVENTS) { + return this.cleanupDuplicateEventsByDedupeKey(storeName, (event) => + this.getReplaceableEventKeyFromEvent(event) + ) + } + // Get the kind for this store - only clean up replaceable event stores const kind = this.getKindByStoreName(storeName) if (!kind || !this.isReplaceableEventKind(kind)) { - return Promise.reject('Not a replaceable event store') + return Promise.reject(new Error('Not a replaceable event store')) } - // First pass: identify duplicates + return this.cleanupDuplicateEventsByDedupeKey(storeName, (event) => + this.getReplaceableEventKeyFromEvent(event) + ) + } + + private async cleanupDuplicateEventsByDedupeKey( + storeName: string, + dedupeKeyForEvent: (event: Event) => string + ): Promise<{ deleted: number; kept: number }> { const allItems = await this.getStoreItems(storeName) const eventMap = new Map() const keysToDelete: string[] = [] @@ -2304,48 +2524,42 @@ class IndexedDbService { invalidItemsCount++ continue } - - // Skip if event doesn't have required fields + if (!item.value.pubkey || !item.value.kind || !item.value.created_at) { invalidItemsCount++ continue } - + try { - const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value) - const existing = eventMap.get(replaceableKey) - - if (!existing || - item.value.created_at > existing.event.created_at || - (item.value.created_at === existing.event.created_at && - item.addedAt > existing.addedAt)) { - // This event is newer, mark the old one for deletion if it exists + const dedupeKey = dedupeKeyForEvent(item.value) + const existing = eventMap.get(dedupeKey) + + if ( + !existing || + item.value.created_at > existing.event.created_at || + (item.value.created_at === existing.event.created_at && item.addedAt > existing.addedAt) + ) { if (existing) { keysToDelete.push(existing.key) } - eventMap.set(replaceableKey, { + eventMap.set(dedupeKey, { key: item.key, event: item.value, addedAt: item.addedAt }) } else { - // This event is older or same, mark it for deletion keysToDelete.push(item.key) } } catch (error) { - // If we can't generate a replaceable key, skip this item - logger.warn('Failed to get replaceable key for item', { key: item.key, error }) + logger.warn('Failed to get dedupe key for item', { key: item.key, error }) invalidItemsCount++ - continue } } - // Second pass: delete duplicates - const totalProcessed = eventMap.size + keysToDelete.length const actualKept = eventMap.size - + if (keysToDelete.length === 0) { - // No duplicates found, but verify counts match + const totalProcessed = eventMap.size + keysToDelete.length if (totalProcessed + invalidItemsCount !== allItems.length) { logger.warn('Count mismatch while cleaning up replaceable events', { totalItems: allItems.length, @@ -2353,24 +2567,23 @@ class IndexedDbService { invalid: invalidItemsCount }) } - return Promise.resolve({ deleted: 0, kept: actualKept }) + return { deleted: 0, kept: actualKept } } return new Promise((resolve) => { const transaction = this.db!.transaction(storeName, 'readwrite') const store = transaction.objectStore(storeName) - + let deletedCount = 0 let completedCount = 0 - keysToDelete.forEach(key => { + keysToDelete.forEach((key) => { const deleteRequest = store.delete(key) deleteRequest.onsuccess = () => { deletedCount++ completedCount++ if (completedCount === keysToDelete.length) { transaction.commit() - const actualKept = eventMap.size const totalProcessed = actualKept + deletedCount if (totalProcessed + invalidItemsCount !== allItems.length) { logger.warn('Count mismatch after deletion', { @@ -2387,7 +2600,6 @@ class IndexedDbService { completedCount++ if (completedCount === keysToDelete.length) { transaction.commit() - const actualKept = eventMap.size resolve({ deleted: deletedCount, kept: actualKept }) } } @@ -3756,22 +3968,19 @@ class IndexedDbService { } } - private approxLibraryPublicationIndexBytes(ev: Event): number { - try { - return new Blob([JSON.stringify(ev)]).size - } catch { - return 2048 - } - } - - async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise { + private async listPublicationCatalogMasterRows(): Promise< + Array<{ key: string; lastAccessAt: number; bytes: number; hasNested: boolean }> + > { await this.initPromise - if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return 0 + if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return [] + + const nestedMasterKeys = new Set() + const masters: Array<{ key: string; lastAccessAt: number; bytes: number }> = [] - const toDelete: string[] = [] await new Promise((resolve, reject) => { - const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly') - const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor() + const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') + const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS) + const req = store.openCursor() req.onsuccess = () => { const cursor = req.result as IDBCursorWithValue | null if (!cursor) { @@ -3779,53 +3988,19 @@ class IndexedDbService { resolve() return } - const row = cursor.value as TLibraryPublicationIndexCacheRow - if (row?.value?.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(row.value)) { - toDelete.push(cursor.key as string) - } - cursor.continue() - } - req.onerror = (e) => { - tx.commit() - reject(idbEventToError(e)) - } - }) - - for (const key of toDelete) { - await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key) - } - return toDelete.length - } - - /** True when any row is keyed by event id instead of kind:pubkey:d address. */ - async libraryPublicationIndexCacheHasLegacyKeys(): Promise { - await this.initPromise - if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return false - - return 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(false) - return - } - const rowKey = cursor.key as string - const row = cursor.value as TLibraryPublicationIndexCacheRow - const ev = row?.value - const addr = ev ? eventTagAddress(ev) : null - if ( - !ev || - ev.kind !== ExtendedKind.PUBLICATION || - !isStructuralPublicationIndex(ev) || - !addr || - rowKey !== addr + const row = cursor.value as TValue + const key = cursor.key as string + if (row?.masterPublicationKey) { + nestedMasterKeys.add(row.masterPublicationKey) + } else if ( + row?.catalogMaster === 1 || + (row?.value?.kind === ExtendedKind.PUBLICATION && !row.masterPublicationKey) ) { - tx.commit() - resolve(true) - return + masters.push({ + key, + lastAccessAt: row.lastAccessAt ?? row.addedAt, + bytes: row.catalogBytes ?? approxPublicationCatalogMasterBytes(row.value as Event) + }) } cursor.continue() } @@ -3834,241 +4009,94 @@ class IndexedDbService { reject(idbEventToError(e)) } }) - } - 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)) - } - }) + return masters.map((row) => ({ ...row, hasNested: nestedMasterKeys.has(row.key) })) } - async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> { - await this.initPromise - if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) { - return { count: 0, bytes: 0 } + async getPublicationCatalogFootprint(): Promise<{ count: number; bytes: number }> { + const rows = await this.listPublicationCatalogMasterRows() + return { + count: rows.length, + bytes: rows.reduce((sum, row) => sum + row.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( + async mergePublicationCatalogIndexEvents( 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 - const rowsToWrite: Array<{ key: string; event: Event }> = [] - for (const ev of events) { if (ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue - const key = eventTagAddress(ev) - if (!key) continue - const existing = rowsToWrite.find((row) => row.key === key) - if (!existing) { - rowsToWrite.push({ key, event: ev }) - continue - } - existing.event = pickNewerPublicationIndexEvent(existing.event, ev) + await this.putReplaceableEvent(ev) } + await this.prunePublicationCatalogMasters(opts.maxEntries, opts.maxBytes) + } - if (rowsToWrite.length === 0) return + async prunePublicationCatalogMasters(maxEntries: number, maxBytes: number): Promise { + const rows = await this.listPublicationCatalogMasterRows() + const candidates = rows + .filter((r) => !r.hasNested) + .sort((a, b) => a.lastAccessAt - b.lastAccessAt) + let totalBytes = rows.reduce((s, r) => s + r.bytes, 0) + let totalCount = rows.length + const toDelete = new Set() - await new Promise((resolve, reject) => { - const tx = this.db!.transaction(storeName, 'readwrite') - const store = tx.objectStore(storeName) - let pending = rowsToWrite.length + for (const victim of candidates) { + if (totalCount <= maxEntries && totalBytes <= maxBytes) break + toDelete.add(victim.key) + totalBytes -= victim.bytes + totalCount -= 1 + } - const finishOne = () => { - pending -= 1 - if (pending === 0) { - tx.commit() - resolve() - } - } + for (const key of toDelete) { + await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key) + } + } - for (const { key, event: ev } of rowsToWrite) { - const get = store.get(key) - get.onsuccess = () => { - const prev = get.result as TLibraryPublicationIndexCacheRow | undefined - if (prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id) { - finishOne() - return - } - const row: TLibraryPublicationIndexCacheRow = { - key, - 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)) - } + /** Remove relay-discovered catalog masters that were never opened (no nested sections cached). */ + async clearPublicationCatalogDiscoveryOnly(): Promise { + const rows = await this.listPublicationCatalogMasterRows() + for (const row of rows) { + if (!row.hasNested) { + await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, row.key) } - }) - - await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes) + } } - /** Drop invalid, legacy id-keyed, and superseded rows after an address-keyed merge. */ - async reconcileLibraryPublicationIndexCache(canonical: PublicationIndexMap): Promise { - await this.initPromise - if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return - - const toDelete: string[] = [] - 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 rowKey = cursor.key as string - const row = cursor.value as TLibraryPublicationIndexCacheRow - const ev = row?.value - const addr = ev ? eventTagAddress(ev) : null - const canon = addr ? canonical.get(addr) : undefined - const invalid = - !ev || - ev.kind !== ExtendedKind.PUBLICATION || - !isStructuralPublicationIndex(ev) || - !addr || - rowKey !== addr - const superseded = Boolean(canon && canon.id !== ev?.id) - if (invalid || superseded) { - toDelete.push(rowKey) - } - cursor.continue() - } - req.onerror = (e) => { - tx.commit() - reject(idbEventToError(e)) - } - }) + /** @deprecated Use {@link getPublicationCatalogFootprint}. */ + async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> { + return this.getPublicationCatalogFootprint() + } - for (const key of toDelete) { - await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key) - } + /** @deprecated Use {@link getPublicationCatalogIndexEvents}. */ + async getLibraryPublicationIndexCacheEvents(): Promise { + return this.getPublicationCatalogIndexEvents() } - async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise { - await this.initPromise - if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return + /** @deprecated Use {@link mergePublicationCatalogIndexEvents}. */ + async mergeLibraryPublicationIndexCacheEvents( + events: Event[], + opts: { maxEntries: number; maxBytes: number } + ): Promise { + return this.mergePublicationCatalogIndexEvents(events, opts) + } - 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)) - } - }) + /** @deprecated Use {@link clearPublicationCatalogDiscoveryOnly}. */ + async clearLibraryPublicationIndexCacheStore(): Promise { + return this.clearPublicationCatalogDiscoveryOnly() + } - 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 - } + /** @deprecated No-op after v43 consolidation. */ + async reconcileLibraryPublicationIndexCache(_canonical: PublicationIndexMap): Promise {} - for (const key of toDelete) { - await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key) - } + /** @deprecated No-op after v43 consolidation. */ + async libraryPublicationIndexCacheHasLegacyKeys(): Promise { + return false } - 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)) - } - }) + /** @deprecated No-op after v43 consolidation. */ + async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise { + return 0 } /**