holosphere 2.0.0-alpha2 → 2.0.0-alpha5

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 (98) hide show
  1. package/dist/2019-D2OG2idw.js +6680 -0
  2. package/dist/2019-D2OG2idw.js.map +1 -0
  3. package/dist/2019-EION3wKo.cjs +8 -0
  4. package/dist/2019-EION3wKo.cjs.map +1 -0
  5. package/dist/_commonjsHelpers-C37NGDzP.cjs +2 -0
  6. package/dist/_commonjsHelpers-C37NGDzP.cjs.map +1 -0
  7. package/dist/_commonjsHelpers-CUmg6egw.js +7 -0
  8. package/dist/_commonjsHelpers-CUmg6egw.js.map +1 -0
  9. package/dist/browser-BSniCNqO.js +3058 -0
  10. package/dist/browser-BSniCNqO.js.map +1 -0
  11. package/dist/browser-Cq59Ij19.cjs +2 -0
  12. package/dist/browser-Cq59Ij19.cjs.map +1 -0
  13. package/dist/cjs/holosphere.cjs +1 -1
  14. package/dist/esm/holosphere.js +50 -53
  15. package/dist/index-BG8FStkt.cjs +12 -0
  16. package/dist/index-BG8FStkt.cjs.map +1 -0
  17. package/dist/index-Bbey4GkP.js +37869 -0
  18. package/dist/index-Bbey4GkP.js.map +1 -0
  19. package/dist/index-Cp3xctq8.js +15104 -0
  20. package/dist/index-Cp3xctq8.js.map +1 -0
  21. package/dist/index-hfVGRwSr.cjs +5 -0
  22. package/dist/index-hfVGRwSr.cjs.map +1 -0
  23. package/dist/indexeddb-storage-BD70pN7q.cjs +2 -0
  24. package/dist/indexeddb-storage-BD70pN7q.cjs.map +1 -0
  25. package/dist/{indexeddb-storage-CMW4qRQS.js → indexeddb-storage-Bjg84U5R.js} +49 -13
  26. package/dist/indexeddb-storage-Bjg84U5R.js.map +1 -0
  27. package/dist/{memory-storage-DQzcAZlf.js → memory-storage-CD0XFayE.js} +6 -2
  28. package/dist/memory-storage-CD0XFayE.js.map +1 -0
  29. package/dist/{memory-storage-DmePEP2q.cjs → memory-storage-DmMyJtOo.cjs} +2 -2
  30. package/dist/memory-storage-DmMyJtOo.cjs.map +1 -0
  31. package/dist/{secp256k1-vOXp40Fx.js → secp256k1-69sS9O-P.js} +2 -393
  32. package/dist/secp256k1-69sS9O-P.js.map +1 -0
  33. package/dist/secp256k1-TcN6vWGh.cjs +12 -0
  34. package/dist/secp256k1-TcN6vWGh.cjs.map +1 -0
  35. package/docs/CONTRACTS.md +797 -0
  36. package/examples/demo.html +47 -0
  37. package/package.json +10 -5
  38. package/src/contracts/abis/Appreciative.json +1280 -0
  39. package/src/contracts/abis/AppreciativeFactory.json +101 -0
  40. package/src/contracts/abis/Bundle.json +1435 -0
  41. package/src/contracts/abis/BundleFactory.json +106 -0
  42. package/src/contracts/abis/Holon.json +881 -0
  43. package/src/contracts/abis/Holons.json +330 -0
  44. package/src/contracts/abis/Managed.json +1262 -0
  45. package/src/contracts/abis/ManagedFactory.json +149 -0
  46. package/src/contracts/abis/Membrane.json +261 -0
  47. package/src/contracts/abis/Splitter.json +1624 -0
  48. package/src/contracts/abis/SplitterFactory.json +220 -0
  49. package/src/contracts/abis/TestToken.json +321 -0
  50. package/src/contracts/abis/Zoned.json +1461 -0
  51. package/src/contracts/abis/ZonedFactory.json +154 -0
  52. package/src/contracts/chain-manager.js +375 -0
  53. package/src/contracts/deployer.js +443 -0
  54. package/src/contracts/event-listener.js +507 -0
  55. package/src/contracts/holon-contracts.js +344 -0
  56. package/src/contracts/index.js +83 -0
  57. package/src/contracts/networks.js +224 -0
  58. package/src/contracts/operations.js +670 -0
  59. package/src/contracts/queries.js +589 -0
  60. package/src/core/holosphere.js +453 -1
  61. package/src/crypto/nostr-utils.js +263 -0
  62. package/src/federation/handshake.js +455 -0
  63. package/src/federation/hologram.js +1 -1
  64. package/src/hierarchical/upcast.js +6 -5
  65. package/src/index.js +463 -1939
  66. package/src/lib/ai-methods.js +308 -0
  67. package/src/lib/contract-methods.js +293 -0
  68. package/src/lib/errors.js +23 -0
  69. package/src/lib/federation-methods.js +238 -0
  70. package/src/lib/index.js +26 -0
  71. package/src/spatial/h3-operations.js +2 -2
  72. package/src/storage/backends/gundb-backend.js +377 -46
  73. package/src/storage/global-tables.js +28 -1
  74. package/src/storage/gun-auth.js +303 -0
  75. package/src/storage/gun-federation.js +776 -0
  76. package/src/storage/gun-references.js +198 -0
  77. package/src/storage/gun-schema.js +291 -0
  78. package/src/storage/gun-wrapper.js +347 -31
  79. package/src/storage/indexeddb-storage.js +49 -11
  80. package/src/storage/memory-storage.js +5 -0
  81. package/src/storage/nostr-async.js +194 -37
  82. package/src/storage/nostr-client.js +580 -51
  83. package/src/storage/persistent-storage.js +6 -1
  84. package/src/storage/unified-storage.js +119 -0
  85. package/src/subscriptions/manager.js +1 -1
  86. package/types/index.d.ts +133 -0
  87. package/dist/index-CDfIuXew.js +0 -15974
  88. package/dist/index-CDfIuXew.js.map +0 -1
  89. package/dist/index-ifOgtDvd.cjs +0 -3
  90. package/dist/index-ifOgtDvd.cjs.map +0 -1
  91. package/dist/indexeddb-storage-CMW4qRQS.js.map +0 -1
  92. package/dist/indexeddb-storage-DLZOgetM.cjs +0 -2
  93. package/dist/indexeddb-storage-DLZOgetM.cjs.map +0 -1
  94. package/dist/memory-storage-DQzcAZlf.js.map +0 -1
  95. package/dist/memory-storage-DmePEP2q.cjs.map +0 -1
  96. package/dist/secp256k1-CP0ZkpAx.cjs +0 -13
  97. package/dist/secp256k1-CP0ZkpAx.cjs.map +0 -1
  98. package/dist/secp256k1-vOXp40Fx.js.map +0 -1
@@ -7,6 +7,116 @@ import { SimplePool, finalizeEvent, getPublicKey } from 'nostr-tools';
7
7
  import { OutboxQueue } from './outbox-queue.js';
8
8
  import { SyncService } from './sync-service.js';
9
9
 
10
+ // Global pool singleton - reuse connections across NostrClient instances
11
+ let globalPool = null;
12
+ let globalPoolRelays = new Set();
13
+
14
+ /**
15
+ * Get or create global SimplePool singleton
16
+ * This ensures WebSocket connections are reused across all operations
17
+ */
18
+ function getGlobalPool(config = {}) {
19
+ if (!globalPool) {
20
+ globalPool = new SimplePool({
21
+ enableReconnect: config.enableReconnect !== false,
22
+ enablePing: config.enablePing !== false,
23
+ });
24
+ }
25
+ return globalPool;
26
+ }
27
+
28
+ // Global pending queries map for deduplication
29
+ // Key: JSON-stringified filter, Value: { promise, timestamp }
30
+ const pendingQueries = new Map();
31
+ const PENDING_QUERY_TIMEOUT = 5000; // 5 seconds
32
+
33
+ // Global active subscriptions map for subscription deduplication
34
+ // Key: JSON-stringified filter, Value: { subscription, callbacks: Set, refCount }
35
+ const activeSubscriptions = new Map();
36
+
37
+ // Throttle background refreshes to avoid flooding the relay
38
+ // Key: path, Value: timestamp of last refresh
39
+ const backgroundRefreshThrottle = new Map();
40
+ const BACKGROUND_REFRESH_INTERVAL = 30000; // Only refresh same path every 30 seconds
41
+
42
+ // Write debouncing for rapid updates to the same path
43
+ // Key: d-tag path, Value: { event, timer, resolve, reject }
44
+ const pendingWrites = new Map();
45
+ const WRITE_DEBOUNCE_MS = 500; // Debounce writes within 500ms window
46
+
47
+ // Long-lived subscription manager - keeps ONE subscription per author for real-time updates
48
+ // Key: pubkey, Value: { subscription, lastEventTime, initialized }
49
+ const authorSubscriptions = new Map();
50
+ const AUTHOR_SUB_INIT_TIMEOUT = 5000; // Wait up to 5s for initial data load
51
+
52
+ /**
53
+ * Simple LRU Cache implementation
54
+ * Automatically evicts least recently used entries when max size is reached
55
+ */
56
+ class LRUCache {
57
+ constructor(maxSize = 500) {
58
+ this.maxSize = maxSize;
59
+ this.cache = new Map();
60
+ }
61
+
62
+ get(key) {
63
+ if (!this.cache.has(key)) return undefined;
64
+
65
+ // Move to end (most recently used)
66
+ const value = this.cache.get(key);
67
+ this.cache.delete(key);
68
+ this.cache.set(key, value);
69
+ return value;
70
+ }
71
+
72
+ set(key, value) {
73
+ // If key exists, delete it first to update position
74
+ if (this.cache.has(key)) {
75
+ this.cache.delete(key);
76
+ }
77
+
78
+ this.cache.set(key, value);
79
+
80
+ // Evict oldest entries if over capacity
81
+ while (this.cache.size > this.maxSize) {
82
+ const oldestKey = this.cache.keys().next().value;
83
+ this.cache.delete(oldestKey);
84
+ }
85
+ }
86
+
87
+ has(key) {
88
+ return this.cache.has(key);
89
+ }
90
+
91
+ delete(key) {
92
+ return this.cache.delete(key);
93
+ }
94
+
95
+ clear() {
96
+ this.cache.clear();
97
+ }
98
+
99
+ get size() {
100
+ return this.cache.size;
101
+ }
102
+
103
+ keys() {
104
+ return this.cache.keys();
105
+ }
106
+
107
+ values() {
108
+ return this.cache.values();
109
+ }
110
+
111
+ entries() {
112
+ return this.cache.entries();
113
+ }
114
+
115
+ forEach(callback) {
116
+ this.cache.forEach(callback);
117
+ }
118
+ }
119
+
10
120
  // Lazy-load WebSocket polyfill for Node.js environment
11
121
  let webSocketPolyfillPromise = null;
12
122
  function ensureWebSocket() {
@@ -57,9 +167,15 @@ export class NostrClient {
57
167
  this.config = config;
58
168
 
59
169
  this._subscriptions = new Map();
60
- this._eventCache = new Map(); // In-memory cache for recent events
170
+ this._eventCache = new LRUCache(config.cacheSize || 500); // LRU cache for recent events
171
+ this._cacheIndex = new Map(); // Reverse index: kind -> Set of cache keys affected by that kind
61
172
  this.persistentStorage = null;
62
173
 
174
+ // Batched persistent writes for better I/O performance
175
+ this._persistQueue = new Map(); // path -> event
176
+ this._persistTimer = null;
177
+ this._persistBatchMs = config.persistBatchMs || 100; // Batch writes within 100ms window
178
+
63
179
  // Initialize pool and storage asynchronously
64
180
  this._initReady = this._initialize();
65
181
  }
@@ -74,10 +190,11 @@ export class NostrClient {
74
190
 
75
191
  // Initialize SimplePool with options (only if relays exist)
76
192
  if (this.relays.length > 0) {
77
- this.pool = new SimplePool({
78
- enableReconnect: this.config.enableReconnect !== false,
79
- enablePing: this.config.enablePing !== false,
80
- });
193
+ // Use global pool singleton to reuse WebSocket connections
194
+ this.pool = getGlobalPool(this.config);
195
+
196
+ // Track relays used by this client
197
+ this.relays.forEach(r => globalPoolRelays.add(r));
81
198
  } else {
82
199
  // Mock pool for testing - returns mock promise that resolves immediately
83
200
  this.pool = {
@@ -90,6 +207,90 @@ export class NostrClient {
90
207
 
91
208
  // Initialize persistent storage
92
209
  await this._initPersistentStorage();
210
+
211
+ // Start long-lived subscription for real-time cache updates
212
+ if (this.relays.length > 0) {
213
+ this._initLongLivedSubscription();
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Initialize a long-lived subscription to keep cache fresh
219
+ * This replaces polling with a single persistent subscription
220
+ * @private
221
+ */
222
+ _initLongLivedSubscription() {
223
+ const subKey = this.publicKey;
224
+
225
+ // Check if subscription already exists for this author
226
+ if (authorSubscriptions.has(subKey)) {
227
+ return;
228
+ }
229
+
230
+ const subInfo = {
231
+ subscription: null,
232
+ initialized: false,
233
+ initPromise: null,
234
+ initResolve: null,
235
+ };
236
+
237
+ // Create promise for initial load completion
238
+ subInfo.initPromise = new Promise(resolve => {
239
+ subInfo.initResolve = resolve;
240
+ });
241
+
242
+ authorSubscriptions.set(subKey, subInfo);
243
+
244
+ // Subscribe to ALL events for this author (kind 30000)
245
+ // This single subscription replaces all the polling queries
246
+ const filter = {
247
+ kinds: [30000],
248
+ authors: [this.publicKey],
249
+ };
250
+
251
+ const sub = this.pool.subscribeMany(
252
+ this.relays,
253
+ [filter],
254
+ {
255
+ onevent: (event) => {
256
+ // Verify author (relay may not respect filter)
257
+ if (event.pubkey !== this.publicKey) {
258
+ return;
259
+ }
260
+
261
+ // Cache the event - this keeps our local cache in sync
262
+ this._cacheEvent(event);
263
+ },
264
+ oneose: () => {
265
+ // End of stored events - initial load complete
266
+ if (!subInfo.initialized) {
267
+ subInfo.initialized = true;
268
+ subInfo.initResolve();
269
+ }
270
+ },
271
+ }
272
+ );
273
+
274
+ subInfo.subscription = sub;
275
+
276
+ // Set timeout for initial load in case EOSE never arrives
277
+ setTimeout(() => {
278
+ if (!subInfo.initialized) {
279
+ subInfo.initialized = true;
280
+ subInfo.initResolve();
281
+ }
282
+ }, AUTHOR_SUB_INIT_TIMEOUT);
283
+ }
284
+
285
+ /**
286
+ * Wait for long-lived subscription to complete initial load
287
+ * @private
288
+ */
289
+ async _waitForSubscriptionInit() {
290
+ const subInfo = authorSubscriptions.get(this.publicKey);
291
+ if (subInfo && subInfo.initPromise) {
292
+ await subInfo.initPromise;
293
+ }
93
294
  }
94
295
 
95
296
  /**
@@ -222,9 +423,11 @@ export class NostrClient {
222
423
 
223
424
  /**
224
425
  * Publish event to relays
426
+ * Supports debouncing for replaceable events (kind 30000-39999) to avoid rapid updates
225
427
  * @param {Object} event - Unsigned event object
226
428
  * @param {Object} options - Publish options
227
429
  * @param {boolean} options.waitForRelays - Wait for relay confirmation (default: false for speed)
430
+ * @param {boolean} options.debounce - Debounce rapid writes to same d-tag (default: true for replaceable events)
228
431
  * @returns {Promise<Object>} Signed event with relay publish results
229
432
  */
230
433
  async publish(event, options = {}) {
@@ -233,6 +436,66 @@ export class NostrClient {
233
436
 
234
437
  const waitForRelays = options.waitForRelays || false;
235
438
 
439
+ // For replaceable events, check if we should debounce
440
+ const isReplaceable = event.kind >= 30000 && event.kind < 40000;
441
+ const shouldDebounce = isReplaceable && options.debounce !== false && !waitForRelays;
442
+
443
+ if (shouldDebounce) {
444
+ const dTag = event.tags?.find(t => t[0] === 'd');
445
+ if (dTag && dTag[1]) {
446
+ return this._debouncedPublish(event, dTag[1], options);
447
+ }
448
+ }
449
+
450
+ return this._doPublish(event, options);
451
+ }
452
+
453
+ /**
454
+ * Debounced publish - coalesces rapid writes to the same d-tag
455
+ * @private
456
+ */
457
+ _debouncedPublish(event, dTagPath, options) {
458
+ return new Promise((resolve, reject) => {
459
+ const existing = pendingWrites.get(dTagPath);
460
+
461
+ if (existing) {
462
+ // Cancel previous pending write and use the new one
463
+ clearTimeout(existing.timer);
464
+ // Resolve the previous promise with the new event (it will be superseded)
465
+ existing.resolve({
466
+ event: null,
467
+ results: [],
468
+ debounced: true,
469
+ supersededBy: event,
470
+ });
471
+ }
472
+
473
+ // Set up debounced write
474
+ const timer = setTimeout(async () => {
475
+ pendingWrites.delete(dTagPath);
476
+ try {
477
+ const result = await this._doPublish(event, options);
478
+ resolve(result);
479
+ } catch (err) {
480
+ reject(err);
481
+ }
482
+ }, WRITE_DEBOUNCE_MS);
483
+
484
+ pendingWrites.set(dTagPath, { event, timer, resolve, reject });
485
+
486
+ // Cache immediately for local-first reads (even before relay publish)
487
+ const signedEvent = finalizeEvent(event, this.privateKey);
488
+ this._cacheEvent(signedEvent);
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Internal publish implementation
494
+ * @private
495
+ */
496
+ async _doPublish(event, options = {}) {
497
+ const waitForRelays = options.waitForRelays || false;
498
+
236
499
  // Sign the event
237
500
  const signedEvent = finalizeEvent(event, this.privateKey);
238
501
 
@@ -256,8 +519,8 @@ export class NostrClient {
256
519
  };
257
520
  } else {
258
521
  // Fire and forget - publish in background, return immediately
259
- this._attemptDelivery(signedEvent, this.relays).catch(err => {
260
- console.warn('[nostr] Immediate delivery failed, queued for retry:', err.message);
522
+ this._attemptDelivery(signedEvent, this.relays).catch(() => {
523
+ // Silent - event is cached locally and queued for retry
261
524
  });
262
525
 
263
526
  // Return immediately (data is in local cache and queued for delivery)
@@ -305,13 +568,19 @@ export class NostrClient {
305
568
  await this.outboxQueue.markSent(event.id, successful);
306
569
  }
307
570
 
308
- // Log failures for debugging
309
- if (failed.length > 0) {
571
+ // Log failures only at debug level (common during network issues)
572
+ if (failed.length > 0 && successful.length === 0) {
573
+ // Only warn if ALL relays failed (likely a real issue)
310
574
  const reasons = results
311
575
  .filter(r => r.status === 'rejected')
312
576
  .map(f => f.reason?.message || f.reason || 'unknown')
313
577
  .join(', ');
314
- console.warn(`[nostr] ${failed.length}/${relays.length} relays failed for ${event.id.slice(0, 8)}: ${reasons}`);
578
+ // Use debug level for timeout issues (very common)
579
+ if (reasons.includes('timed out')) {
580
+ // Silent - event is queued for retry anyway
581
+ } else {
582
+ console.warn(`[nostr] All relays failed for ${event.id.slice(0, 8)}: ${reasons}`);
583
+ }
315
584
  }
316
585
 
317
586
  return { successful, failed, results: formattedResults };
@@ -319,10 +588,12 @@ export class NostrClient {
319
588
 
320
589
  /**
321
590
  * Query events from relays
591
+ * Uses long-lived subscription for cache updates - avoids polling
322
592
  * @param {Object} filter - Nostr filter object
323
593
  * @param {Object} options - Query options
324
594
  * @param {number} options.timeout - Query timeout in ms (default: 30000, set to 0 for no timeout)
325
595
  * @param {boolean} options.localFirst - Return local cache immediately, refresh in background (default: true)
596
+ * @param {boolean} options.forceRelay - Force relay query even if subscription cache is available (default: false)
326
597
  * @returns {Promise<Array>} Array of events
327
598
  */
328
599
  async query(filter, options = {}) {
@@ -331,6 +602,7 @@ export class NostrClient {
331
602
 
332
603
  const timeout = options.timeout !== undefined ? options.timeout : 30000;
333
604
  const localFirst = options.localFirst !== false; // Default to true for speed
605
+ const forceRelay = options.forceRelay === true;
334
606
 
335
607
  // If no relays, query from cache only
336
608
  if (this.relays.length === 0) {
@@ -338,6 +610,35 @@ export class NostrClient {
338
610
  return matchingEvents;
339
611
  }
340
612
 
613
+ // Check if this query can be served from the long-lived subscription cache
614
+ // The subscription keeps ALL events for this author in cache, updated in real-time
615
+ const subInfo = authorSubscriptions.get(this.publicKey);
616
+ const isOwnDataQuery = filter.authors &&
617
+ filter.authors.length === 1 &&
618
+ filter.authors[0] === this.publicKey;
619
+
620
+ // If we have an initialized subscription for our own data, use cache
621
+ if (!forceRelay && isOwnDataQuery && subInfo && subInfo.initialized) {
622
+ // Return matching events from cache - no relay query needed!
623
+ const matchingEvents = this._getMatchingCachedEvents(filter);
624
+ return matchingEvents;
625
+ }
626
+
627
+ // For first query before subscription initializes, wait briefly
628
+ if (isOwnDataQuery && subInfo && !subInfo.initialized) {
629
+ // Wait for subscription to initialize (up to timeout)
630
+ await Promise.race([
631
+ subInfo.initPromise,
632
+ new Promise(resolve => setTimeout(resolve, Math.min(timeout, AUTHOR_SUB_INIT_TIMEOUT)))
633
+ ]);
634
+
635
+ // Now try cache again
636
+ if (subInfo.initialized) {
637
+ const matchingEvents = this._getMatchingCachedEvents(filter);
638
+ return matchingEvents;
639
+ }
640
+ }
641
+
341
642
  // Check d-tag cache first for single-item queries (most common case)
342
643
  // This ensures recently written data is returned immediately
343
644
  if (filter['#d'] && filter['#d'].length === 1 && filter.kinds && filter.kinds.length === 1) {
@@ -370,29 +671,101 @@ export class NostrClient {
370
671
 
371
672
  /**
372
673
  * Query relays and update cache
674
+ * Uses global pending queries map to deduplicate identical concurrent queries
373
675
  * @private
374
676
  */
375
677
  async _queryRelaysAndCache(filter, cacheKey, timeout) {
376
- let events = await this.pool.querySync(this.relays, filter, { timeout });
377
-
378
- // CRITICAL: Filter out events from other authors (relay may not respect filter)
379
- if (filter.authors && filter.authors.length > 0) {
380
- events = events.filter(event => filter.authors.includes(event.pubkey));
678
+ // Check if there's already a pending query for this exact filter
679
+ const pending = pendingQueries.get(cacheKey);
680
+ if (pending && Date.now() - pending.timestamp < PENDING_QUERY_TIMEOUT) {
681
+ // Reuse the pending query promise instead of creating a new one
682
+ return pending.promise;
381
683
  }
382
684
 
383
- // Cache results
384
- this._eventCache.set(cacheKey, {
385
- events,
685
+ // Create the query promise
686
+ const queryPromise = (async () => {
687
+ try {
688
+ let events = await this.pool.querySync(this.relays, filter, { timeout });
689
+
690
+ // CRITICAL: Filter out events from other authors (relay may not respect filter)
691
+ if (filter.authors && filter.authors.length > 0) {
692
+ events = events.filter(event => filter.authors.includes(event.pubkey));
693
+ }
694
+
695
+ // Cache results
696
+ this._eventCache.set(cacheKey, {
697
+ events,
698
+ timestamp: Date.now(),
699
+ });
700
+
701
+ // Update reverse index for fast invalidation
702
+ this._indexCacheEntry(cacheKey, filter);
703
+
704
+ return events;
705
+ } finally {
706
+ // Clean up pending query after completion
707
+ pendingQueries.delete(cacheKey);
708
+ }
709
+ })();
710
+
711
+ // Store the pending query
712
+ pendingQueries.set(cacheKey, {
713
+ promise: queryPromise,
386
714
  timestamp: Date.now(),
387
715
  });
388
716
 
389
- // Limit cache size
390
- if (this._eventCache.size > 100) {
391
- const firstKey = this._eventCache.keys().next().value;
392
- this._eventCache.delete(firstKey);
717
+ return queryPromise;
718
+ }
719
+
720
+ /**
721
+ * Limit cache size (called after cache operations)
722
+ * Note: LRU cache handles this automatically, kept for API compatibility
723
+ * @private
724
+ */
725
+ _limitCacheSize() {
726
+ // LRU cache handles size limiting automatically
727
+ }
728
+
729
+ /**
730
+ * Add cache entry to reverse index for fast invalidation
731
+ * @private
732
+ */
733
+ _indexCacheEntry(cacheKey, filter) {
734
+ // Index by kinds for fast lookup during invalidation
735
+ if (filter.kinds) {
736
+ for (const kind of filter.kinds) {
737
+ if (!this._cacheIndex.has(kind)) {
738
+ this._cacheIndex.set(kind, new Set());
739
+ }
740
+ this._cacheIndex.get(kind).add(cacheKey);
741
+ }
393
742
  }
743
+ }
394
744
 
395
- return events;
745
+ /**
746
+ * Remove cache entry from reverse index
747
+ * @private
748
+ */
749
+ _unindexCacheEntry(cacheKey) {
750
+ // Try to parse the filter from the cache key to remove from index
751
+ if (!cacheKey.startsWith('{')) return;
752
+
753
+ try {
754
+ const filter = JSON.parse(cacheKey);
755
+ if (filter.kinds) {
756
+ for (const kind of filter.kinds) {
757
+ const indexSet = this._cacheIndex.get(kind);
758
+ if (indexSet) {
759
+ indexSet.delete(cacheKey);
760
+ if (indexSet.size === 0) {
761
+ this._cacheIndex.delete(kind);
762
+ }
763
+ }
764
+ }
765
+ }
766
+ } catch {
767
+ // Not a valid filter key, skip
768
+ }
396
769
  }
397
770
 
398
771
  /**
@@ -421,20 +794,42 @@ export class NostrClient {
421
794
 
422
795
  /**
423
796
  * Internal method to refresh a path from relays
797
+ * Throttled to avoid flooding the relay with repeated requests
424
798
  * @private
425
799
  */
426
800
  async _doBackgroundPathRefresh(path, kind, options) {
427
801
  if (this.relays.length === 0) return;
428
802
 
803
+ // Throttle: Skip if we've refreshed this path recently
804
+ const lastRefresh = backgroundRefreshThrottle.get(path);
805
+ if (lastRefresh && Date.now() - lastRefresh < BACKGROUND_REFRESH_INTERVAL) {
806
+ return; // Skip - recently refreshed
807
+ }
808
+
809
+ // Mark as refreshed
810
+ backgroundRefreshThrottle.set(path, Date.now());
811
+
812
+ // Clean up old throttle entries periodically (keep map from growing)
813
+ if (backgroundRefreshThrottle.size > 1000) {
814
+ const cutoff = Date.now() - BACKGROUND_REFRESH_INTERVAL;
815
+ for (const [key, timestamp] of backgroundRefreshThrottle) {
816
+ if (timestamp < cutoff) {
817
+ backgroundRefreshThrottle.delete(key);
818
+ }
819
+ }
820
+ }
821
+
429
822
  const filter = {
430
823
  kinds: [kind],
431
824
  authors: options.authors || [this.publicKey],
432
825
  '#d': [path],
433
826
  limit: 1,
434
827
  };
828
+ const cacheKey = JSON.stringify(filter);
435
829
 
830
+ // Use our query deduplication by calling query() instead of pool.querySync() directly
436
831
  const timeout = options.timeout || 30000;
437
- const events = await this.pool.querySync(this.relays, filter, { timeout });
832
+ const events = await this._queryRelaysAndCache(filter, cacheKey, timeout);
438
833
 
439
834
  // Filter by author (relays may not respect filter)
440
835
  const authorFiltered = events.filter(e =>
@@ -462,22 +857,35 @@ export class NostrClient {
462
857
 
463
858
  /**
464
859
  * Internal method to refresh a prefix from relays
860
+ * Throttled to avoid flooding the relay with repeated requests
465
861
  * @private
466
862
  */
467
863
  async _doBackgroundPrefixRefresh(prefix, kind, options) {
468
864
  if (this.relays.length === 0) return;
469
865
 
866
+ // Throttle: Skip if we've refreshed this prefix recently
867
+ const throttleKey = `prefix:${prefix}`;
868
+ const lastRefresh = backgroundRefreshThrottle.get(throttleKey);
869
+ if (lastRefresh && Date.now() - lastRefresh < BACKGROUND_REFRESH_INTERVAL) {
870
+ return; // Skip - recently refreshed
871
+ }
872
+
873
+ // Mark as refreshed
874
+ backgroundRefreshThrottle.set(throttleKey, Date.now());
875
+
470
876
  // Query with wildcard-ish filter (relays handle d-tag prefix matching)
471
877
  const filter = {
472
878
  kinds: [kind],
473
879
  authors: options.authors || [this.publicKey],
474
880
  limit: options.limit || 1000,
475
881
  };
882
+ const cacheKey = JSON.stringify(filter);
476
883
 
884
+ // Use our query deduplication
477
885
  const timeout = options.timeout || 30000;
478
- let events = await this.pool.querySync(this.relays, filter, { timeout });
886
+ let events = await this._queryRelaysAndCache(filter, cacheKey, timeout);
479
887
 
480
- // Filter by author
888
+ // Filter by author (already done by _queryRelaysAndCache, but double-check)
481
889
  events = events.filter(e =>
482
890
  (options.authors || [this.publicKey]).includes(e.pubkey)
483
891
  );
@@ -584,6 +992,7 @@ export class NostrClient {
584
992
 
585
993
  /**
586
994
  * Subscribe to events
995
+ * Uses subscription deduplication to avoid creating multiple identical subscriptions
587
996
  * @param {Object} filter - Nostr filter object
588
997
  * @param {Function} onEvent - Callback for each event
589
998
  * @param {Object} options - Subscription options
@@ -593,7 +1002,8 @@ export class NostrClient {
593
1002
  // Ensure initialization is complete
594
1003
  await this._initReady;
595
1004
 
596
- const subId = `sub-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1005
+ const subId = `sub-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
1006
+ const filterKey = JSON.stringify(filter);
597
1007
 
598
1008
  // If no relays, check cache for matching events and trigger callbacks
599
1009
  if (this.relays.length === 0) {
@@ -630,6 +1040,43 @@ export class NostrClient {
630
1040
  };
631
1041
  }
632
1042
 
1043
+ // Check if we already have an active subscription for this filter
1044
+ const existing = activeSubscriptions.get(filterKey);
1045
+ if (existing) {
1046
+ // Add callback to existing subscription
1047
+ existing.callbacks.add(onEvent);
1048
+ existing.refCount++;
1049
+
1050
+ // Return wrapper that removes this specific callback on unsubscribe
1051
+ return {
1052
+ id: subId,
1053
+ unsubscribe: () => {
1054
+ existing.callbacks.delete(onEvent);
1055
+ existing.refCount--;
1056
+
1057
+ // Only close actual subscription when no more callbacks
1058
+ if (existing.refCount === 0) {
1059
+ if (existing.subscription && existing.subscription.close) {
1060
+ existing.subscription.close();
1061
+ }
1062
+ activeSubscriptions.delete(filterKey);
1063
+ }
1064
+ this._subscriptions.delete(subId);
1065
+ },
1066
+ };
1067
+ }
1068
+
1069
+ // Create new subscription with shared callback dispatcher
1070
+ const callbacks = new Set([onEvent]);
1071
+ const subscriptionInfo = {
1072
+ callbacks,
1073
+ refCount: 1,
1074
+ subscription: null,
1075
+ };
1076
+
1077
+ // Store before creating subscription to handle race conditions
1078
+ activeSubscriptions.set(filterKey, subscriptionInfo);
1079
+
633
1080
  const sub = this.pool.subscribeMany(
634
1081
  this.relays,
635
1082
  [filter],
@@ -645,7 +1092,18 @@ export class NostrClient {
645
1092
  }
646
1093
 
647
1094
  this._cacheEvent(event);
648
- onEvent(event);
1095
+
1096
+ // Dispatch to ALL registered callbacks for this subscription
1097
+ const subInfo = activeSubscriptions.get(filterKey);
1098
+ if (subInfo) {
1099
+ for (const cb of subInfo.callbacks) {
1100
+ try {
1101
+ cb(event);
1102
+ } catch (err) {
1103
+ console.warn('[nostr] Subscription callback error:', err.message);
1104
+ }
1105
+ }
1106
+ }
649
1107
  },
650
1108
  oneose: () => {
651
1109
  if (options.onEOSE) options.onEOSE();
@@ -653,12 +1111,21 @@ export class NostrClient {
653
1111
  }
654
1112
  );
655
1113
 
1114
+ // Store the actual subscription object
1115
+ subscriptionInfo.subscription = sub;
656
1116
  this._subscriptions.set(subId, sub);
657
1117
 
658
1118
  return {
659
1119
  id: subId,
660
1120
  unsubscribe: () => {
661
- if (sub.close) sub.close();
1121
+ callbacks.delete(onEvent);
1122
+ subscriptionInfo.refCount--;
1123
+
1124
+ // Only close actual subscription when no more callbacks
1125
+ if (subscriptionInfo.refCount === 0) {
1126
+ if (sub.close) sub.close();
1127
+ activeSubscriptions.delete(filterKey);
1128
+ }
662
1129
  this._subscriptions.delete(subId);
663
1130
  },
664
1131
  };
@@ -708,16 +1175,26 @@ export class NostrClient {
708
1175
 
709
1176
  /**
710
1177
  * Invalidate query caches that might be affected by a new event
1178
+ * Uses reverse index for O(1) lookup instead of O(n) scan
711
1179
  * @private
712
1180
  */
713
1181
  _invalidateQueryCachesForEvent(event) {
714
- // Find and remove query cache entries that could match this event
715
- // Query cache keys are JSON-stringified filters
1182
+ // Use reverse index for fast lookup - only check caches that could match this event's kind
1183
+ const indexedKeys = this._cacheIndex.get(event.kind);
1184
+ if (!indexedKeys || indexedKeys.size === 0) {
1185
+ return; // No cached queries for this kind
1186
+ }
1187
+
716
1188
  const keysToDelete = [];
717
1189
 
718
- for (const [cacheKey, cached] of this._eventCache.entries()) {
719
- // Skip non-query caches (event IDs and d-tag keys)
720
- if (!cacheKey.startsWith('{')) continue;
1190
+ // Only iterate over cache entries that match the event's kind
1191
+ for (const cacheKey of indexedKeys) {
1192
+ const cached = this._eventCache.get(cacheKey);
1193
+ if (!cached) {
1194
+ // Cache entry was evicted, clean up index
1195
+ indexedKeys.delete(cacheKey);
1196
+ continue;
1197
+ }
721
1198
 
722
1199
  try {
723
1200
  const filter = JSON.parse(cacheKey);
@@ -726,38 +1203,42 @@ export class NostrClient {
726
1203
  keysToDelete.push(cacheKey);
727
1204
  }
728
1205
  } catch {
729
- // Not a JSON key, skip
1206
+ // Not a valid JSON key, clean up index
1207
+ indexedKeys.delete(cacheKey);
730
1208
  }
731
1209
  }
732
1210
 
733
1211
  for (const key of keysToDelete) {
734
1212
  this._eventCache.delete(key);
1213
+ this._unindexCacheEntry(key);
735
1214
  }
736
1215
  }
737
1216
 
738
1217
  /**
739
- * Cache event in memory and persist
1218
+ * Cache event in memory and persist (batched)
740
1219
  * @private
741
1220
  */
742
1221
  async _cacheEvent(event) {
743
- // Cache in memory
1222
+ // Cache in memory (synchronous - immediate for local-first reads)
744
1223
  this._cacheEventSync(event);
745
1224
 
746
- // Persist to storage
1225
+ // Queue for batched persistence (async - batches writes for I/O efficiency)
747
1226
  if (this.persistentStorage) {
748
- try {
749
- // For replaceable events, use d-tag as key
750
- let storageKey = event.id;
751
- if (event.kind >= 30000 && event.kind < 40000) {
752
- const dTag = event.tags.find(t => t[0] === 'd');
753
- if (dTag && dTag[1]) {
754
- storageKey = dTag[1]; // Use d-tag as key for replaceable events
755
- }
1227
+ // For replaceable events, use d-tag as key
1228
+ let storageKey = event.id;
1229
+ if (event.kind >= 30000 && event.kind < 40000) {
1230
+ const dTag = event.tags.find(t => t[0] === 'd');
1231
+ if (dTag && dTag[1]) {
1232
+ storageKey = dTag[1]; // Use d-tag as key for replaceable events
756
1233
  }
1234
+ }
757
1235
 
758
- await this.persistentStorage.put(storageKey, event);
759
- } catch (error) {
760
- console.warn('Failed to persist event:', error);
1236
+ // Queue for batched write (overwrites previous if same key)
1237
+ this._persistQueue.set(storageKey, event);
1238
+
1239
+ // Schedule batch flush if not already scheduled
1240
+ if (!this._persistTimer) {
1241
+ this._persistTimer = setTimeout(() => this._flushPersistQueue(), this._persistBatchMs);
761
1242
  }
762
1243
  }
763
1244
 
@@ -776,6 +1257,34 @@ export class NostrClient {
776
1257
  }
777
1258
  }
778
1259
 
1260
+ /**
1261
+ * Flush batched persistent writes
1262
+ * @private
1263
+ */
1264
+ async _flushPersistQueue() {
1265
+ this._persistTimer = null;
1266
+
1267
+ if (!this.persistentStorage || this._persistQueue.size === 0) {
1268
+ return;
1269
+ }
1270
+
1271
+ // Take snapshot of current queue and clear it
1272
+ const toWrite = Array.from(this._persistQueue.entries());
1273
+ this._persistQueue.clear();
1274
+
1275
+ // Write all queued events
1276
+ const writePromises = toWrite.map(async ([key, event]) => {
1277
+ try {
1278
+ await this.persistentStorage.put(key, event);
1279
+ } catch (error) {
1280
+ console.warn(`Failed to persist event ${key}:`, error.message);
1281
+ }
1282
+ });
1283
+
1284
+ // Wait for all writes to complete
1285
+ await Promise.all(writePromises);
1286
+ }
1287
+
779
1288
  /**
780
1289
  * Get cached events matching a filter
781
1290
  * @private
@@ -892,13 +1401,32 @@ export class NostrClient {
892
1401
 
893
1402
  /**
894
1403
  * Close all connections and subscriptions
1404
+ * @param {Object} options - Close options
1405
+ * @param {boolean} options.flush - Flush pending writes before closing (default: true)
895
1406
  */
896
- close() {
1407
+ async close(options = {}) {
1408
+ const shouldFlush = options.flush !== false;
1409
+
1410
+ // Flush pending persistent writes before closing
1411
+ if (shouldFlush && this._persistTimer) {
1412
+ clearTimeout(this._persistTimer);
1413
+ await this._flushPersistQueue();
1414
+ }
1415
+
897
1416
  // Stop background sync service
898
1417
  if (this.syncService) {
899
1418
  this.syncService.stop();
900
1419
  }
901
1420
 
1421
+ // Close long-lived author subscription
1422
+ const authorSub = authorSubscriptions.get(this.publicKey);
1423
+ if (authorSub && authorSub.subscription) {
1424
+ if (authorSub.subscription.close) {
1425
+ authorSub.subscription.close();
1426
+ }
1427
+ authorSubscriptions.delete(this.publicKey);
1428
+ }
1429
+
902
1430
  // Close all subscriptions
903
1431
  for (const sub of this._subscriptions.values()) {
904
1432
  if (sub.close) {
@@ -913,8 +1441,9 @@ export class NostrClient {
913
1441
  // Close pool
914
1442
  this.pool.close(this.relays);
915
1443
 
916
- // Clear cache
1444
+ // Clear cache and index
917
1445
  this._eventCache.clear();
1446
+ this._cacheIndex.clear();
918
1447
  }
919
1448
 
920
1449
  /**