holosphere 2.0.0-alpha4 → 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 (28) hide show
  1. package/dist/cjs/holosphere.cjs +1 -1
  2. package/dist/esm/holosphere.js +1 -1
  3. package/dist/{index-CBitK71M.cjs → index-BG8FStkt.cjs} +2 -2
  4. package/dist/{index-CBitK71M.cjs.map → index-BG8FStkt.cjs.map} +1 -1
  5. package/dist/{index-CV0eOogK.js → index-Bbey4GkP.js} +502 -56
  6. package/dist/index-Bbey4GkP.js.map +1 -0
  7. package/dist/{index-Cz-PLCUR.js → index-Cp3xctq8.js} +2 -2
  8. package/dist/{index-Cz-PLCUR.js.map → index-Cp3xctq8.js.map} +1 -1
  9. package/dist/index-hfVGRwSr.cjs +5 -0
  10. package/dist/index-hfVGRwSr.cjs.map +1 -0
  11. package/dist/{indexeddb-storage-CRsZyB2f.cjs → indexeddb-storage-BD70pN7q.cjs} +2 -2
  12. package/dist/{indexeddb-storage-CRsZyB2f.cjs.map → indexeddb-storage-BD70pN7q.cjs.map} +1 -1
  13. package/dist/{indexeddb-storage-DZaGlY_a.js → indexeddb-storage-Bjg84U5R.js} +2 -2
  14. package/dist/{indexeddb-storage-DZaGlY_a.js.map → indexeddb-storage-Bjg84U5R.js.map} +1 -1
  15. package/dist/{memory-storage-BkUi6sZG.js → memory-storage-CD0XFayE.js} +2 -2
  16. package/dist/{memory-storage-BkUi6sZG.js.map → memory-storage-CD0XFayE.js.map} +1 -1
  17. package/dist/{memory-storage-C0DuUsdY.cjs → memory-storage-DmMyJtOo.cjs} +2 -2
  18. package/dist/{memory-storage-C0DuUsdY.cjs.map → memory-storage-DmMyJtOo.cjs.map} +1 -1
  19. package/dist/{secp256k1-DN4FVXcv.js → secp256k1-69sS9O-P.js} +2 -2
  20. package/dist/{secp256k1-DN4FVXcv.js.map → secp256k1-69sS9O-P.js.map} +1 -1
  21. package/dist/{secp256k1-0kPdAVkK.cjs → secp256k1-TcN6vWGh.cjs} +2 -2
  22. package/dist/{secp256k1-0kPdAVkK.cjs.map → secp256k1-TcN6vWGh.cjs.map} +1 -1
  23. package/package.json +1 -1
  24. package/src/storage/nostr-async.js +149 -14
  25. package/src/storage/nostr-client.js +569 -46
  26. package/dist/index-BB_vVJgv.cjs +0 -5
  27. package/dist/index-BB_vVJgv.cjs.map +0 -1
  28. package/dist/index-CV0eOogK.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { getPublicKey as getPublicKey$2, SimplePool, finalizeEvent, nip19, nip04, verifyEvent as verifyEvent$1 } from "nostr-tools";
1
+ import { getPublicKey as getPublicKey$2, finalizeEvent, SimplePool, nip19, nip04, verifyEvent as verifyEvent$1 } from "nostr-tools";
2
2
  import * as h3 from "h3-js";
3
3
  import { latLngToCell, getResolution, cellToParent, cellToChildren, isValidCell } from "h3-js";
4
4
  import Ajv from "ajv";
@@ -361,17 +361,84 @@ async function createPersistentStorage(namespace, options = {}) {
361
361
  const isBrowser = typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
362
362
  typeof process !== "undefined" && process.versions && void 0;
363
363
  if (isBrowser) {
364
- const { IndexedDBStorage } = await import("./indexeddb-storage-DZaGlY_a.js");
364
+ const { IndexedDBStorage } = await import("./indexeddb-storage-Bjg84U5R.js");
365
365
  const storage = new IndexedDBStorage();
366
366
  await storage.init(namespace);
367
367
  return storage;
368
368
  } else {
369
- const { MemoryStorage } = await import("./memory-storage-BkUi6sZG.js");
369
+ const { MemoryStorage } = await import("./memory-storage-CD0XFayE.js");
370
370
  const storage = new MemoryStorage();
371
371
  await storage.init(namespace);
372
372
  return storage;
373
373
  }
374
374
  }
375
+ let globalPool = null;
376
+ let globalPoolRelays = /* @__PURE__ */ new Set();
377
+ function getGlobalPool(config = {}) {
378
+ if (!globalPool) {
379
+ globalPool = new SimplePool({
380
+ enableReconnect: config.enableReconnect !== false,
381
+ enablePing: config.enablePing !== false
382
+ });
383
+ }
384
+ return globalPool;
385
+ }
386
+ const pendingQueries$1 = /* @__PURE__ */ new Map();
387
+ const PENDING_QUERY_TIMEOUT = 5e3;
388
+ const activeSubscriptions = /* @__PURE__ */ new Map();
389
+ const backgroundRefreshThrottle = /* @__PURE__ */ new Map();
390
+ const BACKGROUND_REFRESH_INTERVAL = 3e4;
391
+ const pendingWrites = /* @__PURE__ */ new Map();
392
+ const WRITE_DEBOUNCE_MS = 500;
393
+ const authorSubscriptions = /* @__PURE__ */ new Map();
394
+ const AUTHOR_SUB_INIT_TIMEOUT = 5e3;
395
+ class LRUCache {
396
+ constructor(maxSize = 500) {
397
+ this.maxSize = maxSize;
398
+ this.cache = /* @__PURE__ */ new Map();
399
+ }
400
+ get(key) {
401
+ if (!this.cache.has(key)) return void 0;
402
+ const value = this.cache.get(key);
403
+ this.cache.delete(key);
404
+ this.cache.set(key, value);
405
+ return value;
406
+ }
407
+ set(key, value) {
408
+ if (this.cache.has(key)) {
409
+ this.cache.delete(key);
410
+ }
411
+ this.cache.set(key, value);
412
+ while (this.cache.size > this.maxSize) {
413
+ const oldestKey = this.cache.keys().next().value;
414
+ this.cache.delete(oldestKey);
415
+ }
416
+ }
417
+ has(key) {
418
+ return this.cache.has(key);
419
+ }
420
+ delete(key) {
421
+ return this.cache.delete(key);
422
+ }
423
+ clear() {
424
+ this.cache.clear();
425
+ }
426
+ get size() {
427
+ return this.cache.size;
428
+ }
429
+ keys() {
430
+ return this.cache.keys();
431
+ }
432
+ values() {
433
+ return this.cache.values();
434
+ }
435
+ entries() {
436
+ return this.cache.entries();
437
+ }
438
+ forEach(callback) {
439
+ this.cache.forEach(callback);
440
+ }
441
+ }
375
442
  let webSocketPolyfillPromise = null;
376
443
  function ensureWebSocket() {
377
444
  if (typeof globalThis.WebSocket !== "undefined") {
@@ -407,8 +474,12 @@ class NostrClient {
407
474
  this.publicKey = getPublicKey$2(this.privateKey);
408
475
  this.config = config;
409
476
  this._subscriptions = /* @__PURE__ */ new Map();
410
- this._eventCache = /* @__PURE__ */ new Map();
477
+ this._eventCache = new LRUCache(config.cacheSize || 500);
478
+ this._cacheIndex = /* @__PURE__ */ new Map();
411
479
  this.persistentStorage = null;
480
+ this._persistQueue = /* @__PURE__ */ new Map();
481
+ this._persistTimer = null;
482
+ this._persistBatchMs = config.persistBatchMs || 100;
412
483
  this._initReady = this._initialize();
413
484
  }
414
485
  /**
@@ -418,10 +489,8 @@ class NostrClient {
418
489
  async _initialize() {
419
490
  await ensureWebSocket();
420
491
  if (this.relays.length > 0) {
421
- this.pool = new SimplePool({
422
- enableReconnect: this.config.enableReconnect !== false,
423
- enablePing: this.config.enablePing !== false
424
- });
492
+ this.pool = getGlobalPool(this.config);
493
+ this.relays.forEach((r) => globalPoolRelays.add(r));
425
494
  } else {
426
495
  this.pool = {
427
496
  publish: (relays, event) => [Promise.resolve()],
@@ -433,6 +502,69 @@ class NostrClient {
433
502
  };
434
503
  }
435
504
  await this._initPersistentStorage();
505
+ if (this.relays.length > 0) {
506
+ this._initLongLivedSubscription();
507
+ }
508
+ }
509
+ /**
510
+ * Initialize a long-lived subscription to keep cache fresh
511
+ * This replaces polling with a single persistent subscription
512
+ * @private
513
+ */
514
+ _initLongLivedSubscription() {
515
+ const subKey = this.publicKey;
516
+ if (authorSubscriptions.has(subKey)) {
517
+ return;
518
+ }
519
+ const subInfo = {
520
+ subscription: null,
521
+ initialized: false,
522
+ initPromise: null,
523
+ initResolve: null
524
+ };
525
+ subInfo.initPromise = new Promise((resolve) => {
526
+ subInfo.initResolve = resolve;
527
+ });
528
+ authorSubscriptions.set(subKey, subInfo);
529
+ const filter = {
530
+ kinds: [3e4],
531
+ authors: [this.publicKey]
532
+ };
533
+ const sub = this.pool.subscribeMany(
534
+ this.relays,
535
+ [filter],
536
+ {
537
+ onevent: (event) => {
538
+ if (event.pubkey !== this.publicKey) {
539
+ return;
540
+ }
541
+ this._cacheEvent(event);
542
+ },
543
+ oneose: () => {
544
+ if (!subInfo.initialized) {
545
+ subInfo.initialized = true;
546
+ subInfo.initResolve();
547
+ }
548
+ }
549
+ }
550
+ );
551
+ subInfo.subscription = sub;
552
+ setTimeout(() => {
553
+ if (!subInfo.initialized) {
554
+ subInfo.initialized = true;
555
+ subInfo.initResolve();
556
+ }
557
+ }, AUTHOR_SUB_INIT_TIMEOUT);
558
+ }
559
+ /**
560
+ * Wait for long-lived subscription to complete initial load
561
+ * @private
562
+ */
563
+ async _waitForSubscriptionInit() {
564
+ const subInfo = authorSubscriptions.get(this.publicKey);
565
+ if (subInfo && subInfo.initPromise) {
566
+ await subInfo.initPromise;
567
+ }
436
568
  }
437
569
  /**
438
570
  * Initialize persistent storage
@@ -542,13 +674,61 @@ class NostrClient {
542
674
  }
543
675
  /**
544
676
  * Publish event to relays
677
+ * Supports debouncing for replaceable events (kind 30000-39999) to avoid rapid updates
545
678
  * @param {Object} event - Unsigned event object
546
679
  * @param {Object} options - Publish options
547
680
  * @param {boolean} options.waitForRelays - Wait for relay confirmation (default: false for speed)
681
+ * @param {boolean} options.debounce - Debounce rapid writes to same d-tag (default: true for replaceable events)
548
682
  * @returns {Promise<Object>} Signed event with relay publish results
549
683
  */
550
684
  async publish(event, options = {}) {
551
685
  await this._initReady;
686
+ const waitForRelays = options.waitForRelays || false;
687
+ const isReplaceable = event.kind >= 3e4 && event.kind < 4e4;
688
+ const shouldDebounce = isReplaceable && options.debounce !== false && !waitForRelays;
689
+ if (shouldDebounce) {
690
+ const dTag = event.tags?.find((t) => t[0] === "d");
691
+ if (dTag && dTag[1]) {
692
+ return this._debouncedPublish(event, dTag[1], options);
693
+ }
694
+ }
695
+ return this._doPublish(event, options);
696
+ }
697
+ /**
698
+ * Debounced publish - coalesces rapid writes to the same d-tag
699
+ * @private
700
+ */
701
+ _debouncedPublish(event, dTagPath, options) {
702
+ return new Promise((resolve, reject) => {
703
+ const existing = pendingWrites.get(dTagPath);
704
+ if (existing) {
705
+ clearTimeout(existing.timer);
706
+ existing.resolve({
707
+ event: null,
708
+ results: [],
709
+ debounced: true,
710
+ supersededBy: event
711
+ });
712
+ }
713
+ const timer = setTimeout(async () => {
714
+ pendingWrites.delete(dTagPath);
715
+ try {
716
+ const result = await this._doPublish(event, options);
717
+ resolve(result);
718
+ } catch (err) {
719
+ reject(err);
720
+ }
721
+ }, WRITE_DEBOUNCE_MS);
722
+ pendingWrites.set(dTagPath, { event, timer, resolve, reject });
723
+ const signedEvent = finalizeEvent(event, this.privateKey);
724
+ this._cacheEvent(signedEvent);
725
+ });
726
+ }
727
+ /**
728
+ * Internal publish implementation
729
+ * @private
730
+ */
731
+ async _doPublish(event, options = {}) {
552
732
  const waitForRelays = options.waitForRelays || false;
553
733
  const signedEvent = finalizeEvent(event, this.privateKey);
554
734
  await this._cacheEvent(signedEvent);
@@ -613,20 +793,39 @@ class NostrClient {
613
793
  }
614
794
  /**
615
795
  * Query events from relays
796
+ * Uses long-lived subscription for cache updates - avoids polling
616
797
  * @param {Object} filter - Nostr filter object
617
798
  * @param {Object} options - Query options
618
799
  * @param {number} options.timeout - Query timeout in ms (default: 30000, set to 0 for no timeout)
619
800
  * @param {boolean} options.localFirst - Return local cache immediately, refresh in background (default: true)
801
+ * @param {boolean} options.forceRelay - Force relay query even if subscription cache is available (default: false)
620
802
  * @returns {Promise<Array>} Array of events
621
803
  */
622
804
  async query(filter, options = {}) {
623
805
  await this._initReady;
624
806
  const timeout = options.timeout !== void 0 ? options.timeout : 3e4;
625
807
  const localFirst = options.localFirst !== false;
808
+ const forceRelay = options.forceRelay === true;
626
809
  if (this.relays.length === 0) {
627
810
  const matchingEvents = this._getMatchingCachedEvents(filter);
628
811
  return matchingEvents;
629
812
  }
813
+ const subInfo = authorSubscriptions.get(this.publicKey);
814
+ const isOwnDataQuery = filter.authors && filter.authors.length === 1 && filter.authors[0] === this.publicKey;
815
+ if (!forceRelay && isOwnDataQuery && subInfo && subInfo.initialized) {
816
+ const matchingEvents = this._getMatchingCachedEvents(filter);
817
+ return matchingEvents;
818
+ }
819
+ if (isOwnDataQuery && subInfo && !subInfo.initialized) {
820
+ await Promise.race([
821
+ subInfo.initPromise,
822
+ new Promise((resolve) => setTimeout(resolve, Math.min(timeout, AUTHOR_SUB_INIT_TIMEOUT)))
823
+ ]);
824
+ if (subInfo.initialized) {
825
+ const matchingEvents = this._getMatchingCachedEvents(filter);
826
+ return matchingEvents;
827
+ }
828
+ }
630
829
  if (filter["#d"] && filter["#d"].length === 1 && filter.kinds && filter.kinds.length === 1) {
631
830
  const dTagKey = `d:${filter.kinds[0]}:${filter["#d"][0]}`;
632
831
  const dTagCached = this._eventCache.get(dTagKey);
@@ -647,22 +846,78 @@ class NostrClient {
647
846
  }
648
847
  /**
649
848
  * Query relays and update cache
849
+ * Uses global pending queries map to deduplicate identical concurrent queries
650
850
  * @private
651
851
  */
652
852
  async _queryRelaysAndCache(filter, cacheKey, timeout) {
653
- let events = await this.pool.querySync(this.relays, filter, { timeout });
654
- if (filter.authors && filter.authors.length > 0) {
655
- events = events.filter((event) => filter.authors.includes(event.pubkey));
853
+ const pending = pendingQueries$1.get(cacheKey);
854
+ if (pending && Date.now() - pending.timestamp < PENDING_QUERY_TIMEOUT) {
855
+ return pending.promise;
656
856
  }
657
- this._eventCache.set(cacheKey, {
658
- events,
857
+ const queryPromise = (async () => {
858
+ try {
859
+ let events = await this.pool.querySync(this.relays, filter, { timeout });
860
+ if (filter.authors && filter.authors.length > 0) {
861
+ events = events.filter((event) => filter.authors.includes(event.pubkey));
862
+ }
863
+ this._eventCache.set(cacheKey, {
864
+ events,
865
+ timestamp: Date.now()
866
+ });
867
+ this._indexCacheEntry(cacheKey, filter);
868
+ return events;
869
+ } finally {
870
+ pendingQueries$1.delete(cacheKey);
871
+ }
872
+ })();
873
+ pendingQueries$1.set(cacheKey, {
874
+ promise: queryPromise,
659
875
  timestamp: Date.now()
660
876
  });
661
- if (this._eventCache.size > 100) {
662
- const firstKey = this._eventCache.keys().next().value;
663
- this._eventCache.delete(firstKey);
877
+ return queryPromise;
878
+ }
879
+ /**
880
+ * Limit cache size (called after cache operations)
881
+ * Note: LRU cache handles this automatically, kept for API compatibility
882
+ * @private
883
+ */
884
+ _limitCacheSize() {
885
+ }
886
+ /**
887
+ * Add cache entry to reverse index for fast invalidation
888
+ * @private
889
+ */
890
+ _indexCacheEntry(cacheKey, filter) {
891
+ if (filter.kinds) {
892
+ for (const kind2 of filter.kinds) {
893
+ if (!this._cacheIndex.has(kind2)) {
894
+ this._cacheIndex.set(kind2, /* @__PURE__ */ new Set());
895
+ }
896
+ this._cacheIndex.get(kind2).add(cacheKey);
897
+ }
898
+ }
899
+ }
900
+ /**
901
+ * Remove cache entry from reverse index
902
+ * @private
903
+ */
904
+ _unindexCacheEntry(cacheKey) {
905
+ if (!cacheKey.startsWith("{")) return;
906
+ try {
907
+ const filter = JSON.parse(cacheKey);
908
+ if (filter.kinds) {
909
+ for (const kind2 of filter.kinds) {
910
+ const indexSet = this._cacheIndex.get(kind2);
911
+ if (indexSet) {
912
+ indexSet.delete(cacheKey);
913
+ if (indexSet.size === 0) {
914
+ this._cacheIndex.delete(kind2);
915
+ }
916
+ }
917
+ }
918
+ }
919
+ } catch {
664
920
  }
665
- return events;
666
921
  }
667
922
  /**
668
923
  * Refresh cache in background (fire and forget)
@@ -687,18 +942,33 @@ class NostrClient {
687
942
  }
688
943
  /**
689
944
  * Internal method to refresh a path from relays
945
+ * Throttled to avoid flooding the relay with repeated requests
690
946
  * @private
691
947
  */
692
948
  async _doBackgroundPathRefresh(path, kind2, options) {
693
949
  if (this.relays.length === 0) return;
950
+ const lastRefresh = backgroundRefreshThrottle.get(path);
951
+ if (lastRefresh && Date.now() - lastRefresh < BACKGROUND_REFRESH_INTERVAL) {
952
+ return;
953
+ }
954
+ backgroundRefreshThrottle.set(path, Date.now());
955
+ if (backgroundRefreshThrottle.size > 1e3) {
956
+ const cutoff = Date.now() - BACKGROUND_REFRESH_INTERVAL;
957
+ for (const [key, timestamp] of backgroundRefreshThrottle) {
958
+ if (timestamp < cutoff) {
959
+ backgroundRefreshThrottle.delete(key);
960
+ }
961
+ }
962
+ }
694
963
  const filter = {
695
964
  kinds: [kind2],
696
965
  authors: options.authors || [this.publicKey],
697
966
  "#d": [path],
698
967
  limit: 1
699
968
  };
969
+ const cacheKey = JSON.stringify(filter);
700
970
  const timeout = options.timeout || 3e4;
701
- const events = await this.pool.querySync(this.relays, filter, { timeout });
971
+ const events = await this._queryRelaysAndCache(filter, cacheKey, timeout);
702
972
  const authorFiltered = events.filter(
703
973
  (e) => (options.authors || [this.publicKey]).includes(e.pubkey)
704
974
  );
@@ -720,17 +990,25 @@ class NostrClient {
720
990
  }
721
991
  /**
722
992
  * Internal method to refresh a prefix from relays
993
+ * Throttled to avoid flooding the relay with repeated requests
723
994
  * @private
724
995
  */
725
996
  async _doBackgroundPrefixRefresh(prefix, kind2, options) {
726
997
  if (this.relays.length === 0) return;
998
+ const throttleKey = `prefix:${prefix}`;
999
+ const lastRefresh = backgroundRefreshThrottle.get(throttleKey);
1000
+ if (lastRefresh && Date.now() - lastRefresh < BACKGROUND_REFRESH_INTERVAL) {
1001
+ return;
1002
+ }
1003
+ backgroundRefreshThrottle.set(throttleKey, Date.now());
727
1004
  const filter = {
728
1005
  kinds: [kind2],
729
1006
  authors: options.authors || [this.publicKey],
730
1007
  limit: options.limit || 1e3
731
1008
  };
1009
+ const cacheKey = JSON.stringify(filter);
732
1010
  const timeout = options.timeout || 3e4;
733
- let events = await this.pool.querySync(this.relays, filter, { timeout });
1011
+ let events = await this._queryRelaysAndCache(filter, cacheKey, timeout);
734
1012
  events = events.filter(
735
1013
  (e) => (options.authors || [this.publicKey]).includes(e.pubkey)
736
1014
  );
@@ -805,6 +1083,7 @@ class NostrClient {
805
1083
  }
806
1084
  /**
807
1085
  * Subscribe to events
1086
+ * Uses subscription deduplication to avoid creating multiple identical subscriptions
808
1087
  * @param {Object} filter - Nostr filter object
809
1088
  * @param {Function} onEvent - Callback for each event
810
1089
  * @param {Object} options - Subscription options
@@ -812,7 +1091,8 @@ class NostrClient {
812
1091
  */
813
1092
  async subscribe(filter, onEvent, options = {}) {
814
1093
  await this._initReady;
815
- const subId = `sub-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1094
+ const subId = `sub-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
1095
+ const filterKey = JSON.stringify(filter);
816
1096
  if (this.relays.length === 0) {
817
1097
  const matchingEvents = this._getMatchingCachedEvents(filter);
818
1098
  const mockSub = {
@@ -837,6 +1117,32 @@ class NostrClient {
837
1117
  }
838
1118
  };
839
1119
  }
1120
+ const existing = activeSubscriptions.get(filterKey);
1121
+ if (existing) {
1122
+ existing.callbacks.add(onEvent);
1123
+ existing.refCount++;
1124
+ return {
1125
+ id: subId,
1126
+ unsubscribe: () => {
1127
+ existing.callbacks.delete(onEvent);
1128
+ existing.refCount--;
1129
+ if (existing.refCount === 0) {
1130
+ if (existing.subscription && existing.subscription.close) {
1131
+ existing.subscription.close();
1132
+ }
1133
+ activeSubscriptions.delete(filterKey);
1134
+ }
1135
+ this._subscriptions.delete(subId);
1136
+ }
1137
+ };
1138
+ }
1139
+ const callbacks = /* @__PURE__ */ new Set([onEvent]);
1140
+ const subscriptionInfo = {
1141
+ callbacks,
1142
+ refCount: 1,
1143
+ subscription: null
1144
+ };
1145
+ activeSubscriptions.set(filterKey, subscriptionInfo);
840
1146
  const sub = this.pool.subscribeMany(
841
1147
  this.relays,
842
1148
  [filter],
@@ -848,18 +1154,33 @@ class NostrClient {
848
1154
  }
849
1155
  }
850
1156
  this._cacheEvent(event);
851
- onEvent(event);
1157
+ const subInfo = activeSubscriptions.get(filterKey);
1158
+ if (subInfo) {
1159
+ for (const cb of subInfo.callbacks) {
1160
+ try {
1161
+ cb(event);
1162
+ } catch (err) {
1163
+ console.warn("[nostr] Subscription callback error:", err.message);
1164
+ }
1165
+ }
1166
+ }
852
1167
  },
853
1168
  oneose: () => {
854
1169
  if (options.onEOSE) options.onEOSE();
855
1170
  }
856
1171
  }
857
1172
  );
1173
+ subscriptionInfo.subscription = sub;
858
1174
  this._subscriptions.set(subId, sub);
859
1175
  return {
860
1176
  id: subId,
861
1177
  unsubscribe: () => {
862
- if (sub.close) sub.close();
1178
+ callbacks.delete(onEvent);
1179
+ subscriptionInfo.refCount--;
1180
+ if (subscriptionInfo.refCount === 0) {
1181
+ if (sub.close) sub.close();
1182
+ activeSubscriptions.delete(filterKey);
1183
+ }
863
1184
  this._subscriptions.delete(subId);
864
1185
  }
865
1186
  };
@@ -895,42 +1216,52 @@ class NostrClient {
895
1216
  }
896
1217
  /**
897
1218
  * Invalidate query caches that might be affected by a new event
1219
+ * Uses reverse index for O(1) lookup instead of O(n) scan
898
1220
  * @private
899
1221
  */
900
1222
  _invalidateQueryCachesForEvent(event) {
1223
+ const indexedKeys = this._cacheIndex.get(event.kind);
1224
+ if (!indexedKeys || indexedKeys.size === 0) {
1225
+ return;
1226
+ }
901
1227
  const keysToDelete = [];
902
- for (const [cacheKey, cached] of this._eventCache.entries()) {
903
- if (!cacheKey.startsWith("{")) continue;
1228
+ for (const cacheKey of indexedKeys) {
1229
+ const cached = this._eventCache.get(cacheKey);
1230
+ if (!cached) {
1231
+ indexedKeys.delete(cacheKey);
1232
+ continue;
1233
+ }
904
1234
  try {
905
1235
  const filter = JSON.parse(cacheKey);
906
1236
  if (this._eventMatchesFilter(event, filter)) {
907
1237
  keysToDelete.push(cacheKey);
908
1238
  }
909
1239
  } catch {
1240
+ indexedKeys.delete(cacheKey);
910
1241
  }
911
1242
  }
912
1243
  for (const key of keysToDelete) {
913
1244
  this._eventCache.delete(key);
1245
+ this._unindexCacheEntry(key);
914
1246
  }
915
1247
  }
916
1248
  /**
917
- * Cache event in memory and persist
1249
+ * Cache event in memory and persist (batched)
918
1250
  * @private
919
1251
  */
920
1252
  async _cacheEvent(event) {
921
1253
  this._cacheEventSync(event);
922
1254
  if (this.persistentStorage) {
923
- try {
924
- let storageKey = event.id;
925
- if (event.kind >= 3e4 && event.kind < 4e4) {
926
- const dTag = event.tags.find((t) => t[0] === "d");
927
- if (dTag && dTag[1]) {
928
- storageKey = dTag[1];
929
- }
1255
+ let storageKey = event.id;
1256
+ if (event.kind >= 3e4 && event.kind < 4e4) {
1257
+ const dTag = event.tags.find((t) => t[0] === "d");
1258
+ if (dTag && dTag[1]) {
1259
+ storageKey = dTag[1];
930
1260
  }
931
- await this.persistentStorage.put(storageKey, event);
932
- } catch (error) {
933
- console.warn("Failed to persist event:", error);
1261
+ }
1262
+ this._persistQueue.set(storageKey, event);
1263
+ if (!this._persistTimer) {
1264
+ this._persistTimer = setTimeout(() => this._flushPersistQueue(), this._persistBatchMs);
934
1265
  }
935
1266
  }
936
1267
  if (this.relays.length === 0) {
@@ -945,6 +1276,26 @@ class NostrClient {
945
1276
  }
946
1277
  }
947
1278
  }
1279
+ /**
1280
+ * Flush batched persistent writes
1281
+ * @private
1282
+ */
1283
+ async _flushPersistQueue() {
1284
+ this._persistTimer = null;
1285
+ if (!this.persistentStorage || this._persistQueue.size === 0) {
1286
+ return;
1287
+ }
1288
+ const toWrite = Array.from(this._persistQueue.entries());
1289
+ this._persistQueue.clear();
1290
+ const writePromises = toWrite.map(async ([key, event]) => {
1291
+ try {
1292
+ await this.persistentStorage.put(key, event);
1293
+ } catch (error) {
1294
+ console.warn(`Failed to persist event ${key}:`, error.message);
1295
+ }
1296
+ });
1297
+ await Promise.all(writePromises);
1298
+ }
948
1299
  /**
949
1300
  * Get cached events matching a filter
950
1301
  * @private
@@ -1032,11 +1383,25 @@ class NostrClient {
1032
1383
  }
1033
1384
  /**
1034
1385
  * Close all connections and subscriptions
1386
+ * @param {Object} options - Close options
1387
+ * @param {boolean} options.flush - Flush pending writes before closing (default: true)
1035
1388
  */
1036
- close() {
1389
+ async close(options = {}) {
1390
+ const shouldFlush = options.flush !== false;
1391
+ if (shouldFlush && this._persistTimer) {
1392
+ clearTimeout(this._persistTimer);
1393
+ await this._flushPersistQueue();
1394
+ }
1037
1395
  if (this.syncService) {
1038
1396
  this.syncService.stop();
1039
1397
  }
1398
+ const authorSub = authorSubscriptions.get(this.publicKey);
1399
+ if (authorSub && authorSub.subscription) {
1400
+ if (authorSub.subscription.close) {
1401
+ authorSub.subscription.close();
1402
+ }
1403
+ authorSubscriptions.delete(this.publicKey);
1404
+ }
1040
1405
  for (const sub of this._subscriptions.values()) {
1041
1406
  if (sub.close) {
1042
1407
  sub.close();
@@ -1047,6 +1412,7 @@ class NostrClient {
1047
1412
  this._subscriptions.clear();
1048
1413
  this.pool.close(this.relays);
1049
1414
  this._eventCache.clear();
1415
+ this._cacheIndex.clear();
1050
1416
  }
1051
1417
  /**
1052
1418
  * Get relay status
@@ -3196,11 +3562,11 @@ class GunDBBackend extends StorageBackend {
3196
3562
  }
3197
3563
  }
3198
3564
  const name = "holosphere";
3199
- const version$1 = "2.0.0-alpha4";
3565
+ const version$1 = "2.0.0-alpha5";
3200
3566
  const description = "Holonic geospatial communication infrastructure combining H3 hexagonal indexing with distributed P2P storage";
3201
3567
  const type = "module";
3202
3568
  const bin = {
3203
- "holosphere-activitypub": "./bin/holosphere-activitypub.js"
3569
+ "holosphere-activitypub": "bin/holosphere-activitypub.js"
3204
3570
  };
3205
3571
  const main = "./dist/cjs/holosphere.cjs";
3206
3572
  const module = "./dist/esm/holosphere.js";
@@ -3941,6 +4307,9 @@ const h3Operations = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.define
3941
4307
  toHolon
3942
4308
  }, Symbol.toStringTag, { value: "Module" }));
3943
4309
  const globalSubscriptions = /* @__PURE__ */ new Map();
4310
+ const singlePathSubscriptions = /* @__PURE__ */ new Map();
4311
+ const pendingQueries = /* @__PURE__ */ new Map();
4312
+ const QUERY_DEDUP_WINDOW = 2e3;
3944
4313
  async function nostrPut(client, path, data, kind2 = 3e4) {
3945
4314
  const dataEvent = {
3946
4315
  kind: kind2,
@@ -3977,6 +4346,27 @@ async function nostrGet(client, path, kind2 = 3e4, options = {}) {
3977
4346
  }
3978
4347
  }
3979
4348
  }
4349
+ const queryKey = `get:${client.publicKey}:${kind2}:${path}:${authors.join(",")}`;
4350
+ const pending = pendingQueries.get(queryKey);
4351
+ if (pending && Date.now() - pending.timestamp < QUERY_DEDUP_WINDOW) {
4352
+ return pending.promise;
4353
+ }
4354
+ const queryPromise = _executeNostrGet(client, path, kind2, authors, timeout, options);
4355
+ pendingQueries.set(queryKey, {
4356
+ promise: queryPromise,
4357
+ timestamp: Date.now()
4358
+ });
4359
+ queryPromise.finally(() => {
4360
+ setTimeout(() => {
4361
+ const current = pendingQueries.get(queryKey);
4362
+ if (current && current.promise === queryPromise) {
4363
+ pendingQueries.delete(queryKey);
4364
+ }
4365
+ }, QUERY_DEDUP_WINDOW);
4366
+ });
4367
+ return queryPromise;
4368
+ }
4369
+ async function _executeNostrGet(client, path, kind2, authors, timeout, options) {
3980
4370
  const filter = {
3981
4371
  kinds: [kind2],
3982
4372
  authors,
@@ -4010,24 +4400,24 @@ async function nostrGetAll(client, pathPrefix, kind2 = 3e4, options = {}) {
4010
4400
  if (!options.skipPersistent && client.persistentGetAll) {
4011
4401
  const persistedEvents = await client.persistentGetAll(pathPrefix);
4012
4402
  if (persistedEvents.length > 0) {
4013
- const byPath2 = /* @__PURE__ */ new Map();
4403
+ const byPath = /* @__PURE__ */ new Map();
4014
4404
  for (const event of persistedEvents) {
4015
4405
  if (!event || !event.tags) continue;
4016
4406
  const dTag = event.tags.find((t) => t[0] === "d");
4017
4407
  if (!dTag || !dTag[1] || !dTag[1].startsWith(pathPrefix)) continue;
4018
4408
  const path = dTag[1];
4019
- const existing = byPath2.get(path);
4409
+ const existing = byPath.get(path);
4020
4410
  if (!existing || event.created_at > existing.created_at) {
4021
4411
  try {
4022
4412
  const data = JSON.parse(event.content);
4023
4413
  if (data._deleted) {
4024
- byPath2.delete(path);
4414
+ byPath.delete(path);
4025
4415
  continue;
4026
4416
  }
4027
4417
  if (options.includeAuthor) {
4028
4418
  data._author = event.pubkey;
4029
4419
  }
4030
- byPath2.set(path, { data, created_at: event.created_at });
4420
+ byPath.set(path, { data, created_at: event.created_at });
4031
4421
  } catch (error) {
4032
4422
  }
4033
4423
  }
@@ -4035,9 +4425,30 @@ async function nostrGetAll(client, pathPrefix, kind2 = 3e4, options = {}) {
4035
4425
  if (client.refreshPrefixInBackground) {
4036
4426
  client.refreshPrefixInBackground(pathPrefix, kind2, { authors, timeout, limit: limit2 });
4037
4427
  }
4038
- return Array.from(byPath2.values()).map((item) => item.data);
4428
+ return Array.from(byPath.values()).map((item) => item.data);
4039
4429
  }
4040
4430
  }
4431
+ const queryKey = `getAll:${client.publicKey}:${kind2}:${pathPrefix}:${authors.join(",")}`;
4432
+ const pending = pendingQueries.get(queryKey);
4433
+ if (pending && Date.now() - pending.timestamp < QUERY_DEDUP_WINDOW) {
4434
+ return pending.promise;
4435
+ }
4436
+ const queryPromise = _executeNostrGetAll(client, pathPrefix, kind2, authors, timeout, limit2, options);
4437
+ pendingQueries.set(queryKey, {
4438
+ promise: queryPromise,
4439
+ timestamp: Date.now()
4440
+ });
4441
+ queryPromise.finally(() => {
4442
+ setTimeout(() => {
4443
+ const current = pendingQueries.get(queryKey);
4444
+ if (current && current.promise === queryPromise) {
4445
+ pendingQueries.delete(queryKey);
4446
+ }
4447
+ }, QUERY_DEDUP_WINDOW);
4448
+ });
4449
+ return queryPromise;
4450
+ }
4451
+ async function _executeNostrGetAll(client, pathPrefix, kind2, authors, timeout, limit2, options) {
4041
4452
  const filter = {
4042
4453
  kinds: [kind2],
4043
4454
  authors,
@@ -4226,7 +4637,29 @@ async function nostrDeleteAll(client, pathPrefix, kind2 = 3e4) {
4226
4637
  }
4227
4638
  function nostrSubscribe(client, path, callback, options = {}) {
4228
4639
  const kind2 = options.kind || 3e4;
4229
- options.includeInitial !== false;
4640
+ const subscriptionKey = `single:${client.publicKey}:${kind2}:${path}`;
4641
+ const existing = singlePathSubscriptions.get(subscriptionKey);
4642
+ if (existing) {
4643
+ existing.callbacks.push(callback);
4644
+ return {
4645
+ unsubscribe: () => {
4646
+ const idx = existing.callbacks.indexOf(callback);
4647
+ if (idx > -1) {
4648
+ existing.callbacks.splice(idx, 1);
4649
+ }
4650
+ if (existing.callbacks.length === 0) {
4651
+ existing.subscription.unsubscribe();
4652
+ singlePathSubscriptions.delete(subscriptionKey);
4653
+ }
4654
+ }
4655
+ };
4656
+ }
4657
+ const callbacks = [callback];
4658
+ const subscriptionInfo = {
4659
+ callbacks,
4660
+ subscription: null
4661
+ };
4662
+ singlePathSubscriptions.set(subscriptionKey, subscriptionInfo);
4230
4663
  const filter = {
4231
4664
  kinds: [kind2],
4232
4665
  authors: [client.publicKey],
@@ -4239,11 +4672,6 @@ function nostrSubscribe(client, path, callback, options = {}) {
4239
4672
  filter,
4240
4673
  (event) => {
4241
4674
  if (event.pubkey !== client.publicKey) {
4242
- console.warn("[nostrSubscribe] Rejecting event from different author:", {
4243
- expected: client.publicKey,
4244
- received: event.pubkey,
4245
- eventId: event.id
4246
- });
4247
4675
  return;
4248
4676
  }
4249
4677
  try {
@@ -4251,7 +4679,13 @@ function nostrSubscribe(client, path, callback, options = {}) {
4251
4679
  if (data._deleted) {
4252
4680
  return;
4253
4681
  }
4254
- callback(data, event);
4682
+ for (const cb of callbacks) {
4683
+ try {
4684
+ cb(data, event);
4685
+ } catch (err) {
4686
+ console.error("Subscription callback error:", err);
4687
+ }
4688
+ }
4255
4689
  } catch (error) {
4256
4690
  console.error("Failed to parse event in subscription:", error);
4257
4691
  }
@@ -4261,7 +4695,19 @@ function nostrSubscribe(client, path, callback, options = {}) {
4261
4695
  }
4262
4696
  }
4263
4697
  );
4264
- return subscription;
4698
+ subscriptionInfo.subscription = subscription;
4699
+ return {
4700
+ unsubscribe: () => {
4701
+ const idx = callbacks.indexOf(callback);
4702
+ if (idx > -1) {
4703
+ callbacks.splice(idx, 1);
4704
+ }
4705
+ if (callbacks.length === 0) {
4706
+ subscription.unsubscribe();
4707
+ singlePathSubscriptions.delete(subscriptionKey);
4708
+ }
4709
+ }
4710
+ };
4265
4711
  }
4266
4712
  async function nostrSubscribeMany(client, pathPrefix, callback, options = {}) {
4267
4713
  const kind2 = options.kind || 3e4;
@@ -5013,7 +5459,7 @@ const sha256 = sha256$1;
5013
5459
  let secp256k1 = null;
5014
5460
  async function loadCrypto() {
5015
5461
  if (!secp256k1) {
5016
- const module2 = await import("./secp256k1-DN4FVXcv.js");
5462
+ const module2 = await import("./secp256k1-69sS9O-P.js");
5017
5463
  secp256k1 = module2.secp256k1;
5018
5464
  }
5019
5465
  return secp256k1;
@@ -16643,7 +17089,7 @@ class ChainManager {
16643
17089
  */
16644
17090
  async _loadEthers() {
16645
17091
  if (!this.ethers) {
16646
- const ethersModule = await import("./index-Cz-PLCUR.js");
17092
+ const ethersModule = await import("./index-Cp3xctq8.js");
16647
17093
  this.ethers = ethersModule;
16648
17094
  }
16649
17095
  return this.ethers;
@@ -25407,7 +25853,7 @@ class ContractDeployer {
25407
25853
  */
25408
25854
  async _loadEthers() {
25409
25855
  if (!this.ethers) {
25410
- this.ethers = await import("./index-Cz-PLCUR.js");
25856
+ this.ethers = await import("./index-Cp3xctq8.js");
25411
25857
  }
25412
25858
  return this.ethers;
25413
25859
  }
@@ -25745,7 +26191,7 @@ class ContractOperations {
25745
26191
  */
25746
26192
  async _loadEthers() {
25747
26193
  if (!this.ethers) {
25748
- this.ethers = await import("./index-Cz-PLCUR.js");
26194
+ this.ethers = await import("./index-Cp3xctq8.js");
25749
26195
  }
25750
26196
  return this.ethers;
25751
26197
  }
@@ -37420,4 +37866,4 @@ export {
37420
37866
  exists as y,
37421
37867
  bytes as z
37422
37868
  };
37423
- //# sourceMappingURL=index-CV0eOogK.js.map
37869
+ //# sourceMappingURL=index-Bbey4GkP.js.map