import { appendDedupe, SortedMap } from "@snort/shared" import { EventEmitter } from "eventemitter3" import { EventExt, EventType, type TaggedNostrEvent } from "." import { findTag } from "./utils" export const EmptySnapshot: NoteStoreSnapshotData = [] export type NoteStoreSnapshotData = Array export type NoteStoreHook = () => void export type NoteStoreHookRelease = () => void export type OnEventCallback = (e: Readonly>) => void export type OnEventCallbackRelease = () => void export type OnEoseCallback = (c: string) => void export type OnEoseCallbackRelease = () => void export interface NosteStoreEvents { event: (evs: Array) => void } /** * Generic note store interface */ export abstract class NoteStore extends EventEmitter { abstract add(ev: Readonly | Readonly>): void abstract clear(): void abstract get snapshot(): NoteStoreSnapshotData } export abstract class HookedNoteStore extends NoteStore { #storeSnapshot: NoteStoreSnapshotData | undefined #nextEmit?: ReturnType #bufEmit: Array = [] /** * Interval to emit changes in milli-seconds */ protected emitInterval = 300 /** * Snapshot is computed lazily: only materialised when first read after a change. * This avoids the O(n²) GC pressure of copying all events on every addition. */ get snapshot() { if (this.#storeSnapshot === undefined) { this.#storeSnapshot = this.takeSnapshot() ?? [] } return this.#storeSnapshot } abstract override add(ev: Readonly | Readonly>): void abstract override clear(): void protected abstract takeSnapshot(): NoteStoreSnapshotData | undefined protected onChange(changes: Array): void { // Invalidate the cached snapshot; it will be recomputed on next read. this.#storeSnapshot = undefined this.#bufEmit.push(...changes) if (!this.#nextEmit) { this.#nextEmit = setTimeout(() => { this.flushEmit() }, this.emitInterval) } } flushEmit() { // Always clear the timer handle so onChange() can schedule a new one this.#nextEmit = undefined if (this.#bufEmit.length > 0) { const cloned = [...this.#bufEmit] this.#bufEmit = [] this.emit("event", cloned) } } /** * Cancel any pending buffered emit and immediately notify listeners with an * empty array, signalling that the store has been cleared. */ protected onClear() { this.#bufEmit = [] if (this.#nextEmit) { clearTimeout(this.#nextEmit) this.#nextEmit = undefined } this.#storeSnapshot = undefined this.emit("event", []) } } /** * A note store that holds a single replaceable event for a given user defined key generator function */ export class KeyedReplaceableNoteStore extends HookedNoteStore { #keyFn: (ev: TaggedNostrEvent) => string #events: SortedMap = new SortedMap([], (a, b) => b[1].created_at - a[1].created_at) constructor(fn: (ev: TaggedNostrEvent) => string) { super() this.#keyFn = fn } add(ev: TaggedNostrEvent | Array) { ev = Array.isArray(ev) ? ev : [ev] const changes: Array = [] ev.forEach(a => { const keyOnEvent = this.#keyFn(a) const existing = this.#events.get(keyOnEvent) if (a.created_at > (existing?.created_at ?? 0)) { if (existing) { a.relays = appendDedupe(existing.relays, a.relays) } this.#events.set(keyOnEvent, a) changes.push(a) } }) if (changes.length > 0) { this.onChange(changes) } return changes.length } clear() { this.#events.clear() this.onClear() } takeSnapshot() { return [...this.#events.values()] } } /** * General use note store based on kind ranges */ export class NoteCollection extends KeyedReplaceableNoteStore { constructor() { super(e => { switch (EventExt.getType(e.kind)) { case EventType.Addressable: return `${e.kind}:${e.pubkey}:${findTag(e, "d")}` case EventType.Replaceable: return `${e.kind}:${e.pubkey}` default: return e.id } }) } }