Browse Source

dedup and validate publication entries

imwald
Silberengel 1 week ago
parent
commit
8da183185b
  1. 2
      package.json
  2. 27
      src/lib/library-index-idb-cache.ts
  3. 25
      src/lib/library-publication-index.test.ts
  4. 100
      src/lib/library-publication-index.ts
  5. 53
      src/lib/publication-index.test.ts
  6. 103
      src/lib/publication-index.ts
  7. 85
      src/services/indexed-db.service.ts

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

27
src/lib/library-index-idb-cache.ts

@ -4,21 +4,28 @@ import {
getLibraryIndexCacheBudget getLibraryIndexCacheBudget
} from '@/lib/library-index-cache-config' } from '@/lib/library-index-cache-config'
import logger from '@/lib/logger' 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 indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
export async function loadLibraryIndexCacheEvents(): Promise<Event[]> { export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
try { try {
const cached = await indexedDb.getLibraryPublicationIndexCacheEvents() 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 structural = filterStructuralIndexEvents(cached)
const map = buildStructuralPublicationIndexMap(structural)
const normalized = publicationIndexMapValues(map)
if (structural.length < cached.length) { if (structural.length < cached.length) {
void indexedDb void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {})
.pruneUnverifiedLibraryPublicationIndexCacheEvents() }
.catch(() => {}) if (normalized.length !== cached.length) {
void persistLibraryIndexCacheEvents(normalized).catch(() => {})
} }
return structural return normalized
} catch (e) { } catch (e) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] index IDB read failed', { logger.warn('[Library] index IDB read failed', {
@ -30,11 +37,13 @@ export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
} }
export async function persistLibraryIndexCacheEvents(events: Event[]): Promise<void> { export async function persistLibraryIndexCacheEvents(events: Event[]): Promise<void> {
const kind30040 = events.filter((ev) => ev.kind === ExtendedKind.PUBLICATION) const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events))
if (kind30040.length === 0) return const normalized = publicationIndexMapValues(map)
if (normalized.length === 0) return
try { try {
const budget = getLibraryIndexCacheBudget() const budget = getLibraryIndexCacheBudget()
await indexedDb.mergeLibraryPublicationIndexCacheEvents(kind30040, budget) await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget)
await indexedDb.reconcileLibraryPublicationIndexCache(map)
} catch (e) { } catch (e) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] index IDB write failed', { logger.warn('[Library] index IDB write failed', {

25
src/lib/library-publication-index.test.ts

@ -22,20 +22,25 @@ import {
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import { buildIndexByAddress } from '@/lib/publication-index' import { buildIndexByAddress } from '@/lib/publication-index'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { finalizeEvent, generateSecretKey, getPublicKey, kinds } from 'nostr-tools'
const PK = 'a'.repeat(64) const sk = generateSecretKey()
const PK = getPublicKey(sk)
function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { function indexEvent(
return { d: string,
id, aTags: string[],
opts?: { created_at?: number }
): Event {
return finalizeEvent(
{
kind: ExtendedKind.PUBLICATION, kind: ExtendedKind.PUBLICATION,
pubkey: PK, created_at: opts?.created_at ?? 100,
created_at: 100,
content: '', content: '',
tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])], tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])]
sig: 'c'.repeat(128) },
} sk
)
} }
describe('library-publication-index', () => { describe('library-publication-index', () => {

100
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 { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import { import {
buildIndexByAddress, buildIndexByAddress,
buildStructuralPublicationIndexMap,
collectPublicationIndexEventIds, collectPublicationIndexEventIds,
collectReachableAddressesCached, collectReachableAddressesCached,
eventTagAddress, eventTagAddress,
filterValidIndexEvents, filterValidIndexEvents,
getReferencedChild30040Addresses, getReferencedChild30040Addresses,
getTopLevelIndexEvents, getTopLevelIndexEvents,
hydrateNestedIndexEvents getTopLevelIndexEventsFromMap,
hydrateNestedIndexEvents,
mergePublicationIndexMaps,
publicationIndexMapValues,
type PublicationIndexMap
} from '@/lib/publication-index' } from '@/lib/publication-index'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
@ -124,8 +129,7 @@ export type LibraryPublicationEntry = {
type LibraryIndexCache = { type LibraryIndexCache = {
relayKey: string relayKey: string
viewerPubkey: string | null viewerPubkey: string | null
indexEvents: Event[] indexByAddress: PublicationIndexMap
indexByAddress: Map<string, Event>
engagement: PublicationEngagementMaps engagement: PublicationEngagementMaps
} }
@ -147,18 +151,22 @@ type LibraryIndexLoadJob = {
forceRefresh: boolean forceRefresh: boolean
promise: Promise<LibraryIndexLoadResult> promise: Promise<LibraryIndexLoadResult>
onIndexesReadyListeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void> onIndexesReadyListeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>
lastProgressEvents: Event[] | null lastProgressIndex: PublicationIndexMap | null
} }
let indexLoadJob: LibraryIndexLoadJob | null = null let indexLoadJob: LibraryIndexLoadJob | null = null
function indexEventsFromCache(cache: LibraryIndexCache): Event[] {
return publicationIndexMapValues(cache.indexByAddress)
}
function emitIndexesReadySnapshot( function emitIndexesReadySnapshot(
listeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>, listeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>,
indexEvents: Event[] indexByAddress: PublicationIndexMap
) { ) {
if (listeners.length === 0) return if (listeners.length === 0) return
const indexByAddress = buildIndexByAddress(indexEvents) const indexEvents = publicationIndexMapValues(indexByAddress)
const topLevel = getTopLevelIndexEvents(indexEvents) const topLevel = getTopLevelIndexEventsFromMap(indexByAddress)
const snapshot: LibraryIndexLoadSnapshot = { const snapshot: LibraryIndexLoadSnapshot = {
engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()), engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()),
allIndexCount: indexEvents.length, allIndexCount: indexEvents.length,
@ -176,8 +184,8 @@ function registerIndexesReadyListener(
) { ) {
if (!listener) return if (!listener) return
job.onIndexesReadyListeners.push(listener) job.onIndexesReadyListeners.push(listener)
if (job.lastProgressEvents) { if (job.lastProgressIndex) {
emitIndexesReadySnapshot([listener], job.lastProgressEvents) emitIndexesReadySnapshot([listener], job.lastProgressIndex)
} }
} }
@ -570,14 +578,14 @@ async function filterValidNewIndexEvents(
} }
async function mergeValidIndexBatch( async function mergeValidIndexBatch(
existing: Event[], existing: PublicationIndexMap,
knownIds: Set<string>, knownIds: Set<string>,
incoming: Event[] incoming: Event[]
): Promise<Event[]> { ): Promise<PublicationIndexMap> {
const newValid = await filterValidNewIndexEvents(incoming, knownIds) const newValid = await filterValidNewIndexEvents(incoming, knownIds)
if (newValid.length === 0) return existing if (newValid.length === 0) return existing
for (const event of newValid) knownIds.add(event.id) for (const event of newValid) knownIds.add(event.id)
return dedupeEventsById([...existing, ...newValid]) return mergePublicationIndexMaps(existing, newValid)
} }
export async function fetchLibraryIndexEvents( export async function fetchLibraryIndexEvents(
@ -588,7 +596,8 @@ export async function fetchLibraryIndexEvents(
if (indexRelays.length === 0) return [] if (indexRelays.length === 0) return []
const cached = await loadLibraryIndexCacheEvents() 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 knownValidIds = new Set(validMerged.map((event) => event.id))
const emitProgress = () => { const emitProgress = () => {
options?.onProgress?.(validMerged) options?.onProgress?.(validMerged)
@ -620,7 +629,8 @@ export async function fetchLibraryIndexEvents(
const firstPageNetwork = dedupeEventsById( const firstPageNetwork = dedupeEventsById(
firstSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : [])) 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) void persistLibraryIndexCacheEvents(validMerged)
emitProgress() emitProgress()
@ -668,7 +678,8 @@ export async function fetchLibraryIndexEvents(
const deepNetwork = dedupeEventsById( const deepNetwork = dedupeEventsById(
deepSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : [])) 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) void persistLibraryIndexCacheEvents(validMerged)
emitProgress() emitProgress()
@ -1265,8 +1276,9 @@ export function computeLibraryFeedRootOrder(
const seen = new Set<string>() const seen = new Set<string>()
const ordered: Event[] = [] const ordered: Event[] = []
for (const root of [...sortedEngaged, ...restRoots]) { for (const root of [...sortedEngaged, ...restRoots]) {
if (seen.has(root.id)) continue const dedupeKey = eventTagAddress(root) ?? root.id
seen.add(root.id) if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
ordered.push(root) ordered.push(root)
} }
return ordered return ordered
@ -1634,8 +1646,7 @@ export async function searchLibraryPublications(
let indexEvents = context.indexEvents let indexEvents = context.indexEvents
if (indexEvents.length === 0) { if (indexEvents.length === 0) {
const cachedIndex = await loadLibraryIndexCacheEvents() indexEvents = await loadLibraryIndexCacheEvents()
indexEvents = filterValidIndexEvents(cachedIndex)
} }
const engagement = context.engagement ?? EMPTY_ENGAGEMENT const engagement = context.engagement ?? EMPTY_ENGAGEMENT
@ -1894,7 +1905,9 @@ export async function searchLibraryPublicationsOnRelays(
void persistLibraryIndexCacheEvents(valid) void persistLibraryIndexCacheEvents(valid)
} }
const mergedIndex = dedupeEventsById([...(context.indexEvents ?? []), ...valid]) const mergedIndex = publicationIndexMapValues(
mergePublicationIndexMaps(buildStructuralPublicationIndexMap(context.indexEvents ?? []), valid)
)
const indexByAddress = buildIndexByAddress(mergedIndex) const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress) const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT const engagement = context.engagement ?? EMPTY_ENGAGEMENT
@ -2024,7 +2037,7 @@ export async function loadLibraryPublicationIndex(
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] load joined in-flight', { logger.info('[Library] load joined in-flight', {
relayCount: relayUrls.length, relayCount: relayUrls.length,
hasProgress: indexLoadJob.lastProgressEvents != null hasProgress: indexLoadJob.lastProgressIndex != null
}) })
} }
return indexLoadJob.promise return indexLoadJob.promise
@ -2034,7 +2047,7 @@ export async function loadLibraryPublicationIndex(
relayKey, relayKey,
forceRefresh, forceRefresh,
onIndexesReadyListeners: [], onIndexesReadyListeners: [],
lastProgressEvents: null, lastProgressIndex: null,
promise: Promise.resolve(null as unknown as LibraryIndexLoadResult) promise: Promise.resolve(null as unknown as LibraryIndexLoadResult)
} }
registerIndexesReadyListener(job, options?.onIndexesReady) registerIndexesReadyListener(job, options?.onIndexesReady)
@ -2060,22 +2073,23 @@ async function runLibraryPublicationIndexLoad(
logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key }) logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key })
} }
const emitIndexesReady = (indexEvents: Event[]) => { const emitIndexesReady = (indexByAddress: PublicationIndexMap) => {
job.lastProgressEvents = indexEvents job.lastProgressIndex = indexByAddress
emitIndexesReadySnapshot(job.onIndexesReadyListeners, indexEvents) emitIndexesReadySnapshot(job.onIndexesReadyListeners, indexByAddress)
} }
if (!options?.forceRefresh && sessionCache?.relayKey === key) { if (!options?.forceRefresh && sessionCache?.relayKey === key) {
const cachedIndexEvents = indexEventsFromCache(sessionCache)
if (sessionCache.viewerPubkey !== viewerPubkey) { if (sessionCache.viewerPubkey !== viewerPubkey) {
const targetAddresses = collectTargetAddressesFromIndexes( const targetAddresses = collectTargetAddressesFromIndexes(
sessionCache.indexEvents, cachedIndexEvents,
sessionCache.indexByAddress sessionCache.indexByAddress
) )
const targetEventIds = collectPublicationIndexEventIds(sessionCache.indexEvents) const targetEventIds = collectPublicationIndexEventIds(cachedIndexEvents)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls( const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined, viewerPubkey ?? undefined,
relayUrls, relayUrls,
sessionCache.indexEvents cachedIndexEvents
) )
sessionCache = { sessionCache = {
...sessionCache, ...sessionCache,
@ -2090,7 +2104,7 @@ async function runLibraryPublicationIndexLoad(
} }
const engaged = await buildEngagedFromCache( const engaged = await buildEngagedFromCache(
relayUrls, relayUrls,
sessionCache.indexEvents, cachedIndexEvents,
sessionCache.indexByAddress, sessionCache.indexByAddress,
sessionCache.engagement, sessionCache.engagement,
viewerPubkey viewerPubkey
@ -2098,39 +2112,43 @@ async function runLibraryPublicationIndexLoad(
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] load from cache', { engaged: engaged.length }) logger.info('[Library] load from cache', { engaged: engaged.length })
} }
emitIndexesReady(sessionCache.indexEvents) emitIndexesReady(sessionCache.indexByAddress)
return { return {
engaged, engaged,
allIndexCount: sessionCache.indexEvents.length, allIndexCount: cachedIndexEvents.length,
topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length, topLevelCount: getTopLevelIndexEventsFromMap(sessionCache.indexByAddress).length,
indexEvents: sessionCache.indexEvents, indexEvents: cachedIndexEvents,
engagement: sessionCache.engagement engagement: sessionCache.engagement
} }
} }
const indexEvents = await fetchLibraryIndexEvents(relayUrls, { let indexByAddress = buildStructuralPublicationIndexMap(
onProgress: emitIndexesReady await fetchLibraryIndexEvents(relayUrls, {
onProgress: (events) => emitIndexesReady(buildStructuralPublicationIndexMap(events))
}) })
)
let indexEvents = publicationIndexMapValues(indexByAddress)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] indexes fetched', { validCount: indexEvents.length }) logger.info('[Library] indexes fetched', { validCount: indexEvents.length })
} }
const indexByAddress = buildIndexByAddress(indexEvents) let topLevel = getTopLevelIndexEventsFromMap(indexByAddress)
let topLevel = getTopLevelIndexEvents(indexEvents)
emitIndexesReady(indexEvents) emitIndexesReady(indexByAddress)
const topLevelForHydrate = topLevel const topLevelForHydrate = topLevel
await hydrateNestedIndexEvents(indexEvents, indexByAddress, relayUrls, { await hydrateNestedIndexEvents(indexByAddress, relayUrls, {
maxPasses: 1, maxPasses: 1,
maxMissingPerPass: HYDRATE_MISSING_CAP, maxMissingPerPass: HYDRATE_MISSING_CAP,
scanRoots: topLevelForHydrate scanRoots: topLevelForHydrate
}) })
indexEvents = publicationIndexMapValues(indexByAddress)
void persistLibraryIndexCacheEvents(indexEvents)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] nested hydrate done', { indexCount: indexEvents.length }) logger.info('[Library] nested hydrate done', { indexCount: indexEvents.length })
} }
topLevel = getTopLevelIndexEvents(indexEvents) topLevel = getTopLevelIndexEventsFromMap(indexByAddress)
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents) const targetEventIds = collectPublicationIndexEventIds(indexEvents)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -2165,7 +2183,7 @@ async function runLibraryPublicationIndexLoad(
logger.info('[Library] engagement maps built', engagementMapsSizeSummary(engagement)) 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) const engaged = pickLibraryPublicationEntries(topLevel, indexByAddress, engagement)

53
src/lib/publication-index.test.ts

@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
buildIndexByAddress, buildIndexByAddress,
buildPublicationIndexMap,
collectReachableAddresses, collectReachableAddresses,
collectReachableAddressesCached, collectReachableAddressesCached,
eventTagAddress, eventTagAddress,
filterValidIndexEvents, filterValidIndexEvents,
getTopLevelIndexEvents getTopLevelIndexEvents,
pickNewerPublicationIndexEvent,
publicationIndexMapValues
} from '@/lib/publication-index' } from '@/lib/publication-index'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
@ -93,4 +96,52 @@ describe('publication-index', () => {
const ev = contentEvent('section-a') const ev = contentEvent('section-a')
expect(eventTagAddress(ev)).toBe(`30041:${PK}: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)
})
}) })

103
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). */ /** Removes kind 30040 index events that don't comply with NKBIP-01 (includes signature check). */
export function filterValidIndexEvents(events: Event[]): Event[] { export function filterValidIndexEvents(events: Event[]): Event[] {
return events.filter( return events.filter(isValidPublicationIndexEvent)
(event) => isStructuralPublicationIndex(event) && isVerifiedPublicationIndex(event) }
)
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<string, Event>
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<Event>): 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<Event>): 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<Event>
): PublicationIndexMap {
const out: PublicationIndexMap = new Map(base)
for (const event of incoming) {
upsertStructuralPublicationIndexMap(out, event)
}
return out
}
export function mergePublicationIndexMaps(
base: PublicationIndexMap,
incoming: Iterable<Event>
): PublicationIndexMap {
return mergeValidatedPublicationIndexMaps(base, incoming)
}
export function publicationIndexMapValues(map: PublicationIndexMap): Event[] {
return [...map.values()]
} }
export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] { export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] {
@ -99,22 +172,29 @@ export function getReferencedChild30040Addresses(events: Event[]): Set<string> {
} }
export function getTopLevelIndexEvents(events: Event[]): Event[] { export function getTopLevelIndexEvents(events: Event[]): Event[] {
const referenced = getReferencedChild30040Addresses(events) const normalized = publicationIndexMapValues(buildStructuralPublicationIndexMap(events))
return events.filter((event) => { const referenced = getReferencedChild30040Addresses(normalized)
return normalized.filter((event) => {
const addr = eventTagAddress(event) const addr = eventTagAddress(event)
return addr && !referenced.has(addr) return addr && !referenced.has(addr)
}) })
} }
export function getTopLevelIndexEventsFromMap(map: PublicationIndexMap): Event[] {
return getTopLevelIndexEvents(publicationIndexMapValues(map))
}
export function buildIndexByAddress(events: Event[]): Map<string, Event> { export function buildIndexByAddress(events: Event[]): Map<string, Event> {
const map = new Map<string, Event>() const map = new Map<string, Event>()
for (const event of events) { for (const event of events) {
const addr = eventTagAddress(event) const addr = eventTagAddress(event)
if (!addr) continue if (!addr) continue
const prev = map.get(addr) const prev = map.get(addr)
if (!prev || event.created_at > prev.created_at) { if (!prev) {
map.set(addr, event) map.set(addr, event)
continue
} }
map.set(addr, pickNewerPublicationIndexEvent(prev, event))
} }
return map return map
} }
@ -159,8 +239,7 @@ export type HydrateNestedIndexOptions = {
/** Batch-fetch nested kind 30040 indexes referenced by `a` tags but missing from cache. */ /** Batch-fetch nested kind 30040 indexes referenced by `a` tags but missing from cache. */
export async function hydrateNestedIndexEvents( export async function hydrateNestedIndexEvents(
indexEvents: Event[], indexByAddress: PublicationIndexMap,
indexByAddress: Map<string, Event>,
relayUrls: string[], relayUrls: string[],
options?: HydrateNestedIndexOptions | number options?: HydrateNestedIndexOptions | number
): Promise<void> { ): Promise<void> {
@ -168,7 +247,7 @@ export async function hydrateNestedIndexEvents(
typeof options === 'number' ? { maxPasses: options } : (options ?? {}) typeof options === 'number' ? { maxPasses: options } : (options ?? {})
const maxPasses = opts.maxPasses ?? 2 const maxPasses = opts.maxPasses ?? 2
const maxMissingPerPass = opts.maxMissingPerPass const maxMissingPerPass = opts.maxMissingPerPass
const scanEvents = opts.scanRoots ?? indexEvents const scanEvents = opts.scanRoots ?? publicationIndexMapValues(indexByAddress)
for (let pass = 0; pass < maxPasses; pass++) { for (let pass = 0; pass < maxPasses; pass++) {
const missingRefs: PublicationSectionRef[] = [] const missingRefs: PublicationSectionRef[] = []
@ -188,11 +267,7 @@ export async function hydrateNestedIndexEvents(
const fetched = await batchFetchPublicationSectionEvents(missingRefs, relayUrls) const fetched = await batchFetchPublicationSectionEvents(missingRefs, relayUrls)
let added = 0 let added = 0
for (const ev of fetched.values()) { for (const ev of fetched.values()) {
const addr = eventTagAddress(ev) if (upsertPublicationIndexMap(indexByAddress, ev)) added++
if (!addr || indexByAddress.has(addr)) continue
indexByAddress.set(addr, ev)
indexEvents.push(ev)
added++
} }
if (added === 0) break if (added === 0) break
} }

85
src/services/indexed-db.service.ts

@ -27,7 +27,13 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' 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 { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import { import {
@ -3792,16 +3798,26 @@ class IndexedDbService {
const now = Date.now() const now = Date.now()
const storeName = StoreNames.LIBRARY_PUBLICATION_INDEX 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<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(storeName, 'readwrite') const tx = this.db!.transaction(storeName, 'readwrite')
const store = tx.objectStore(storeName) const store = tx.objectStore(storeName)
let pending = events.length let pending = rowsToWrite.length
if (pending === 0) {
tx.commit()
resolve()
return
}
const finishOne = () => { const finishOne = () => {
pending -= 1 pending -= 1
@ -3811,12 +3827,16 @@ class IndexedDbService {
} }
} }
for (const ev of events) { for (const { key, event: ev } of rowsToWrite) {
const get = store.get(ev.id) const get = store.get(key)
get.onsuccess = () => { get.onsuccess = () => {
const prev = get.result as TLibraryPublicationIndexCacheRow | undefined const prev = get.result as TLibraryPublicationIndexCacheRow | undefined
if (prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id) {
finishOne()
return
}
const row: TLibraryPublicationIndexCacheRow = { const row: TLibraryPublicationIndexCacheRow = {
key: ev.id, key,
value: ev, value: ev,
addedAt: prev?.addedAt ?? now, addedAt: prev?.addedAt ?? now,
lastAccessAt: now, lastAccessAt: now,
@ -3839,6 +3859,51 @@ class IndexedDbService {
await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes) 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<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
const toDelete: string[] = []
await new Promise<void>((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<void> { async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise<void> {
await this.initPromise await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return

Loading…
Cancel
Save