From 939eeae25afda5bb5dfa36aff3863f82f94ea717 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Jan 2024 20:22:02 -0600 Subject: [PATCH] Add Optimizer storage with EventSet --- src/storages/optimizer.ts | 93 ++++++++++++++++++++++++++++++ src/utils/event-set.test.ts | 109 ++++++++++++++++++++++++++++++++++++ src/utils/event-set.ts | 77 +++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 src/storages/optimizer.ts create mode 100644 src/utils/event-set.test.ts create mode 100644 src/utils/event-set.ts diff --git a/src/storages/optimizer.ts b/src/storages/optimizer.ts new file mode 100644 index 0000000..4b4ed9e --- /dev/null +++ b/src/storages/optimizer.ts @@ -0,0 +1,93 @@ +import { type DittoEvent, type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/store.ts'; +import { type DittoFilter, normalizeFilters } from '@/filter.ts'; +import { EventSet } from '@/utils/event-set.ts'; + +interface OptimizerOpts { + db: EventStore; + cache: EventStore; + client: EventStore; +} + +class Optimizer implements EventStore { + #db: EventStore; + #cache: EventStore; + #client: EventStore; + + constructor(opts: OptimizerOpts) { + this.#db = opts.db; + this.#cache = opts.cache; + this.#client = opts.client; + } + + async storeEvent(event: DittoEvent, opts?: StoreEventOpts | undefined): Promise { + await Promise.all([ + this.#db.storeEvent(event, opts), + this.#cache.storeEvent(event, opts), + ]); + } + + async getEvents( + filters: DittoFilter[], + opts: GetEventsOpts | undefined = {}, + ): Promise[]> { + const { limit = Infinity } = opts; + filters = normalizeFilters(filters); + + if (opts?.signal?.aborted) return Promise.resolve([]); + if (!filters.length) return Promise.resolve([]); + + const results = new EventSet>(); + + // Filters with IDs are immutable, so we can take them straight from the cache if we have them. + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + if (filter.ids) { + const ids = new Set(filter.ids); + for (const event of await this.#cache.getEvents([filter], opts)) { + ids.delete(event.id); + results.add(event); + if (results.size >= limit) return getResults(); + } + filters[i] = { ...filter, ids: [...ids] }; + } + } + + filters = normalizeFilters(filters); + if (!filters.length) return getResults(); + + // Query the database for events. + for (const dbEvent of await this.#db.getEvents(filters, opts)) { + results.add(dbEvent); + if (results.size >= limit) return getResults(); + } + + // Query the cache again. + for (const cacheEvent of await this.#cache.getEvents(filters, opts)) { + results.add(cacheEvent); + if (results.size >= limit) return getResults(); + } + + // Finally, query the client. + for (const clientEvent of await this.#client.getEvents(filters, opts)) { + results.add(clientEvent); + if (results.size >= limit) return getResults(); + } + + /** Get return type from map. */ + function getResults() { + return [...results.values()]; + } + + return getResults(); + } + + countEvents(_filters: DittoFilter[]): Promise { + throw new Error('COUNT not implemented.'); + } + + deleteEvents(_filters: DittoFilter[]): Promise { + throw new Error('DELETE not implemented.'); + } +} + +export { Optimizer }; diff --git a/src/utils/event-set.test.ts b/src/utils/event-set.test.ts new file mode 100644 index 0000000..b6e26b9 --- /dev/null +++ b/src/utils/event-set.test.ts @@ -0,0 +1,109 @@ +import { assertEquals } from '@/deps-test.ts'; + +import { EventSet } from './event-set.ts'; + +Deno.test('EventSet', () => { + const set = new EventSet(); + assertEquals(set.size, 0); + + const event = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] }; + set.add(event); + assertEquals(set.size, 1); + assertEquals(set.has(event), true); + + set.add(event); + assertEquals(set.size, 1); + assertEquals(set.has(event), true); + + set.delete(event); + assertEquals(set.size, 0); + assertEquals(set.has(event), false); + + set.delete(event); + assertEquals(set.size, 0); + assertEquals(set.has(event), false); + + set.add(event); + assertEquals(set.size, 1); + assertEquals(set.has(event), true); + + set.clear(); + assertEquals(set.size, 0); + assertEquals(set.has(event), false); +}); + +Deno.test('EventSet.add (replaceable)', () => { + const event0 = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] }; + const event1 = { id: '2', kind: 0, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [] }; + const event2 = { id: '3', kind: 0, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [] }; + + const set = new EventSet(); + set.add(event0); + assertEquals(set.size, 1); + assertEquals(set.has(event0), true); + + set.add(event1); + assertEquals(set.size, 1); + assertEquals(set.has(event0), false); + assertEquals(set.has(event1), true); + + set.add(event2); + assertEquals(set.size, 1); + assertEquals(set.has(event0), false); + assertEquals(set.has(event1), false); + assertEquals(set.has(event2), true); +}); + +Deno.test('EventSet.add (parameterized)', () => { + const event0 = { id: '1', kind: 30000, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [['d', 'a']] }; + const event1 = { id: '2', kind: 30000, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [['d', 'a']] }; + const event2 = { id: '3', kind: 30000, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [['d', 'a']] }; + + const set = new EventSet(); + set.add(event0); + assertEquals(set.size, 1); + assertEquals(set.has(event0), true); + + set.add(event1); + assertEquals(set.size, 1); + assertEquals(set.has(event0), false); + assertEquals(set.has(event1), true); + + set.add(event2); + assertEquals(set.size, 1); + assertEquals(set.has(event0), false); + assertEquals(set.has(event1), false); + assertEquals(set.has(event2), true); +}); + +Deno.test('EventSet.eventReplaces', () => { + const event0 = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] }; + const event1 = { id: '2', kind: 0, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [] }; + const event2 = { id: '3', kind: 0, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [] }; + const event3 = { id: '4', kind: 0, pubkey: 'def', content: '', created_at: 0, sig: '', tags: [] }; + + assertEquals(EventSet.eventReplaces(event1, event0), true); + assertEquals(EventSet.eventReplaces(event2, event0), true); + assertEquals(EventSet.eventReplaces(event2, event1), true); + + assertEquals(EventSet.eventReplaces(event0, event1), false); + assertEquals(EventSet.eventReplaces(event0, event2), false); + assertEquals(EventSet.eventReplaces(event1, event2), false); + + assertEquals(EventSet.eventReplaces(event3, event1), false); + assertEquals(EventSet.eventReplaces(event1, event3), false); +}); + +Deno.test('EventSet.eventReplaces (parameterized)', () => { + const event0 = { id: '1', kind: 30000, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [['d', 'a']] }; + const event1 = { id: '2', kind: 30000, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [['d', 'a']] }; + const event2 = { id: '3', kind: 30000, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [['d', 'a']] }; + + assertEquals(EventSet.eventReplaces(event1, event0), true); + assertEquals(EventSet.eventReplaces(event2, event0), true); + assertEquals(EventSet.eventReplaces(event2, event1), true); + + assertEquals(EventSet.eventReplaces(event0, event1), false); + assertEquals(EventSet.eventReplaces(event0, event2), false); + assertEquals(EventSet.eventReplaces(event1, event2), false); +}); diff --git a/src/utils/event-set.ts b/src/utils/event-set.ts new file mode 100644 index 0000000..92ac417 --- /dev/null +++ b/src/utils/event-set.ts @@ -0,0 +1,77 @@ +import { type Event } from '@/deps.ts'; +import { isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts'; + +/** In-memory store for Nostr events with replaceable event functionality. */ +class EventSet implements Set { + #map = new Map(); + + get size() { + return this.#map.size; + } + + add(event: E): this { + if (isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind)) { + for (const e of this.values()) { + if (EventSet.eventReplaces(event, e)) { + this.delete(e); + } + } + } + this.#map.set(event.id, event); + return this; + } + + clear(): void { + this.#map.clear(); + } + + delete(event: E): boolean { + return this.#map.delete(event.id); + } + + forEach(callbackfn: (event: E, key: E, set: Set) => void, thisArg?: any): void { + return this.#map.forEach((event, _id) => callbackfn(event, event, this), thisArg); + } + + has(event: E): boolean { + return this.#map.has(event.id); + } + + *entries(): IterableIterator<[E, E]> { + for (const event of this.#map.values()) { + yield [event, event]; + } + } + + keys(): IterableIterator { + return this.#map.values(); + } + + values(): IterableIterator { + return this.#map.values(); + } + + [Symbol.iterator](): IterableIterator { + return this.#map.values(); + } + + [Symbol.toStringTag]: string = 'EventSet'; + + /** Returns true if both events are replaceable, belong to the same pubkey (and `d` tag, for parameterized events), and the first event is newer than the second one. */ + static eventReplaces(event: Event, event2: Event): boolean { + if (isReplaceableKind(event.kind)) { + return event.kind === event2.kind && event.pubkey === event2.pubkey && event.created_at > event2.created_at; + } else if (isParameterizedReplaceableKind(event.kind)) { + const d = event.tags.find(([name]) => name === 'd')?.[1] || ''; + const d2 = event2.tags.find(([name]) => name === 'd')?.[1] || ''; + + return event.kind === event2.kind && + event.pubkey === event2.pubkey && + d === d2 && + event.created_at > event2.created_at; + } + return false; + } +} + +export { EventSet };