diff --git a/package.json b/package.json index 9a16796b..823ce7fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.21.3", + "version": "23.21.4", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/lib/library-index-idb-cache.ts b/src/lib/library-index-idb-cache.ts index ab12f8e5..44d74a12 100644 --- a/src/lib/library-index-idb-cache.ts +++ b/src/lib/library-index-idb-cache.ts @@ -4,21 +4,28 @@ import { getLibraryIndexCacheBudget } from '@/lib/library-index-cache-config' import logger from '@/lib/logger' -import { filterStructuralIndexEvents } from '@/lib/publication-index' +import { + buildStructuralPublicationIndexMap, + filterStructuralIndexEvents, + publicationIndexMapValues +} from '@/lib/publication-index' import indexedDb from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' export async function loadLibraryIndexCacheEvents(): Promise { try { const cached = await indexedDb.getLibraryPublicationIndexCacheEvents() - // IDB rows were verified on write; structural re-check only (avoid ~5k verifyEvent on read). + // Structural re-check + address dedupe only — avoid ~5k verifyEvent on read (main-thread hang). const structural = filterStructuralIndexEvents(cached) + const map = buildStructuralPublicationIndexMap(structural) + const normalized = publicationIndexMapValues(map) if (structural.length < cached.length) { - void indexedDb - .pruneUnverifiedLibraryPublicationIndexCacheEvents() - .catch(() => {}) + void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {}) + } + if (normalized.length !== cached.length) { + void persistLibraryIndexCacheEvents(normalized).catch(() => {}) } - return structural + return normalized } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] index IDB read failed', { @@ -30,11 +37,13 @@ export async function loadLibraryIndexCacheEvents(): Promise { } export async function persistLibraryIndexCacheEvents(events: Event[]): Promise { - const kind30040 = events.filter((ev) => ev.kind === ExtendedKind.PUBLICATION) - if (kind30040.length === 0) return + const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events)) + const normalized = publicationIndexMapValues(map) + if (normalized.length === 0) return try { const budget = getLibraryIndexCacheBudget() - await indexedDb.mergeLibraryPublicationIndexCacheEvents(kind30040, budget) + await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget) + await indexedDb.reconcileLibraryPublicationIndexCache(map) } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] index IDB write failed', { diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index dbe8329f..1ab8b4af 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -22,20 +22,25 @@ import { } from '@/lib/library-publication-index' import { buildIndexByAddress } from '@/lib/publication-index' import type { Event } from 'nostr-tools' -import { kinds } from 'nostr-tools' - -const PK = 'a'.repeat(64) - -function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { - return { - id, - kind: ExtendedKind.PUBLICATION, - pubkey: PK, - created_at: 100, - content: '', - tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])], - sig: 'c'.repeat(128) - } +import { finalizeEvent, generateSecretKey, getPublicKey, kinds } from 'nostr-tools' + +const sk = generateSecretKey() +const PK = getPublicKey(sk) + +function indexEvent( + d: string, + aTags: string[], + opts?: { created_at?: number } +): Event { + return finalizeEvent( + { + kind: ExtendedKind.PUBLICATION, + created_at: opts?.created_at ?? 100, + content: '', + tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])] + }, + sk + ) } describe('library-publication-index', () => { diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index dd6d02bf..1fabca6b 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -11,13 +11,18 @@ import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http' import { buildIndexByAddress, + buildStructuralPublicationIndexMap, collectPublicationIndexEventIds, collectReachableAddressesCached, eventTagAddress, filterValidIndexEvents, getReferencedChild30040Addresses, getTopLevelIndexEvents, - hydrateNestedIndexEvents + getTopLevelIndexEventsFromMap, + hydrateNestedIndexEvents, + mergePublicationIndexMaps, + publicationIndexMapValues, + type PublicationIndexMap } from '@/lib/publication-index' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { verifyEvent } from 'nostr-tools' @@ -124,8 +129,7 @@ export type LibraryPublicationEntry = { type LibraryIndexCache = { relayKey: string viewerPubkey: string | null - indexEvents: Event[] - indexByAddress: Map + indexByAddress: PublicationIndexMap engagement: PublicationEngagementMaps } @@ -147,18 +151,22 @@ type LibraryIndexLoadJob = { forceRefresh: boolean promise: Promise onIndexesReadyListeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void> - lastProgressEvents: Event[] | null + lastProgressIndex: PublicationIndexMap | null } let indexLoadJob: LibraryIndexLoadJob | null = null +function indexEventsFromCache(cache: LibraryIndexCache): Event[] { + return publicationIndexMapValues(cache.indexByAddress) +} + function emitIndexesReadySnapshot( listeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>, - indexEvents: Event[] + indexByAddress: PublicationIndexMap ) { if (listeners.length === 0) return - const indexByAddress = buildIndexByAddress(indexEvents) - const topLevel = getTopLevelIndexEvents(indexEvents) + const indexEvents = publicationIndexMapValues(indexByAddress) + const topLevel = getTopLevelIndexEventsFromMap(indexByAddress) const snapshot: LibraryIndexLoadSnapshot = { engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()), allIndexCount: indexEvents.length, @@ -176,8 +184,8 @@ function registerIndexesReadyListener( ) { if (!listener) return job.onIndexesReadyListeners.push(listener) - if (job.lastProgressEvents) { - emitIndexesReadySnapshot([listener], job.lastProgressEvents) + if (job.lastProgressIndex) { + emitIndexesReadySnapshot([listener], job.lastProgressIndex) } } @@ -570,14 +578,14 @@ async function filterValidNewIndexEvents( } async function mergeValidIndexBatch( - existing: Event[], + existing: PublicationIndexMap, knownIds: Set, incoming: Event[] -): Promise { +): Promise { const newValid = await filterValidNewIndexEvents(incoming, knownIds) if (newValid.length === 0) return existing for (const event of newValid) knownIds.add(event.id) - return dedupeEventsById([...existing, ...newValid]) + return mergePublicationIndexMaps(existing, newValid) } export async function fetchLibraryIndexEvents( @@ -588,7 +596,8 @@ export async function fetchLibraryIndexEvents( if (indexRelays.length === 0) return [] const cached = await loadLibraryIndexCacheEvents() - let validMerged = dedupeEventsById(cached) + let indexMap = buildStructuralPublicationIndexMap(cached) + let validMerged = publicationIndexMapValues(indexMap) const knownValidIds = new Set(validMerged.map((event) => event.id)) const emitProgress = () => { options?.onProgress?.(validMerged) @@ -620,7 +629,8 @@ export async function fetchLibraryIndexEvents( const firstPageNetwork = dedupeEventsById( firstSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : [])) ) - validMerged = await mergeValidIndexBatch(validMerged, knownValidIds, firstPageNetwork) + indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, firstPageNetwork) + validMerged = publicationIndexMapValues(indexMap) void persistLibraryIndexCacheEvents(validMerged) emitProgress() @@ -668,7 +678,8 @@ export async function fetchLibraryIndexEvents( const deepNetwork = dedupeEventsById( deepSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : [])) ) - validMerged = await mergeValidIndexBatch(validMerged, knownValidIds, deepNetwork) + indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, deepNetwork) + validMerged = publicationIndexMapValues(indexMap) void persistLibraryIndexCacheEvents(validMerged) emitProgress() @@ -1265,8 +1276,9 @@ export function computeLibraryFeedRootOrder( const seen = new Set() const ordered: Event[] = [] for (const root of [...sortedEngaged, ...restRoots]) { - if (seen.has(root.id)) continue - seen.add(root.id) + const dedupeKey = eventTagAddress(root) ?? root.id + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) ordered.push(root) } return ordered @@ -1634,8 +1646,7 @@ export async function searchLibraryPublications( let indexEvents = context.indexEvents if (indexEvents.length === 0) { - const cachedIndex = await loadLibraryIndexCacheEvents() - indexEvents = filterValidIndexEvents(cachedIndex) + indexEvents = await loadLibraryIndexCacheEvents() } const engagement = context.engagement ?? EMPTY_ENGAGEMENT @@ -1894,7 +1905,9 @@ export async function searchLibraryPublicationsOnRelays( void persistLibraryIndexCacheEvents(valid) } - const mergedIndex = dedupeEventsById([...(context.indexEvents ?? []), ...valid]) + const mergedIndex = publicationIndexMapValues( + mergePublicationIndexMaps(buildStructuralPublicationIndexMap(context.indexEvents ?? []), valid) + ) const indexByAddress = buildIndexByAddress(mergedIndex) const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress) const engagement = context.engagement ?? EMPTY_ENGAGEMENT @@ -2024,7 +2037,7 @@ export async function loadLibraryPublicationIndex( if (import.meta.env.DEV) { logger.info('[Library] load joined in-flight', { relayCount: relayUrls.length, - hasProgress: indexLoadJob.lastProgressEvents != null + hasProgress: indexLoadJob.lastProgressIndex != null }) } return indexLoadJob.promise @@ -2034,7 +2047,7 @@ export async function loadLibraryPublicationIndex( relayKey, forceRefresh, onIndexesReadyListeners: [], - lastProgressEvents: null, + lastProgressIndex: null, promise: Promise.resolve(null as unknown as LibraryIndexLoadResult) } registerIndexesReadyListener(job, options?.onIndexesReady) @@ -2060,22 +2073,23 @@ async function runLibraryPublicationIndexLoad( logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key }) } - const emitIndexesReady = (indexEvents: Event[]) => { - job.lastProgressEvents = indexEvents - emitIndexesReadySnapshot(job.onIndexesReadyListeners, indexEvents) + const emitIndexesReady = (indexByAddress: PublicationIndexMap) => { + job.lastProgressIndex = indexByAddress + emitIndexesReadySnapshot(job.onIndexesReadyListeners, indexByAddress) } if (!options?.forceRefresh && sessionCache?.relayKey === key) { + const cachedIndexEvents = indexEventsFromCache(sessionCache) if (sessionCache.viewerPubkey !== viewerPubkey) { const targetAddresses = collectTargetAddressesFromIndexes( - sessionCache.indexEvents, + cachedIndexEvents, sessionCache.indexByAddress ) - const targetEventIds = collectPublicationIndexEventIds(sessionCache.indexEvents) + const targetEventIds = collectPublicationIndexEventIds(cachedIndexEvents) const engagementRelayUrls = await buildLibraryEngagementRelayUrls( viewerPubkey ?? undefined, relayUrls, - sessionCache.indexEvents + cachedIndexEvents ) sessionCache = { ...sessionCache, @@ -2090,7 +2104,7 @@ async function runLibraryPublicationIndexLoad( } const engaged = await buildEngagedFromCache( relayUrls, - sessionCache.indexEvents, + cachedIndexEvents, sessionCache.indexByAddress, sessionCache.engagement, viewerPubkey @@ -2098,39 +2112,43 @@ async function runLibraryPublicationIndexLoad( if (import.meta.env.DEV) { logger.info('[Library] load from cache', { engaged: engaged.length }) } - emitIndexesReady(sessionCache.indexEvents) + emitIndexesReady(sessionCache.indexByAddress) return { engaged, - allIndexCount: sessionCache.indexEvents.length, - topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length, - indexEvents: sessionCache.indexEvents, + allIndexCount: cachedIndexEvents.length, + topLevelCount: getTopLevelIndexEventsFromMap(sessionCache.indexByAddress).length, + indexEvents: cachedIndexEvents, engagement: sessionCache.engagement } } - const indexEvents = await fetchLibraryIndexEvents(relayUrls, { - onProgress: emitIndexesReady - }) + let indexByAddress = buildStructuralPublicationIndexMap( + await fetchLibraryIndexEvents(relayUrls, { + onProgress: (events) => emitIndexesReady(buildStructuralPublicationIndexMap(events)) + }) + ) + let indexEvents = publicationIndexMapValues(indexByAddress) if (import.meta.env.DEV) { logger.info('[Library] indexes fetched', { validCount: indexEvents.length }) } - const indexByAddress = buildIndexByAddress(indexEvents) - let topLevel = getTopLevelIndexEvents(indexEvents) + let topLevel = getTopLevelIndexEventsFromMap(indexByAddress) - emitIndexesReady(indexEvents) + emitIndexesReady(indexByAddress) const topLevelForHydrate = topLevel - await hydrateNestedIndexEvents(indexEvents, indexByAddress, relayUrls, { + await hydrateNestedIndexEvents(indexByAddress, relayUrls, { maxPasses: 1, maxMissingPerPass: HYDRATE_MISSING_CAP, scanRoots: topLevelForHydrate }) + indexEvents = publicationIndexMapValues(indexByAddress) + void persistLibraryIndexCacheEvents(indexEvents) if (import.meta.env.DEV) { logger.info('[Library] nested hydrate done', { indexCount: indexEvents.length }) } - topLevel = getTopLevelIndexEvents(indexEvents) + topLevel = getTopLevelIndexEventsFromMap(indexByAddress) const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) const targetEventIds = collectPublicationIndexEventIds(indexEvents) if (import.meta.env.DEV) { @@ -2165,7 +2183,7 @@ async function runLibraryPublicationIndexLoad( logger.info('[Library] engagement maps built', engagementMapsSizeSummary(engagement)) } - sessionCache = { relayKey: key, viewerPubkey, indexEvents, indexByAddress, engagement } + sessionCache = { relayKey: key, viewerPubkey, indexByAddress, engagement } const engaged = pickLibraryPublicationEntries(topLevel, indexByAddress, engagement) diff --git a/src/lib/publication-index.test.ts b/src/lib/publication-index.test.ts index 5c4cab6a..d2865431 100644 --- a/src/lib/publication-index.test.ts +++ b/src/lib/publication-index.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest' import { ExtendedKind } from '@/constants' import { buildIndexByAddress, + buildPublicationIndexMap, collectReachableAddresses, collectReachableAddressesCached, eventTagAddress, filterValidIndexEvents, - getTopLevelIndexEvents + getTopLevelIndexEvents, + pickNewerPublicationIndexEvent, + publicationIndexMapValues } from '@/lib/publication-index' import type { Event } from 'nostr-tools' import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' @@ -93,4 +96,52 @@ describe('publication-index', () => { const ev = contentEvent('section-a') expect(eventTagAddress(ev)).toBe(`30041:${PK}:section-a`) }) + + it('buildPublicationIndexMap keeps newest valid row per kind:pubkey:d', () => { + const older = indexEvent('same-book', [`30041:${PK}:intro`]) + older.created_at = 10 + const newer = finalizeEvent( + { + kind: ExtendedKind.PUBLICATION, + created_at: 20, + content: '', + tags: [ + ['d', 'same-book'], + ['title', 'Revised title'], + ['a', `30041:${PK}:intro`] + ] + }, + sk + ) + const invalid = { ...older, content: 'not empty' } + + const map = buildPublicationIndexMap([older, newer, invalid]) + expect(publicationIndexMapValues(map)).toHaveLength(1) + expect(map.get(`30040:${PK}:same-book`)?.id).toBe(newer.id) + expect(getTopLevelIndexEvents([older, newer, invalid])).toHaveLength(1) + expect(getTopLevelIndexEvents([older, newer, invalid])[0].id).toBe(newer.id) + }) + + it('pickNewerPublicationIndexEvent breaks created_at ties by event id', () => { + const first = finalizeEvent( + { + kind: ExtendedKind.PUBLICATION, + created_at: 50, + content: '', + tags: [['d', 'tie-book'], ['title', 'First'], ['a', `30041:${PK}:a`]] + }, + sk + ) + const second = finalizeEvent( + { + kind: ExtendedKind.PUBLICATION, + created_at: 50, + content: '', + tags: [['d', 'tie-book'], ['title', 'Second'], ['a', `30041:${PK}:b`]] + }, + sk + ) + const chosen = pickNewerPublicationIndexEvent(first, second) + expect(chosen.id).toBe(first.id > second.id ? first.id : second.id) + }) }) diff --git a/src/lib/publication-index.ts b/src/lib/publication-index.ts index e4928c33..868b4480 100644 --- a/src/lib/publication-index.ts +++ b/src/lib/publication-index.ts @@ -48,9 +48,82 @@ export function filterStructuralIndexEvents(events: Event[]): Event[] { /** Removes kind 30040 index events that don't comply with NKBIP-01 (includes signature check). */ export function filterValidIndexEvents(events: Event[]): Event[] { - return events.filter( - (event) => isStructuralPublicationIndex(event) && isVerifiedPublicationIndex(event) - ) + return events.filter(isValidPublicationIndexEvent) +} + +export function isValidPublicationIndexEvent(event: Event): boolean { + return isStructuralPublicationIndex(event) && isVerifiedPublicationIndex(event) +} + +/** Canonical library index: `kind:pubkey:d` → newest valid kind-30040 row. */ +export type PublicationIndexMap = Map + +export function pickNewerPublicationIndexEvent(prev: Event, next: Event): Event { + if (next.created_at > prev.created_at) return next + if (next.created_at < prev.created_at) return prev + return next.id > prev.id ? next : prev +} + +/** Upsert by address using NKBIP-01 shape checks only (no signature verify — safe for large IDB reads). */ +export function upsertStructuralPublicationIndexMap(map: PublicationIndexMap, event: Event): boolean { + if (!isStructuralPublicationIndex(event)) return false + const addr = eventTagAddress(event) + if (!addr) return false + const prev = map.get(addr) + if (!prev) { + map.set(addr, event) + return true + } + const chosen = pickNewerPublicationIndexEvent(prev, event) + if (chosen.id === prev.id) return false + map.set(addr, chosen) + return true +} + +/** Build address map from cached rows without verifyEvent (rows were verified on write). */ +export function buildStructuralPublicationIndexMap(events: Iterable): PublicationIndexMap { + const map: PublicationIndexMap = new Map() + for (const event of events) { + upsertStructuralPublicationIndexMap(map, event) + } + return map +} + +export function upsertPublicationIndexMap(map: PublicationIndexMap, event: Event): boolean { + if (!isValidPublicationIndexEvent(event)) return false + return upsertStructuralPublicationIndexMap(map, event) +} + +/** Build the canonical index map from any event list (invalid rows dropped; includes verify). */ +export function buildPublicationIndexMap(events: Iterable): PublicationIndexMap { + const map: PublicationIndexMap = new Map() + for (const event of events) { + upsertPublicationIndexMap(map, event) + } + return map +} + +/** Merge pre-validated network rows into a map (skips re-verifying signatures). */ +export function mergeValidatedPublicationIndexMaps( + base: PublicationIndexMap, + incoming: Iterable +): PublicationIndexMap { + const out: PublicationIndexMap = new Map(base) + for (const event of incoming) { + upsertStructuralPublicationIndexMap(out, event) + } + return out +} + +export function mergePublicationIndexMaps( + base: PublicationIndexMap, + incoming: Iterable +): PublicationIndexMap { + return mergeValidatedPublicationIndexMaps(base, incoming) +} + +export function publicationIndexMapValues(map: PublicationIndexMap): Event[] { + return [...map.values()] } export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] { @@ -99,22 +172,29 @@ export function getReferencedChild30040Addresses(events: Event[]): Set { } export function getTopLevelIndexEvents(events: Event[]): Event[] { - const referenced = getReferencedChild30040Addresses(events) - return events.filter((event) => { + const normalized = publicationIndexMapValues(buildStructuralPublicationIndexMap(events)) + const referenced = getReferencedChild30040Addresses(normalized) + return normalized.filter((event) => { const addr = eventTagAddress(event) return addr && !referenced.has(addr) }) } +export function getTopLevelIndexEventsFromMap(map: PublicationIndexMap): Event[] { + return getTopLevelIndexEvents(publicationIndexMapValues(map)) +} + export function buildIndexByAddress(events: Event[]): Map { const map = new Map() for (const event of events) { const addr = eventTagAddress(event) if (!addr) continue const prev = map.get(addr) - if (!prev || event.created_at > prev.created_at) { + if (!prev) { map.set(addr, event) + continue } + map.set(addr, pickNewerPublicationIndexEvent(prev, event)) } return map } @@ -159,8 +239,7 @@ export type HydrateNestedIndexOptions = { /** Batch-fetch nested kind 30040 indexes referenced by `a` tags but missing from cache. */ export async function hydrateNestedIndexEvents( - indexEvents: Event[], - indexByAddress: Map, + indexByAddress: PublicationIndexMap, relayUrls: string[], options?: HydrateNestedIndexOptions | number ): Promise { @@ -168,7 +247,7 @@ export async function hydrateNestedIndexEvents( typeof options === 'number' ? { maxPasses: options } : (options ?? {}) const maxPasses = opts.maxPasses ?? 2 const maxMissingPerPass = opts.maxMissingPerPass - const scanEvents = opts.scanRoots ?? indexEvents + const scanEvents = opts.scanRoots ?? publicationIndexMapValues(indexByAddress) for (let pass = 0; pass < maxPasses; pass++) { const missingRefs: PublicationSectionRef[] = [] @@ -188,11 +267,7 @@ export async function hydrateNestedIndexEvents( const fetched = await batchFetchPublicationSectionEvents(missingRefs, relayUrls) let added = 0 for (const ev of fetched.values()) { - const addr = eventTagAddress(ev) - if (!addr || indexByAddress.has(addr)) continue - indexByAddress.set(addr, ev) - indexEvents.push(ev) - added++ + if (upsertPublicationIndexMap(indexByAddress, ev)) added++ } if (added === 0) break } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 6cde7f79..507b6f8d 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -27,7 +27,13 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' -import { isVerifiedPublicationIndex } from '@/lib/publication-index' +import { + eventTagAddress, + isStructuralPublicationIndex, + isVerifiedPublicationIndex, + pickNewerPublicationIndexEvent, + type PublicationIndexMap +} from '@/lib/publication-index' import { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { @@ -3792,16 +3798,26 @@ class IndexedDbService { 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) + } + + if (rowsToWrite.length === 0) return 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 - } + let pending = rowsToWrite.length const finishOne = () => { pending -= 1 @@ -3811,12 +3827,16 @@ class IndexedDbService { } } - for (const ev of events) { - const get = store.get(ev.id) + 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: ev.id, + key, value: ev, addedAt: prev?.addedAt ?? now, lastAccessAt: now, @@ -3839,6 +3859,51 @@ class IndexedDbService { 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 + if ( + !ev || + ev.kind !== ExtendedKind.PUBLICATION || + !isStructuralPublicationIndex(ev) || + !addr || + rowKey !== addr || + !canon || + canon.id !== ev.id + ) { + toDelete.push(rowKey) + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + + for (const key of toDelete) { + await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key) + } + } + async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise { await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return