@hashtree/nostr 0.1.14 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +106 -176
  2. package/dist/eventCollection.d.ts +1 -1
  3. package/dist/eventCollection.d.ts.map +1 -1
  4. package/dist/eventCollection.js +2 -7
  5. package/dist/eventCollection.js.map +1 -1
  6. package/dist/eventKeys.d.ts +1 -0
  7. package/dist/eventKeys.d.ts.map +1 -1
  8. package/dist/eventKeys.js +7 -1
  9. package/dist/eventKeys.js.map +1 -1
  10. package/dist/events.d.ts +4 -0
  11. package/dist/events.d.ts.map +1 -1
  12. package/dist/events.js +76 -70
  13. package/dist/events.js.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +10 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/resolver/nostr.d.ts.map +1 -1
  19. package/dist/resolver/nostr.js +37 -25
  20. package/dist/resolver/nostr.js.map +1 -1
  21. package/dist/snapshot.d.ts +5 -0
  22. package/dist/snapshot.d.ts.map +1 -1
  23. package/dist/snapshot.js +11 -3
  24. package/dist/snapshot.js.map +1 -1
  25. package/dist/treeEventSnapshotPermalinks.js +2 -2
  26. package/dist/treeEventSnapshotPermalinks.js.map +1 -1
  27. package/dist/treeEventSnapshots.d.ts.map +1 -1
  28. package/dist/treeEventSnapshots.js +11 -12
  29. package/dist/treeEventSnapshots.js.map +1 -1
  30. package/package.json +18 -14
  31. package/src/eventCollection.ts +189 -0
  32. package/src/eventKeys.ts +155 -0
  33. package/src/eventValidation.ts +71 -0
  34. package/src/events.ts +929 -0
  35. package/src/index.ts +122 -0
  36. package/src/replaceablePublish.ts +204 -0
  37. package/src/resolver/index.ts +14 -0
  38. package/src/resolver/nostr.ts +1212 -0
  39. package/src/snapshot.ts +214 -0
  40. package/src/treeEventSnapshotPermalinks.ts +150 -0
  41. package/src/treeEventSnapshots.ts +283 -0
  42. package/dist/webrtc/index.d.ts +0 -6
  43. package/dist/webrtc/index.d.ts.map +0 -1
  44. package/dist/webrtc/index.js +0 -7
  45. package/dist/webrtc/index.js.map +0 -1
  46. package/dist/webrtc/lruCache.d.ts +0 -20
  47. package/dist/webrtc/lruCache.d.ts.map +0 -1
  48. package/dist/webrtc/lruCache.js +0 -59
  49. package/dist/webrtc/lruCache.js.map +0 -1
  50. package/dist/webrtc/peer.d.ts +0 -133
  51. package/dist/webrtc/peer.d.ts.map +0 -1
  52. package/dist/webrtc/peer.js +0 -617
  53. package/dist/webrtc/peer.js.map +0 -1
  54. package/dist/webrtc/peerSelector.d.ts +0 -2
  55. package/dist/webrtc/peerSelector.d.ts.map +0 -1
  56. package/dist/webrtc/peerSelector.js +0 -2
  57. package/dist/webrtc/peerSelector.js.map +0 -1
  58. package/dist/webrtc/protocol.d.ts +0 -3
  59. package/dist/webrtc/protocol.d.ts.map +0 -1
  60. package/dist/webrtc/protocol.js +0 -2
  61. package/dist/webrtc/protocol.js.map +0 -1
  62. package/dist/webrtc/store.d.ts +0 -217
  63. package/dist/webrtc/store.d.ts.map +0 -1
  64. package/dist/webrtc/store.js +0 -1320
  65. package/dist/webrtc/store.js.map +0 -1
  66. package/dist/webrtc/types.d.ts +0 -101
  67. package/dist/webrtc/types.d.ts.map +0 -1
  68. package/dist/webrtc/types.js +0 -5
  69. package/dist/webrtc/types.js.map +0 -1
package/src/events.ts ADDED
@@ -0,0 +1,929 @@
1
+ import { decode, encode } from '@msgpack/msgpack';
2
+ import {
3
+ CollectionSource,
4
+ serializeCollectionManifestMetadata,
5
+ type CollectionManifest,
6
+ } from '@hashtree/collection';
7
+ import { HashTree, LinkType, type CID, type Store, toHex, sha256 } from '@hashtree/core';
8
+ import {
9
+ COLLECTION_MANIFEST_METADATA_FILE,
10
+ collectionManifestToNostrEventManifest,
11
+ createNostrEventCollectionWriter,
12
+ DEFAULT_NOSTR_EVENT_COLLECTION_SOURCE_ID,
13
+ nostrEventCollectionManifestMetadata,
14
+ nostrEventManifestToCollectionManifest,
15
+ } from './eventCollection.js';
16
+ import {
17
+ compareEvents,
18
+ compareReplaceableEvents,
19
+ createdAtFromIndexKey,
20
+ getDTag,
21
+ isParameterizedReplaceableKind,
22
+ isReplaceableKind,
23
+ MANIFEST_BY_AUTHOR_KIND_TIME,
24
+ MANIFEST_BY_AUTHOR_TIME,
25
+ MANIFEST_BY_ID,
26
+ MANIFEST_BY_KIND_TIME,
27
+ MANIFEST_BY_KIND_TIME_AUTHOR,
28
+ MANIFEST_BY_TAG,
29
+ MANIFEST_BY_TIME,
30
+ MANIFEST_PARAMETERIZED_REPLACEABLE,
31
+ MANIFEST_REPLACEABLE,
32
+ normalizeTagName,
33
+ normalizeTagValue,
34
+ padKind,
35
+ parameterizedReplaceableKey,
36
+ replaceableKey,
37
+ retainLatestReplaceableEvents,
38
+ tagPrefix,
39
+ } from './eventKeys.js';
40
+ import { assertStringArray, validateEventShape, validateHex64, validateKind } from './eventValidation.js';
41
+
42
+ export const NOSTR_EVENT_ENVELOPE_VERSION = 1;
43
+
44
+ export interface StoredNostrEvent {
45
+ id: string;
46
+ pubkey: string;
47
+ created_at: number;
48
+ kind: number;
49
+ tags: string[][];
50
+ content: string;
51
+ sig: string;
52
+ }
53
+
54
+ export interface NostrEventManifest {
55
+ byId: CID | null;
56
+ byAuthorTime: CID | null;
57
+ byAuthorKindTime: CID | null;
58
+ byKindTime: CID | null;
59
+ byKindTimeAuthor: CID | null;
60
+ byTime: CID | null;
61
+ byTag: CID | null;
62
+ replaceable: CID | null;
63
+ parameterizedReplaceable: CID | null;
64
+ }
65
+
66
+ export interface ListEventsOptions {
67
+ limit?: number;
68
+ since?: number;
69
+ until?: number;
70
+ /** Throw when a selected event blob is missing or unreadable instead of skipping it. */
71
+ strict?: boolean;
72
+ }
73
+
74
+ export type NostrEventQueryValue<T> = T | readonly T[];
75
+
76
+ export interface NostrEventQuery {
77
+ ids?: NostrEventQueryValue<string>;
78
+ authors?: NostrEventQueryValue<string>;
79
+ kinds?: NostrEventQueryValue<number>;
80
+ tags?: Record<string, NostrEventQueryValue<string>>;
81
+ }
82
+
83
+ interface NormalizedNostrEventQuery {
84
+ ids: string[];
85
+ authors: string[];
86
+ kinds: number[];
87
+ tags: Map<string, string[]>;
88
+ }
89
+
90
+ interface NostrEventQueryPlan {
91
+ type: 'ids' | 'index';
92
+ indexName?: string;
93
+ prefixes: string[];
94
+ }
95
+
96
+ const EMPTY_NOSTR_EVENT_QUERY: NormalizedNostrEventQuery = {
97
+ ids: [],
98
+ authors: [],
99
+ kinds: [],
100
+ tags: new Map(),
101
+ };
102
+
103
+ function normalizeQueryValues<T>(value: NostrEventQueryValue<T> | undefined): T[] {
104
+ if (value === undefined) {
105
+ return [];
106
+ }
107
+
108
+ if (Array.isArray(value)) {
109
+ return [...value];
110
+ }
111
+
112
+ const scalarValue = value as T;
113
+ return [scalarValue];
114
+ }
115
+
116
+ function dedupeValues<T>(values: T[]): T[] {
117
+ return [...new Set(values)];
118
+ }
119
+
120
+ function normalizeNostrEventQuery(query: NostrEventQuery = {}): NormalizedNostrEventQuery {
121
+ const ids = dedupeValues(normalizeQueryValues(query.ids).map((value) => validateHex64(value, 'event id')));
122
+ const authors = dedupeValues(normalizeQueryValues(query.authors).map((value) => validateHex64(value, 'pubkey')));
123
+ const kinds = dedupeValues(normalizeQueryValues(query.kinds).map((value) => validateKind(value)));
124
+ const tags = new Map<string, string[]>();
125
+
126
+ for (const [rawTagName, rawTagValues] of Object.entries(query.tags ?? {})) {
127
+ const tagName = normalizeTagName(rawTagName);
128
+ const tagValues = dedupeValues(
129
+ normalizeQueryValues(rawTagValues).map((value) => normalizeTagValue(tagName, value)),
130
+ );
131
+ if (tagValues.length > 0) {
132
+ tags.set(tagName, tagValues);
133
+ }
134
+ }
135
+
136
+ return { ids, authors, kinds, tags };
137
+ }
138
+
139
+ function matchesNostrEventQuery(event: StoredNostrEvent, query: NormalizedNostrEventQuery): boolean {
140
+ if (query.ids.length > 0 && !query.ids.includes(event.id)) {
141
+ return false;
142
+ }
143
+ if (query.authors.length > 0 && !query.authors.includes(event.pubkey)) {
144
+ return false;
145
+ }
146
+ if (query.kinds.length > 0 && !query.kinds.includes(event.kind)) {
147
+ return false;
148
+ }
149
+
150
+ for (const [tagName, tagValues] of query.tags) {
151
+ const matchesTag = event.tags.some(([candidateTagName, candidateTagValue]) => {
152
+ if (typeof candidateTagName !== 'string' || typeof candidateTagValue !== 'string') {
153
+ return false;
154
+ }
155
+
156
+ const normalizedCandidateName = candidateTagName.toLowerCase();
157
+ if (normalizedCandidateName !== tagName) {
158
+ return false;
159
+ }
160
+
161
+ return tagValues.includes(normalizeTagValue(tagName, candidateTagValue));
162
+ });
163
+
164
+ if (!matchesTag) {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ return true;
170
+ }
171
+
172
+ function selectNostrEventQueryPlan(
173
+ query: NormalizedNostrEventQuery,
174
+ manifest: NostrEventManifest | null,
175
+ bounded: boolean,
176
+ ): NostrEventQueryPlan {
177
+ if (query.ids.length > 0) {
178
+ return { type: 'ids', prefixes: [] };
179
+ }
180
+
181
+ if (query.tags.size === 1 && manifest?.byTag) {
182
+ const firstTag = query.tags.entries().next().value;
183
+ if (firstTag) {
184
+ const [tagName, tagValues] = firstTag;
185
+ return {
186
+ type: 'index',
187
+ indexName: MANIFEST_BY_TAG,
188
+ prefixes: tagValues.map((tagValue) => tagPrefix(tagName, tagValue)),
189
+ };
190
+ }
191
+ }
192
+
193
+ // A bounded page across many authors should stop at the global page limit.
194
+ // The author-bearing index can reject unrelated events before reading blobs.
195
+ if (bounded && manifest?.byKindTimeAuthor && query.authors.length >= 12 && query.kinds.length > 0) {
196
+ return {
197
+ type: 'index',
198
+ indexName: MANIFEST_BY_KIND_TIME_AUTHOR,
199
+ prefixes: query.kinds.map((kind) => `${padKind(kind)}:`),
200
+ };
201
+ }
202
+
203
+ if (query.authors.length > 0) {
204
+ if (query.kinds.length === 1) {
205
+ const kindPrefix = `${padKind(query.kinds[0]!)}:`;
206
+ return {
207
+ type: 'index',
208
+ indexName: MANIFEST_BY_AUTHOR_KIND_TIME,
209
+ prefixes: query.authors.map((author) => `${author}:${kindPrefix}`),
210
+ };
211
+ }
212
+
213
+ return {
214
+ type: 'index',
215
+ indexName: MANIFEST_BY_AUTHOR_TIME,
216
+ prefixes: query.authors.map((author) => `${author}:`),
217
+ };
218
+ }
219
+
220
+ if (query.kinds.length > 0) {
221
+ return {
222
+ type: 'index',
223
+ indexName: MANIFEST_BY_KIND_TIME,
224
+ prefixes: query.kinds.map((kind) => `${padKind(kind)}:`),
225
+ };
226
+ }
227
+
228
+ return {
229
+ type: 'index',
230
+ indexName: MANIFEST_BY_TIME,
231
+ prefixes: [''],
232
+ };
233
+ }
234
+
235
+ function mergeQueriedEvents(eventGroups: StoredNostrEvent[][], limit?: number): StoredNostrEvent[] {
236
+ const merged = new Map<string, StoredNostrEvent>();
237
+
238
+ for (const group of eventGroups) {
239
+ for (const event of group) {
240
+ const current = merged.get(event.id);
241
+ if (!current || compareEvents(event, current) > 0) {
242
+ merged.set(event.id, event);
243
+ }
244
+ }
245
+ }
246
+
247
+ const ordered = [...merged.values()].sort((left, right) => compareEvents(right, left));
248
+ return limit === undefined ? ordered : ordered.slice(0, limit);
249
+ }
250
+
251
+ function canonicalEventIdPayload(event: Omit<StoredNostrEvent, 'id' | 'sig'>): string {
252
+ return JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
253
+ }
254
+
255
+ async function computeCanonicalEventId(event: Omit<StoredNostrEvent, 'sig'>): Promise<string> {
256
+ const payload = canonicalEventIdPayload(event);
257
+ return toHex(await sha256(new TextEncoder().encode(payload)));
258
+ }
259
+
260
+ export function encodeStoredNostrEventMsgpack(event: StoredNostrEvent): Uint8Array {
261
+ const normalized = validateEventShape(event);
262
+ return encode([
263
+ NOSTR_EVENT_ENVELOPE_VERSION,
264
+ normalized.id,
265
+ normalized.pubkey,
266
+ normalized.created_at,
267
+ normalized.kind,
268
+ normalized.tags,
269
+ normalized.content,
270
+ normalized.sig,
271
+ ]);
272
+ }
273
+
274
+ const eventTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
275
+
276
+ function decodeEventText(value: unknown): string {
277
+ if (!(value instanceof Uint8Array)) {
278
+ throw new Error('Invalid Nostr event envelope string');
279
+ }
280
+ return eventTextDecoder.decode(value);
281
+ }
282
+
283
+ export function decodeStoredNostrEventMsgpack(data: Uint8Array): StoredNostrEvent {
284
+ const bytes = new Uint8Array(data);
285
+ const decoded = decode(bytes);
286
+ if (!Array.isArray(decoded) || decoded.length !== 8) {
287
+ throw new Error('Invalid Nostr event envelope');
288
+ }
289
+
290
+ const [
291
+ version,
292
+ id,
293
+ pubkey,
294
+ createdAt,
295
+ kind,
296
+ tags,
297
+ content,
298
+ sig,
299
+ ] = decoded;
300
+
301
+ if (version !== NOSTR_EVENT_ENVELOPE_VERSION) {
302
+ throw new Error(`Unsupported Nostr event envelope version: ${String(version)}`);
303
+ }
304
+ if (typeof id !== 'string' || typeof pubkey !== 'string' || typeof content !== 'string' || typeof sig !== 'string') {
305
+ throw new Error('Invalid Nostr event envelope fields');
306
+ }
307
+ if (typeof createdAt !== 'number' || !Number.isInteger(createdAt) || createdAt < 0) {
308
+ throw new Error('Invalid Nostr event created_at');
309
+ }
310
+ if (typeof kind !== 'number' || !Number.isInteger(kind) || kind < 0) {
311
+ throw new Error('Invalid Nostr event kind');
312
+ }
313
+
314
+ assertStringArray(tags);
315
+
316
+ // MessagePack's default UTF-8 fast path strips leading U+FEFF. Decode the
317
+ // original string bytes only after checking their types: rawStrings alone
318
+ // cannot distinguish a MessagePack string from a binary value.
319
+ const raw = decode(bytes, { rawStrings: true }) as unknown[];
320
+ const rawTags = raw[5] as unknown[][];
321
+
322
+ return validateEventShape({
323
+ id: decodeEventText(raw[1]),
324
+ pubkey: decodeEventText(raw[2]),
325
+ created_at: createdAt,
326
+ kind,
327
+ tags: rawTags.map(tag => tag.map(decodeEventText)),
328
+ content: decodeEventText(raw[6]),
329
+ sig: decodeEventText(raw[7]),
330
+ });
331
+ }
332
+
333
+ export class NostrEventStore {
334
+ private readonly store: Store;
335
+ private readonly tree: HashTree;
336
+
337
+ constructor(store: Store) {
338
+ this.store = store;
339
+ this.tree = new HashTree({ store });
340
+ }
341
+
342
+ encodeEvent(event: StoredNostrEvent): Uint8Array {
343
+ return encodeStoredNostrEventMsgpack(this.validateEventShape(event));
344
+ }
345
+
346
+ decodeEvent(data: Uint8Array): StoredNostrEvent {
347
+ return this.validateEventShape(decodeStoredNostrEventMsgpack(data));
348
+ }
349
+
350
+ async add(root: CID | null, event: StoredNostrEvent): Promise<CID> {
351
+ const normalized = await this.validateEvent(event);
352
+ const manifest = await this.getManifest(root);
353
+ // The validated event id commits to immutable event contents. Replayed
354
+ // relay history and outbox retries must not replace derived collection keys.
355
+ if (root && await this.collectionSourceFromManifest(manifest).get(normalized.id)) {
356
+ return root;
357
+ }
358
+ const decision = await this.resolveReplaceableDecision(manifest, normalized);
359
+ if (!decision.accept) {
360
+ if (!root) {
361
+ throw new Error('Rejecting replaceable event without an existing manifest root');
362
+ }
363
+ return root;
364
+ }
365
+
366
+ const eventBytes = this.encodeEvent(normalized);
367
+ const { cid: eventCid } = await this.tree.putFile(eventBytes);
368
+ const writer = this.collectionWriterFromManifest(manifest);
369
+ if (decision.replaced?.event) {
370
+ await writer.replace(normalized, eventCid, decision.replaced.event);
371
+ } else {
372
+ await writer.put(normalized, eventCid);
373
+ }
374
+
375
+ const nextManifest = collectionManifestToNostrEventManifest(writer.manifest());
376
+ const manifestRoot = await this.writeManifest(nextManifest);
377
+ if (!manifestRoot) {
378
+ throw new Error('Failed to create Nostr event manifest');
379
+ }
380
+
381
+ // Replaced event bytes can still be referenced by another index or snapshot.
382
+ // Reclaim unreferenced blocks at the store's root-aware eviction boundary.
383
+
384
+ return manifestRoot;
385
+ }
386
+
387
+ async build(root: CID | null, events: StoredNostrEvent[]): Promise<CID | null> {
388
+ const normalized = retainLatestReplaceableEvents(
389
+ await Promise.all(events.map((event) => this.validateEvent(event))),
390
+ );
391
+ normalized.sort(compareEvents);
392
+
393
+ if (normalized.length === 0) {
394
+ return root;
395
+ }
396
+
397
+ if (root) {
398
+ let current = root;
399
+ for (const event of normalized) {
400
+ current = await this.add(current, event);
401
+ }
402
+ return current;
403
+ }
404
+
405
+ const writer = this.collectionWriterFromManifest(this.emptyManifest());
406
+ await writer.rebuild(await Promise.all(normalized.map(async (event) => {
407
+ const { cid } = await this.tree.putFile(this.encodeEvent(event));
408
+ return { item: event, cid };
409
+ })));
410
+
411
+ return await this.writeManifest(collectionManifestToNostrEventManifest(writer.manifest()));
412
+ }
413
+
414
+ async getById(root: CID | null, eventId: string): Promise<StoredNostrEvent | null> {
415
+ const source = this.collectionSourceFromManifest(await this.getManifest(root));
416
+ const eventCid = await source.get(validateHex64(eventId, 'event id'));
417
+ if (!eventCid) {
418
+ return null;
419
+ }
420
+
421
+ return this.readStoredEvent(eventCid);
422
+ }
423
+
424
+ /** Remove an event from every index without deleting blocks used by older roots. */
425
+ async delete(root: CID | null, eventId: string): Promise<CID | null> {
426
+ const event = await this.getById(root, eventId);
427
+ if (!event) return root;
428
+ const writer = this.collectionWriterFromManifest(await this.getManifest(root));
429
+ await writer.delete(event);
430
+ return this.writeManifest(collectionManifestToNostrEventManifest(writer.manifest()));
431
+ }
432
+
433
+ async listByAuthor(root: CID | null, pubkey: string, options: ListEventsOptions = {}): Promise<StoredNostrEvent[]> {
434
+ return this.collectEvents(
435
+ this.collectionSourceFromManifest(await this.getManifest(root)),
436
+ MANIFEST_BY_AUTHOR_TIME,
437
+ `${validateHex64(pubkey, 'pubkey')}:`,
438
+ options,
439
+ );
440
+ }
441
+
442
+ async listByAuthorAndKind(
443
+ root: CID | null,
444
+ pubkey: string,
445
+ kind: number,
446
+ options: ListEventsOptions = {}
447
+ ): Promise<StoredNostrEvent[]> {
448
+ return this.collectEvents(
449
+ this.collectionSourceFromManifest(await this.getManifest(root)),
450
+ MANIFEST_BY_AUTHOR_KIND_TIME,
451
+ `${validateHex64(pubkey, 'pubkey')}:${validateKind(kind).toString(16).padStart(8, '0')}:`,
452
+ options,
453
+ );
454
+ }
455
+
456
+ async *streamByAuthor(
457
+ root: CID | null,
458
+ pubkey: string,
459
+ options: ListEventsOptions = {},
460
+ ): AsyncGenerator<StoredNostrEvent> {
461
+ yield* this.streamEvents(
462
+ this.collectionSourceFromManifest(await this.getManifest(root)),
463
+ MANIFEST_BY_AUTHOR_TIME,
464
+ `${validateHex64(pubkey, 'pubkey')}:`,
465
+ options,
466
+ );
467
+ }
468
+
469
+ async *streamByAuthorAndKind(
470
+ root: CID | null,
471
+ pubkey: string,
472
+ kind: number,
473
+ options: ListEventsOptions = {},
474
+ ): AsyncGenerator<StoredNostrEvent> {
475
+ yield* this.streamEvents(
476
+ this.collectionSourceFromManifest(await this.getManifest(root)),
477
+ MANIFEST_BY_AUTHOR_KIND_TIME,
478
+ `${validateHex64(pubkey, 'pubkey')}:${validateKind(kind).toString(16).padStart(8, '0')}:`,
479
+ options,
480
+ );
481
+ }
482
+
483
+ async query(
484
+ root: CID | null,
485
+ query: NostrEventQuery = {},
486
+ options: ListEventsOptions = {},
487
+ ): Promise<StoredNostrEvent[]> {
488
+ const normalizedQuery = normalizeNostrEventQuery(query);
489
+ const manifest = normalizedQuery.ids.length ? null : await this.getManifest(root);
490
+ const plan = selectNostrEventQueryPlan(normalizedQuery, manifest, options.limit !== undefined);
491
+
492
+ if (plan.type === 'ids') {
493
+ return this.queryByIds(root, normalizedQuery, options);
494
+ }
495
+
496
+ const source = this.collectionSourceFromManifest(manifest!);
497
+ const groups = await Promise.all(
498
+ plan.prefixes.map((prefix) => this.collectEvents(
499
+ source,
500
+ plan.indexName!,
501
+ prefix,
502
+ options,
503
+ normalizedQuery,
504
+ )),
505
+ );
506
+
507
+ return mergeQueriedEvents(groups, options.limit);
508
+ }
509
+
510
+ async *streamQuery(
511
+ root: CID | null,
512
+ query: NostrEventQuery = {},
513
+ options: ListEventsOptions = {},
514
+ ): AsyncGenerator<StoredNostrEvent> {
515
+ const normalizedQuery = normalizeNostrEventQuery(query);
516
+ const manifest = normalizedQuery.ids.length ? null : await this.getManifest(root);
517
+ const plan = selectNostrEventQueryPlan(normalizedQuery, manifest, options.limit !== undefined);
518
+
519
+ if (plan.type === 'ids' || plan.prefixes.length !== 1) {
520
+ for (const event of await this.query(root, query, options)) {
521
+ yield event;
522
+ }
523
+ return;
524
+ }
525
+
526
+ yield* this.streamEvents(
527
+ this.collectionSourceFromManifest(manifest!),
528
+ plan.indexName!,
529
+ plan.prefixes[0] ?? '',
530
+ options,
531
+ normalizedQuery,
532
+ );
533
+ }
534
+
535
+ async getReplaceable(root: CID | null, pubkey: string, kind: number): Promise<StoredNostrEvent | null> {
536
+ const source = this.collectionSourceFromManifest(await this.getManifest(root));
537
+ const eventCid = await source.getIndexLink(
538
+ MANIFEST_REPLACEABLE,
539
+ replaceableKey(validateHex64(pubkey, 'pubkey'), validateKind(kind)),
540
+ );
541
+
542
+ return eventCid ? this.readStoredEvent(eventCid) : null;
543
+ }
544
+
545
+ async listRecent(root: CID | null, options: ListEventsOptions = {}): Promise<StoredNostrEvent[]> {
546
+ return this.collectEvents(
547
+ this.collectionSourceFromManifest(await this.getManifest(root)),
548
+ MANIFEST_BY_TIME,
549
+ '',
550
+ options,
551
+ );
552
+ }
553
+
554
+ async *streamRecent(
555
+ root: CID | null,
556
+ options: ListEventsOptions = {},
557
+ ): AsyncGenerator<StoredNostrEvent> {
558
+ yield* this.streamEvents(
559
+ this.collectionSourceFromManifest(await this.getManifest(root)),
560
+ MANIFEST_BY_TIME,
561
+ '',
562
+ options,
563
+ );
564
+ }
565
+
566
+ async listByTag(
567
+ root: CID | null,
568
+ tagName: string,
569
+ tagValue: string,
570
+ options: ListEventsOptions = {}
571
+ ): Promise<StoredNostrEvent[]> {
572
+ return this.collectEvents(
573
+ this.collectionSourceFromManifest(await this.getManifest(root)),
574
+ MANIFEST_BY_TAG,
575
+ tagPrefix(tagName, tagValue),
576
+ options,
577
+ );
578
+ }
579
+
580
+ async *streamByTag(
581
+ root: CID | null,
582
+ tagName: string,
583
+ tagValue: string,
584
+ options: ListEventsOptions = {},
585
+ ): AsyncGenerator<StoredNostrEvent> {
586
+ yield* this.streamEvents(
587
+ this.collectionSourceFromManifest(await this.getManifest(root)),
588
+ MANIFEST_BY_TAG,
589
+ tagPrefix(tagName, tagValue),
590
+ options,
591
+ );
592
+ }
593
+
594
+ async getParameterizedReplaceable(
595
+ root: CID | null,
596
+ pubkey: string,
597
+ kind: number,
598
+ dTag: string
599
+ ): Promise<StoredNostrEvent | null> {
600
+ const source = this.collectionSourceFromManifest(await this.getManifest(root));
601
+ const eventCid = await source.getIndexLink(
602
+ MANIFEST_PARAMETERIZED_REPLACEABLE,
603
+ parameterizedReplaceableKey(
604
+ validateHex64(pubkey, 'pubkey'),
605
+ validateKind(kind),
606
+ dTag,
607
+ ),
608
+ );
609
+
610
+ return eventCid ? this.readStoredEvent(eventCid) : null;
611
+ }
612
+
613
+ async getManifest(root: CID | null): Promise<NostrEventManifest> {
614
+ if (!root) {
615
+ return {
616
+ byId: null,
617
+ byAuthorTime: null,
618
+ byAuthorKindTime: null,
619
+ byKindTime: null,
620
+ byKindTimeAuthor: null,
621
+ byTime: null,
622
+ byTag: null,
623
+ replaceable: null,
624
+ parameterizedReplaceable: null,
625
+ };
626
+ }
627
+
628
+ const entries = await this.tree.listDirectory(root);
629
+ const getCid = (name: string): CID | null => entries.find(entry => entry.name === name)?.cid ?? null;
630
+
631
+ return {
632
+ byId: getCid(MANIFEST_BY_ID),
633
+ byAuthorTime: getCid(MANIFEST_BY_AUTHOR_TIME),
634
+ byAuthorKindTime: getCid(MANIFEST_BY_AUTHOR_KIND_TIME),
635
+ byKindTime: getCid(MANIFEST_BY_KIND_TIME),
636
+ byKindTimeAuthor: getCid(MANIFEST_BY_KIND_TIME_AUTHOR),
637
+ byTime: getCid(MANIFEST_BY_TIME),
638
+ byTag: getCid(MANIFEST_BY_TAG),
639
+ replaceable: getCid(MANIFEST_REPLACEABLE),
640
+ parameterizedReplaceable: getCid(MANIFEST_PARAMETERIZED_REPLACEABLE),
641
+ };
642
+ }
643
+
644
+ async getCollectionManifest(
645
+ root: CID | null,
646
+ sourceId: string = DEFAULT_NOSTR_EVENT_COLLECTION_SOURCE_ID,
647
+ ): Promise<CollectionManifest> {
648
+ return nostrEventManifestToCollectionManifest(await this.getManifest(root), sourceId);
649
+ }
650
+
651
+ async getCollectionSource(
652
+ root: CID | null,
653
+ sourceId: string = DEFAULT_NOSTR_EVENT_COLLECTION_SOURCE_ID,
654
+ ): Promise<CollectionSource> {
655
+ return new CollectionSource(this.store, await this.getCollectionManifest(root, sourceId));
656
+ }
657
+
658
+ async listByKind(
659
+ root: CID | null,
660
+ kind: number,
661
+ options: ListEventsOptions = {}
662
+ ): Promise<StoredNostrEvent[]> {
663
+ return this.collectEvents(
664
+ this.collectionSourceFromManifest(await this.getManifest(root)),
665
+ MANIFEST_BY_KIND_TIME,
666
+ `${validateKind(kind).toString(16).padStart(8, '0')}:`,
667
+ options,
668
+ );
669
+ }
670
+
671
+ async *streamByKind(
672
+ root: CID | null,
673
+ kind: number,
674
+ options: ListEventsOptions = {},
675
+ ): AsyncGenerator<StoredNostrEvent> {
676
+ yield* this.streamEvents(
677
+ this.collectionSourceFromManifest(await this.getManifest(root)),
678
+ MANIFEST_BY_KIND_TIME,
679
+ `${validateKind(kind).toString(16).padStart(8, '0')}:`,
680
+ options,
681
+ );
682
+ }
683
+
684
+ private async collectEvents(
685
+ source: CollectionSource,
686
+ indexName: string,
687
+ prefix: string,
688
+ options: ListEventsOptions = {},
689
+ query: NormalizedNostrEventQuery = EMPTY_NOSTR_EVENT_QUERY,
690
+ ): Promise<StoredNostrEvent[]> {
691
+ const events: StoredNostrEvent[] = [];
692
+ for await (const event of this.streamEvents(source, indexName, prefix, options, query)) {
693
+ events.push(event);
694
+ }
695
+ return events;
696
+ }
697
+
698
+ private async *streamEvents(
699
+ source: CollectionSource,
700
+ indexName: string,
701
+ prefix: string,
702
+ options: ListEventsOptions = {},
703
+ query: NormalizedNostrEventQuery = EMPTY_NOSTR_EVENT_QUERY,
704
+ ): AsyncGenerator<StoredNostrEvent> {
705
+ const iterator = indexName === MANIFEST_BY_ID
706
+ ? source.streamQueryById({ prefix })
707
+ : source.streamQueryIndex(indexName, { prefix });
708
+ let emitted = 0;
709
+
710
+ for await (const { key, cid: eventCid } of iterator) {
711
+ const parts = indexName === MANIFEST_BY_KIND_TIME_AUTHOR ? key.split(':') : null;
712
+ const createdAt = createdAtFromIndexKey(parts ? `${parts[1]}:${parts[3]}` : key);
713
+ if (options.until !== undefined && createdAt > options.until) {
714
+ continue;
715
+ }
716
+ if (options.since !== undefined && createdAt < options.since) {
717
+ break;
718
+ }
719
+
720
+ if (parts && query.authors.length > 0 && !query.authors.includes(parts[2]!)) {
721
+ continue;
722
+ }
723
+ const event = await this.tryReadStoredEvent(eventCid, options.strict);
724
+ if (!event) {
725
+ continue;
726
+ }
727
+ if (!matchesNostrEventQuery(event, query)) {
728
+ continue;
729
+ }
730
+
731
+ yield event;
732
+ emitted += 1;
733
+ if (options.limit !== undefined && emitted >= options.limit) {
734
+ break;
735
+ }
736
+ }
737
+ }
738
+
739
+ private async queryByIds(
740
+ root: CID | null,
741
+ query: NormalizedNostrEventQuery,
742
+ options: ListEventsOptions = {},
743
+ ): Promise<StoredNostrEvent[]> {
744
+ const events: StoredNostrEvent[] = [];
745
+ for (const eventId of query.ids) {
746
+ const event = await this.getById(root, eventId);
747
+ if (!event) {
748
+ continue;
749
+ }
750
+ if (options.until !== undefined && event.created_at > options.until) {
751
+ continue;
752
+ }
753
+ if (options.since !== undefined && event.created_at < options.since) {
754
+ continue;
755
+ }
756
+ if (!matchesNostrEventQuery(event, query)) {
757
+ continue;
758
+ }
759
+ events.push(event);
760
+ }
761
+
762
+ events.sort((left, right) => compareEvents(right, left));
763
+ return options.limit === undefined ? events : events.slice(0, options.limit);
764
+ }
765
+
766
+ private async readStoredEvent(eventCid: CID): Promise<StoredNostrEvent> {
767
+ const data = await this.tree.readFile(eventCid);
768
+ if (!data) {
769
+ throw new Error('Stored Nostr event blob is missing');
770
+ }
771
+
772
+ return this.decodeEvent(data);
773
+ }
774
+
775
+ private async tryReadStoredEvent(eventCid: CID, strict = false): Promise<StoredNostrEvent | null> {
776
+ try {
777
+ return await this.readStoredEvent(eventCid);
778
+ } catch (error) {
779
+ if (strict) {
780
+ throw error;
781
+ }
782
+ if (error instanceof Error && error.message === 'Stored Nostr event blob is missing') {
783
+ return null;
784
+ }
785
+ console.warn('Skipping unreadable stored Nostr event', error);
786
+ return null;
787
+ }
788
+ }
789
+
790
+ private emptyManifest(): NostrEventManifest {
791
+ return {
792
+ byId: null,
793
+ byAuthorTime: null,
794
+ byAuthorKindTime: null,
795
+ byKindTime: null,
796
+ byKindTimeAuthor: null,
797
+ byTime: null,
798
+ byTag: null,
799
+ replaceable: null,
800
+ parameterizedReplaceable: null,
801
+ };
802
+ }
803
+
804
+ private collectionSourceFromManifest(manifest: NostrEventManifest): CollectionSource {
805
+ return new CollectionSource(
806
+ this.store,
807
+ nostrEventManifestToCollectionManifest(manifest),
808
+ );
809
+ }
810
+
811
+ private collectionWriterFromManifest(manifest: NostrEventManifest) {
812
+ return createNostrEventCollectionWriter(this.store, manifest);
813
+ }
814
+
815
+ private async resolveReplaceableDecision(
816
+ manifest: NostrEventManifest,
817
+ event: StoredNostrEvent,
818
+ ): Promise<{ accept: boolean; replaced?: { event: StoredNostrEvent; cid: CID } }> {
819
+ const slot = isReplaceableKind(event.kind)
820
+ ? {
821
+ indexName: MANIFEST_REPLACEABLE,
822
+ key: replaceableKey(event.pubkey, event.kind),
823
+ }
824
+ : isParameterizedReplaceableKind(event.kind)
825
+ ? {
826
+ indexName: MANIFEST_PARAMETERIZED_REPLACEABLE,
827
+ key: parameterizedReplaceableKey(event.pubkey, event.kind, getDTag(event) ?? ''),
828
+ }
829
+ : null;
830
+
831
+ if (!slot) {
832
+ return { accept: true };
833
+ }
834
+
835
+ const source = this.collectionSourceFromManifest(manifest);
836
+ const existingCid = await source.getIndexLink(slot.indexName, slot.key);
837
+ if (!existingCid) {
838
+ return { accept: true };
839
+ }
840
+
841
+ try {
842
+ const existingEvent = await this.readStoredEvent(existingCid);
843
+ if (compareReplaceableEvents(event, existingEvent) > 0) {
844
+ return {
845
+ accept: true,
846
+ replaced: {
847
+ event: existingEvent,
848
+ cid: existingCid,
849
+ },
850
+ };
851
+ }
852
+ return { accept: false };
853
+ } catch (error) {
854
+ if (error instanceof Error && error.message === 'Stored Nostr event blob is missing') {
855
+ return { accept: true };
856
+ }
857
+ throw error;
858
+ }
859
+ }
860
+
861
+ private async writeManifest(manifest: NostrEventManifest): Promise<CID | null> {
862
+ const entries = [];
863
+ const rootMetadata = nostrEventCollectionManifestMetadata(manifest);
864
+
865
+ if (manifest.byId) {
866
+ entries.push({ name: MANIFEST_BY_ID, cid: manifest.byId, size: 0, type: LinkType.Dir });
867
+ }
868
+ if (manifest.byAuthorTime) {
869
+ entries.push({ name: MANIFEST_BY_AUTHOR_TIME, cid: manifest.byAuthorTime, size: 0, type: LinkType.Dir });
870
+ }
871
+ if (manifest.byAuthorKindTime) {
872
+ entries.push({ name: MANIFEST_BY_AUTHOR_KIND_TIME, cid: manifest.byAuthorKindTime, size: 0, type: LinkType.Dir });
873
+ }
874
+ if (manifest.byKindTime) {
875
+ entries.push({ name: MANIFEST_BY_KIND_TIME, cid: manifest.byKindTime, size: 0, type: LinkType.Dir });
876
+ }
877
+ if (manifest.byKindTimeAuthor) {
878
+ entries.push({ name: MANIFEST_BY_KIND_TIME_AUTHOR, cid: manifest.byKindTimeAuthor, size: 0, type: LinkType.Dir });
879
+ }
880
+ if (manifest.byTime) {
881
+ entries.push({ name: MANIFEST_BY_TIME, cid: manifest.byTime, size: 0, type: LinkType.Dir });
882
+ }
883
+ if (manifest.byTag) {
884
+ entries.push({ name: MANIFEST_BY_TAG, cid: manifest.byTag, size: 0, type: LinkType.Dir });
885
+ }
886
+ if (manifest.replaceable) {
887
+ entries.push({ name: MANIFEST_REPLACEABLE, cid: manifest.replaceable, size: 0, type: LinkType.Dir });
888
+ }
889
+ if (manifest.parameterizedReplaceable) {
890
+ entries.push({
891
+ name: MANIFEST_PARAMETERIZED_REPLACEABLE,
892
+ cid: manifest.parameterizedReplaceable,
893
+ size: 0,
894
+ type: LinkType.Dir,
895
+ });
896
+ }
897
+ if (rootMetadata) {
898
+ const metadataBytes = serializeCollectionManifestMetadata(rootMetadata);
899
+ const { cid, size } = await this.tree.putFile(metadataBytes);
900
+ entries.push({
901
+ name: COLLECTION_MANIFEST_METADATA_FILE,
902
+ cid,
903
+ size,
904
+ type: LinkType.File,
905
+ });
906
+ }
907
+
908
+ if (entries.length === 0) {
909
+ return null;
910
+ }
911
+
912
+ const { cid } = await this.tree.putDirectory(entries);
913
+ return cid;
914
+ }
915
+
916
+ private validateEventShape(event: StoredNostrEvent): StoredNostrEvent {
917
+ return validateEventShape(event);
918
+ }
919
+
920
+ private async validateEvent(event: StoredNostrEvent): Promise<StoredNostrEvent> {
921
+ const normalized = this.validateEventShape(event);
922
+ const computedId = await computeCanonicalEventId(normalized);
923
+ if (computedId !== normalized.id) {
924
+ throw new Error(`Event id mismatch: expected ${computedId}, got ${normalized.id}`);
925
+ }
926
+
927
+ return normalized;
928
+ }
929
+ }