holosphere 1.1.20 → 2.0.0-alpha0

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 (146) hide show
  1. package/.env.example +36 -0
  2. package/.eslintrc.json +16 -0
  3. package/.prettierrc.json +7 -0
  4. package/README.md +483 -367
  5. package/bin/holosphere-activitypub.js +158 -0
  6. package/cleanup-test-data.js +204 -0
  7. package/examples/demo.html +1333 -0
  8. package/examples/example-bot.js +197 -0
  9. package/package.json +47 -87
  10. package/scripts/check-bundle-size.js +54 -0
  11. package/scripts/check-quest-ids.js +77 -0
  12. package/scripts/import-holons.js +578 -0
  13. package/scripts/publish-to-relay.js +101 -0
  14. package/scripts/read-example.js +186 -0
  15. package/scripts/relay-diagnostic.js +59 -0
  16. package/scripts/relay-example.js +179 -0
  17. package/scripts/resync-to-relay.js +245 -0
  18. package/scripts/revert-import.js +196 -0
  19. package/scripts/test-hybrid-mode.js +108 -0
  20. package/scripts/test-local-storage.js +63 -0
  21. package/scripts/test-nostr-direct.js +55 -0
  22. package/scripts/test-read-data.js +45 -0
  23. package/scripts/test-write-read.js +63 -0
  24. package/scripts/verify-import.js +95 -0
  25. package/scripts/verify-relay-data.js +139 -0
  26. package/src/ai/aggregation.js +319 -0
  27. package/src/ai/breakdown.js +511 -0
  28. package/src/ai/classifier.js +217 -0
  29. package/src/ai/council.js +228 -0
  30. package/src/ai/embeddings.js +279 -0
  31. package/src/ai/federation-ai.js +324 -0
  32. package/src/ai/h3-ai.js +955 -0
  33. package/src/ai/index.js +112 -0
  34. package/src/ai/json-ops.js +225 -0
  35. package/src/ai/llm-service.js +205 -0
  36. package/src/ai/nl-query.js +223 -0
  37. package/src/ai/relationships.js +353 -0
  38. package/src/ai/schema-extractor.js +218 -0
  39. package/src/ai/spatial.js +293 -0
  40. package/src/ai/tts.js +194 -0
  41. package/src/content/social-protocols.js +168 -0
  42. package/src/core/holosphere.js +273 -0
  43. package/src/crypto/secp256k1.js +259 -0
  44. package/src/federation/discovery.js +334 -0
  45. package/src/federation/hologram.js +1042 -0
  46. package/src/federation/registry.js +386 -0
  47. package/src/hierarchical/upcast.js +110 -0
  48. package/src/index.js +2669 -0
  49. package/src/schema/validator.js +91 -0
  50. package/src/spatial/h3-operations.js +110 -0
  51. package/src/storage/backend-factory.js +125 -0
  52. package/src/storage/backend-interface.js +142 -0
  53. package/src/storage/backends/activitypub/server.js +653 -0
  54. package/src/storage/backends/activitypub-backend.js +272 -0
  55. package/src/storage/backends/gundb-backend.js +233 -0
  56. package/src/storage/backends/nostr-backend.js +136 -0
  57. package/src/storage/filesystem-storage-browser.js +41 -0
  58. package/src/storage/filesystem-storage.js +138 -0
  59. package/src/storage/global-tables.js +81 -0
  60. package/src/storage/gun-async.js +281 -0
  61. package/src/storage/gun-wrapper.js +221 -0
  62. package/src/storage/indexeddb-storage.js +122 -0
  63. package/src/storage/key-storage-simple.js +76 -0
  64. package/src/storage/key-storage.js +136 -0
  65. package/src/storage/memory-storage.js +59 -0
  66. package/src/storage/migration.js +338 -0
  67. package/src/storage/nostr-async.js +811 -0
  68. package/src/storage/nostr-client.js +939 -0
  69. package/src/storage/nostr-wrapper.js +211 -0
  70. package/src/storage/outbox-queue.js +208 -0
  71. package/src/storage/persistent-storage.js +109 -0
  72. package/src/storage/sync-service.js +164 -0
  73. package/src/subscriptions/manager.js +142 -0
  74. package/test-ai-real-api.js +202 -0
  75. package/tests/unit/ai/aggregation.test.js +295 -0
  76. package/tests/unit/ai/breakdown.test.js +446 -0
  77. package/tests/unit/ai/classifier.test.js +294 -0
  78. package/tests/unit/ai/council.test.js +262 -0
  79. package/tests/unit/ai/embeddings.test.js +384 -0
  80. package/tests/unit/ai/federation-ai.test.js +344 -0
  81. package/tests/unit/ai/h3-ai.test.js +458 -0
  82. package/tests/unit/ai/index.test.js +304 -0
  83. package/tests/unit/ai/json-ops.test.js +307 -0
  84. package/tests/unit/ai/llm-service.test.js +390 -0
  85. package/tests/unit/ai/nl-query.test.js +383 -0
  86. package/tests/unit/ai/relationships.test.js +311 -0
  87. package/tests/unit/ai/schema-extractor.test.js +384 -0
  88. package/tests/unit/ai/spatial.test.js +279 -0
  89. package/tests/unit/ai/tts.test.js +279 -0
  90. package/tests/unit/content.test.js +332 -0
  91. package/tests/unit/contract/core.test.js +88 -0
  92. package/tests/unit/contract/crypto.test.js +198 -0
  93. package/tests/unit/contract/data.test.js +223 -0
  94. package/tests/unit/contract/federation.test.js +181 -0
  95. package/tests/unit/contract/hierarchical.test.js +113 -0
  96. package/tests/unit/contract/schema.test.js +114 -0
  97. package/tests/unit/contract/social.test.js +217 -0
  98. package/tests/unit/contract/spatial.test.js +110 -0
  99. package/tests/unit/contract/subscriptions.test.js +128 -0
  100. package/tests/unit/contract/utils.test.js +159 -0
  101. package/tests/unit/core.test.js +152 -0
  102. package/tests/unit/crypto.test.js +328 -0
  103. package/tests/unit/federation.test.js +234 -0
  104. package/tests/unit/gun-async.test.js +252 -0
  105. package/tests/unit/hierarchical.test.js +399 -0
  106. package/tests/unit/integration/scenario-01-geographic-storage.test.js +74 -0
  107. package/tests/unit/integration/scenario-02-federation.test.js +76 -0
  108. package/tests/unit/integration/scenario-03-subscriptions.test.js +102 -0
  109. package/tests/unit/integration/scenario-04-validation.test.js +129 -0
  110. package/tests/unit/integration/scenario-05-hierarchy.test.js +125 -0
  111. package/tests/unit/integration/scenario-06-social.test.js +135 -0
  112. package/tests/unit/integration/scenario-07-persistence.test.js +130 -0
  113. package/tests/unit/integration/scenario-08-authorization.test.js +161 -0
  114. package/tests/unit/integration/scenario-09-cross-dimensional.test.js +139 -0
  115. package/tests/unit/integration/scenario-10-cross-holosphere-capabilities.test.js +357 -0
  116. package/tests/unit/integration/scenario-11-cross-holosphere-federation.test.js +410 -0
  117. package/tests/unit/integration/scenario-12-capability-federated-read.test.js +719 -0
  118. package/tests/unit/performance/benchmark.test.js +85 -0
  119. package/tests/unit/schema.test.js +213 -0
  120. package/tests/unit/spatial.test.js +158 -0
  121. package/tests/unit/storage.test.js +195 -0
  122. package/tests/unit/subscriptions.test.js +328 -0
  123. package/tests/unit/test-data-permanence-debug.js +197 -0
  124. package/tests/unit/test-data-permanence.js +340 -0
  125. package/tests/unit/test-key-persistence-fixed.js +148 -0
  126. package/tests/unit/test-key-persistence.js +172 -0
  127. package/tests/unit/test-relay-permanence.js +376 -0
  128. package/tests/unit/test-second-node.js +95 -0
  129. package/tests/unit/test-simple-write.js +89 -0
  130. package/vite.config.js +49 -0
  131. package/vitest.config.js +20 -0
  132. package/FEDERATION.md +0 -213
  133. package/compute.js +0 -298
  134. package/content.js +0 -980
  135. package/federation.js +0 -1234
  136. package/global.js +0 -736
  137. package/hexlib.js +0 -335
  138. package/hologram.js +0 -183
  139. package/holosphere-bundle.esm.js +0 -33256
  140. package/holosphere-bundle.js +0 -33287
  141. package/holosphere-bundle.min.js +0 -39
  142. package/holosphere.d.ts +0 -601
  143. package/holosphere.js +0 -719
  144. package/node.js +0 -246
  145. package/schema.js +0 -139
  146. package/utils.js +0 -302
@@ -0,0 +1,939 @@
1
+ /**
2
+ * Nostr Client - SimplePool wrapper for relay management
3
+ * Provides connection pooling and relay management
4
+ */
5
+
6
+ import { SimplePool, finalizeEvent, getPublicKey } from 'nostr-tools';
7
+ import { OutboxQueue } from './outbox-queue.js';
8
+ import { SyncService } from './sync-service.js';
9
+
10
+ // Lazy-load WebSocket polyfill for Node.js environment
11
+ let webSocketPolyfillPromise = null;
12
+ function ensureWebSocket() {
13
+ if (typeof globalThis.WebSocket !== 'undefined') {
14
+ return Promise.resolve();
15
+ }
16
+
17
+ if (!webSocketPolyfillPromise) {
18
+ webSocketPolyfillPromise = (async () => {
19
+ try {
20
+ const { default: WebSocket } = await import('ws');
21
+ globalThis.WebSocket = WebSocket;
22
+ } catch (e) {
23
+ // WebSocket polyfill not available, will use mock pool
24
+ }
25
+ })();
26
+ }
27
+
28
+ return webSocketPolyfillPromise;
29
+ }
30
+ import { createPersistentStorage } from './persistent-storage.js';
31
+
32
+ /**
33
+ * NostrClient - Manages connections to Nostr relays
34
+ */
35
+ export class NostrClient {
36
+ /**
37
+ * @param {Object} config - Configuration options
38
+ * @param {string[]} config.relays - Array of relay URLs
39
+ * @param {string} config.privateKey - Private key for signing (hex format)
40
+ * @param {boolean} config.enableReconnect - Auto-reconnect on disconnect (default: true)
41
+ * @param {boolean} config.enablePing - Auto-ping relays (default: true)
42
+ * @param {string} config.appName - Application name for storage namespace
43
+ * @param {boolean} config.persistence - Enable persistent storage (default: true)
44
+ */
45
+ constructor(config = {}) {
46
+ if (config.relays && !Array.isArray(config.relays)) {
47
+ throw new Error('Relays must be an array');
48
+ }
49
+
50
+ this.relays = config.relays || [];
51
+
52
+ // Generate or use provided private key
53
+ // IMPORTANT: If you want keys to persist across restarts, you MUST provide
54
+ // a privateKey in config. Otherwise, a new key is generated each time.
55
+ this.privateKey = config.privateKey || this._generatePrivateKey();
56
+ this.publicKey = getPublicKey(this.privateKey);
57
+ this.config = config;
58
+
59
+ this._subscriptions = new Map();
60
+ this._eventCache = new Map(); // In-memory cache for recent events
61
+ this.persistentStorage = null;
62
+
63
+ // Initialize pool and storage asynchronously
64
+ this._initReady = this._initialize();
65
+ }
66
+
67
+ /**
68
+ * Initialize WebSocket polyfill and pool
69
+ * @private
70
+ */
71
+ async _initialize() {
72
+ // Ensure WebSocket is available before initializing pool
73
+ await ensureWebSocket();
74
+
75
+ // Initialize SimplePool with options (only if relays exist)
76
+ if (this.relays.length > 0) {
77
+ this.pool = new SimplePool({
78
+ enableReconnect: this.config.enableReconnect !== false,
79
+ enablePing: this.config.enablePing !== false,
80
+ });
81
+ } else {
82
+ // Mock pool for testing - returns mock promise that resolves immediately
83
+ this.pool = {
84
+ publish: (relays, event) => [Promise.resolve()],
85
+ querySync: (relays, filter, options) => Promise.resolve([]),
86
+ subscribeMany: (relays, filters, opts) => ({ close: () => {} }),
87
+ close: (relays) => {},
88
+ };
89
+ }
90
+
91
+ // Initialize persistent storage
92
+ await this._initPersistentStorage();
93
+ }
94
+
95
+ /**
96
+ * Initialize persistent storage
97
+ * @private
98
+ */
99
+ async _initPersistentStorage() {
100
+ // Only enable persistence if explicitly requested or radisk is true
101
+ if (this.config.persistence !== false && (this.config.radisk || this.config.appName)) {
102
+ try {
103
+ const namespace = this.config.appName || 'holosphere_default';
104
+ this.persistentStorage = await createPersistentStorage(namespace, {
105
+ dataDir: this.config.dataDir
106
+ });
107
+
108
+ // Load cached events from persistent storage
109
+ await this._loadFromPersistentStorage();
110
+
111
+ // Initialize outbox queue for guaranteed delivery
112
+ this.outboxQueue = new OutboxQueue(this.persistentStorage, {
113
+ maxRetries: this.config.maxRetries || 5,
114
+ baseDelay: this.config.retryBaseDelay || 1000,
115
+ maxDelay: this.config.retryMaxDelay || 60000,
116
+ failedTTL: this.config.failedTTL || 24 * 60 * 60 * 1000, // 24 hours
117
+ });
118
+
119
+ // Start background sync service
120
+ if (this.config.backgroundSync !== false) {
121
+ this.syncService = new SyncService(this, {
122
+ interval: this.config.syncInterval || 10000, // 10 seconds
123
+ autoStart: true,
124
+ });
125
+ }
126
+ } catch (error) {
127
+ console.warn('Failed to initialize persistent storage:', error);
128
+ // Continue without persistence
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Load events from persistent storage into cache
135
+ * @private
136
+ */
137
+ async _loadFromPersistentStorage() {
138
+ if (!this.persistentStorage) return;
139
+
140
+ try {
141
+ // Load all events (excluding outbox queue entries)
142
+ const events = await this.persistentStorage.getAll('');
143
+ for (const event of events) {
144
+ // Skip outbox queue entries
145
+ if (event && event.id && !event.status) {
146
+ // Add to cache
147
+ this._cacheEventSync(event);
148
+ }
149
+ }
150
+ } catch (error) {
151
+ console.warn('Failed to load from persistent storage:', error);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get a single event from persistent storage by path (d-tag)
157
+ * @param {string} path - The d-tag path to look up
158
+ * @returns {Promise<Object|null>} The event or null if not found
159
+ */
160
+ async persistentGet(path) {
161
+ await this._initReady;
162
+ if (!this.persistentStorage) return null;
163
+ try {
164
+ const event = await this.persistentStorage.get(path);
165
+ return event || null;
166
+ } catch (error) {
167
+ console.warn('[nostr] Persistent storage read failed:', error);
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Get all events from persistent storage matching a prefix
174
+ * @param {string} prefix - The path prefix to match
175
+ * @returns {Promise<Array>} Array of events
176
+ */
177
+ async persistentGetAll(prefix) {
178
+ await this._initReady;
179
+ if (!this.persistentStorage) return [];
180
+ try {
181
+ const events = await this.persistentStorage.getAll(prefix);
182
+ // Filter out outbox queue entries and null values
183
+ return events.filter(e => e && e.id && !e.status);
184
+ } catch (error) {
185
+ console.warn('[nostr] Persistent storage read failed:', error);
186
+ return [];
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Generate random private key
192
+ * @private
193
+ */
194
+ _generatePrivateKey() {
195
+ const bytes = new Uint8Array(32);
196
+ if (typeof window !== 'undefined' && window.crypto) {
197
+ window.crypto.getRandomValues(bytes);
198
+ } else {
199
+ // Node.js - use dynamic import
200
+ try {
201
+ // Use crypto.randomFillSync if available
202
+ const crypto = globalThis.crypto || global.crypto;
203
+ if (crypto && crypto.getRandomValues) {
204
+ crypto.getRandomValues(bytes);
205
+ } else {
206
+ // Fallback to Math.random (less secure, but works in testing)
207
+ for (let i = 0; i < 32; i++) {
208
+ bytes[i] = Math.floor(Math.random() * 256);
209
+ }
210
+ }
211
+ } catch (e) {
212
+ // Fallback to Math.random
213
+ for (let i = 0; i < 32; i++) {
214
+ bytes[i] = Math.floor(Math.random() * 256);
215
+ }
216
+ }
217
+ }
218
+ return Array.from(bytes)
219
+ .map(b => b.toString(16).padStart(2, '0'))
220
+ .join('');
221
+ }
222
+
223
+ /**
224
+ * Publish event to relays
225
+ * @param {Object} event - Unsigned event object
226
+ * @param {Object} options - Publish options
227
+ * @param {boolean} options.waitForRelays - Wait for relay confirmation (default: false for speed)
228
+ * @returns {Promise<Object>} Signed event with relay publish results
229
+ */
230
+ async publish(event, options = {}) {
231
+ // Ensure initialization is complete
232
+ await this._initReady;
233
+
234
+ const waitForRelays = options.waitForRelays || false;
235
+
236
+ // Sign the event
237
+ const signedEvent = finalizeEvent(event, this.privateKey);
238
+
239
+ // 1. Cache the event locally first (this makes reads instant)
240
+ await this._cacheEvent(signedEvent);
241
+
242
+ // 2. Enqueue for guaranteed delivery (if outbox queue available)
243
+ if (this.outboxQueue) {
244
+ await this.outboxQueue.enqueue(signedEvent, this.relays);
245
+ }
246
+
247
+ // 3. Publish to relays
248
+ if (waitForRelays) {
249
+ // Wait for relay confirmation if explicitly requested
250
+ const deliveryResult = await this._attemptDelivery(signedEvent, this.relays);
251
+
252
+ return {
253
+ event: signedEvent,
254
+ results: deliveryResult.results,
255
+ queued: !!this.outboxQueue,
256
+ };
257
+ } else {
258
+ // 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);
261
+ });
262
+
263
+ // Return immediately (data is in local cache and queued for delivery)
264
+ return {
265
+ event: signedEvent,
266
+ results: [],
267
+ queued: !!this.outboxQueue,
268
+ };
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Attempt to deliver event to relays
274
+ * Updates outbox queue with delivery status
275
+ * @param {Object} event - Signed event to deliver
276
+ * @param {string[]} relays - Target relay URLs
277
+ * @returns {Promise<Object>} Delivery result with successful/failed relays
278
+ */
279
+ async _attemptDelivery(event, relays) {
280
+ const results = await Promise.allSettled(
281
+ this.pool.publish(relays, event)
282
+ );
283
+
284
+ const successful = [];
285
+ const failed = [];
286
+ const formattedResults = [];
287
+
288
+ results.forEach((r, i) => {
289
+ formattedResults.push({
290
+ relay: relays[i],
291
+ status: r.status,
292
+ value: r.value,
293
+ reason: r.reason,
294
+ });
295
+
296
+ if (r.status === 'fulfilled') {
297
+ successful.push(relays[i]);
298
+ } else {
299
+ failed.push(relays[i]);
300
+ }
301
+ });
302
+
303
+ // Update outbox queue with delivery status
304
+ if (this.outboxQueue) {
305
+ await this.outboxQueue.markSent(event.id, successful);
306
+ }
307
+
308
+ // Log failures for debugging
309
+ if (failed.length > 0) {
310
+ const reasons = results
311
+ .filter(r => r.status === 'rejected')
312
+ .map(f => f.reason?.message || f.reason || 'unknown')
313
+ .join(', ');
314
+ console.warn(`[nostr] ${failed.length}/${relays.length} relays failed for ${event.id.slice(0, 8)}: ${reasons}`);
315
+ }
316
+
317
+ return { successful, failed, results: formattedResults };
318
+ }
319
+
320
+ /**
321
+ * Query events from relays
322
+ * @param {Object} filter - Nostr filter object
323
+ * @param {Object} options - Query options
324
+ * @param {number} options.timeout - Query timeout in ms (default: 30000, set to 0 for no timeout)
325
+ * @param {boolean} options.localFirst - Return local cache immediately, refresh in background (default: true)
326
+ * @returns {Promise<Array>} Array of events
327
+ */
328
+ async query(filter, options = {}) {
329
+ // Ensure initialization is complete
330
+ await this._initReady;
331
+
332
+ const timeout = options.timeout !== undefined ? options.timeout : 30000;
333
+ const localFirst = options.localFirst !== false; // Default to true for speed
334
+
335
+ // If no relays, query from cache only
336
+ if (this.relays.length === 0) {
337
+ const matchingEvents = this._getMatchingCachedEvents(filter);
338
+ return matchingEvents;
339
+ }
340
+
341
+ // Check d-tag cache first for single-item queries (most common case)
342
+ // This ensures recently written data is returned immediately
343
+ if (filter['#d'] && filter['#d'].length === 1 && filter.kinds && filter.kinds.length === 1) {
344
+ const dTagKey = `d:${filter.kinds[0]}:${filter['#d'][0]}`;
345
+ const dTagCached = this._eventCache.get(dTagKey);
346
+ if (dTagCached && Date.now() - dTagCached.timestamp < 5000) {
347
+ return dTagCached.events;
348
+ }
349
+ }
350
+
351
+ const cacheKey = JSON.stringify(filter);
352
+ const cached = this._eventCache.get(cacheKey);
353
+
354
+ // Return fresh cache immediately
355
+ if (cached && Date.now() - cached.timestamp < 5000) {
356
+ return cached.events;
357
+ }
358
+
359
+ // If we have stale cache and localFirst is enabled, return it immediately
360
+ // and refresh in background
361
+ if (localFirst && cached) {
362
+ // Refresh cache in background
363
+ this._refreshCacheInBackground(filter, cacheKey, timeout);
364
+ return cached.events;
365
+ }
366
+
367
+ // No cache available or localFirst disabled - must query relays
368
+ return this._queryRelaysAndCache(filter, cacheKey, timeout);
369
+ }
370
+
371
+ /**
372
+ * Query relays and update cache
373
+ * @private
374
+ */
375
+ 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));
381
+ }
382
+
383
+ // Cache results
384
+ this._eventCache.set(cacheKey, {
385
+ events,
386
+ timestamp: Date.now(),
387
+ });
388
+
389
+ // Limit cache size
390
+ if (this._eventCache.size > 100) {
391
+ const firstKey = this._eventCache.keys().next().value;
392
+ this._eventCache.delete(firstKey);
393
+ }
394
+
395
+ return events;
396
+ }
397
+
398
+ /**
399
+ * Refresh cache in background (fire and forget)
400
+ * @private
401
+ */
402
+ _refreshCacheInBackground(filter, cacheKey, timeout) {
403
+ this._queryRelaysAndCache(filter, cacheKey, timeout).catch(err => {
404
+ // Silently ignore background refresh errors - we have stale cache as fallback
405
+ console.debug('[nostr] Background cache refresh failed:', err.message);
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Refresh a single path from relays in the background
411
+ * Used for persistent-first reads to update local data
412
+ * @param {string} path - The d-tag path to refresh
413
+ * @param {number} kind - Event kind (default: 30000)
414
+ * @param {Object} options - Query options
415
+ */
416
+ refreshPathInBackground(path, kind = 30000, options = {}) {
417
+ this._doBackgroundPathRefresh(path, kind, options).catch(err => {
418
+ console.debug('[nostr] Background path refresh failed:', err.message);
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Internal method to refresh a path from relays
424
+ * @private
425
+ */
426
+ async _doBackgroundPathRefresh(path, kind, options) {
427
+ if (this.relays.length === 0) return;
428
+
429
+ const filter = {
430
+ kinds: [kind],
431
+ authors: options.authors || [this.publicKey],
432
+ '#d': [path],
433
+ limit: 1,
434
+ };
435
+
436
+ const timeout = options.timeout || 30000;
437
+ const events = await this.pool.querySync(this.relays, filter, { timeout });
438
+
439
+ // Filter by author (relays may not respect filter)
440
+ const authorFiltered = events.filter(e =>
441
+ (options.authors || [this.publicKey]).includes(e.pubkey)
442
+ );
443
+
444
+ if (authorFiltered.length > 0) {
445
+ // Update cache and persistent storage
446
+ await this._cacheEvent(authorFiltered[0]);
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Refresh all paths with a prefix from relays in the background
452
+ * Used for persistent-first collection reads
453
+ * @param {string} prefix - The d-tag prefix to refresh
454
+ * @param {number} kind - Event kind (default: 30000)
455
+ * @param {Object} options - Query options
456
+ */
457
+ refreshPrefixInBackground(prefix, kind = 30000, options = {}) {
458
+ this._doBackgroundPrefixRefresh(prefix, kind, options).catch(err => {
459
+ console.debug('[nostr] Background prefix refresh failed:', err.message);
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Internal method to refresh a prefix from relays
465
+ * @private
466
+ */
467
+ async _doBackgroundPrefixRefresh(prefix, kind, options) {
468
+ if (this.relays.length === 0) return;
469
+
470
+ // Query with wildcard-ish filter (relays handle d-tag prefix matching)
471
+ const filter = {
472
+ kinds: [kind],
473
+ authors: options.authors || [this.publicKey],
474
+ limit: options.limit || 1000,
475
+ };
476
+
477
+ const timeout = options.timeout || 30000;
478
+ let events = await this.pool.querySync(this.relays, filter, { timeout });
479
+
480
+ // Filter by author
481
+ events = events.filter(e =>
482
+ (options.authors || [this.publicKey]).includes(e.pubkey)
483
+ );
484
+
485
+ // Filter by d-tag prefix (client-side since relays don't support prefix matching)
486
+ events = events.filter(e => {
487
+ const dTag = e.tags.find(t => t[0] === 'd');
488
+ return dTag && dTag[1] && dTag[1].startsWith(prefix);
489
+ });
490
+
491
+ // Update cache and persistent storage for each event
492
+ for (const event of events) {
493
+ await this._cacheEvent(event);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Hybrid query: Check local cache first, then query relays for missing data
499
+ * Merges local and relay results, preferring newer events
500
+ * @param {Object} filter - Nostr filter object
501
+ * @param {Object} options - Query options
502
+ * @param {number} options.timeout - Query timeout in ms (default: 30000)
503
+ * @returns {Promise<Array>} Array of events (merged from local + relay)
504
+ */
505
+ async queryHybrid(filter, options = {}) {
506
+ // Ensure initialization is complete
507
+ await this._initReady;
508
+
509
+ const timeout = options.timeout !== undefined ? options.timeout : 30000;
510
+
511
+ // Step 1: Get events from local cache
512
+ const localEvents = this._getMatchingCachedEvents(filter);
513
+
514
+ // If no relays configured, return local only
515
+ if (this.relays.length === 0) {
516
+ return localEvents;
517
+ }
518
+
519
+ // Step 2: Query relays
520
+ let relayEvents = [];
521
+ try {
522
+ relayEvents = await this.pool.querySync(this.relays, filter, { timeout });
523
+
524
+ // Filter out events from other authors
525
+ if (filter.authors && filter.authors.length > 0) {
526
+ relayEvents = relayEvents.filter(event => filter.authors.includes(event.pubkey));
527
+ }
528
+ } catch (error) {
529
+ console.warn('Relay query failed, using local cache only:', error.message);
530
+ }
531
+
532
+ // Step 3: Merge results (prefer newer events for replaceable events)
533
+ const merged = new Map();
534
+
535
+ // Add local events first
536
+ for (const event of localEvents) {
537
+ const key = this._getEventKey(event);
538
+ merged.set(key, event);
539
+ }
540
+
541
+ // Add or update with relay events (relay events are authoritative)
542
+ for (const event of relayEvents) {
543
+ const key = this._getEventKey(event);
544
+ const existing = merged.get(key);
545
+
546
+ // For replaceable events, prefer newer
547
+ if (event.kind >= 30000 && event.kind < 40000 && existing) {
548
+ if (event.created_at >= existing.created_at) {
549
+ merged.set(key, event);
550
+ }
551
+ } else {
552
+ // For non-replaceable or if no existing, just add
553
+ merged.set(key, event);
554
+ }
555
+ }
556
+
557
+ const result = Array.from(merged.values());
558
+
559
+ // Cache the merged results
560
+ const cacheKey = JSON.stringify(filter);
561
+ this._eventCache.set(cacheKey, {
562
+ events: result,
563
+ timestamp: Date.now(),
564
+ });
565
+
566
+ return result;
567
+ }
568
+
569
+ /**
570
+ * Get unique key for event (for merging)
571
+ * @private
572
+ */
573
+ _getEventKey(event) {
574
+ // For replaceable events with d-tag, use kind:dtag
575
+ if (event.kind >= 30000 && event.kind < 40000) {
576
+ const dTag = event.tags.find(t => t[0] === 'd');
577
+ if (dTag && dTag[1]) {
578
+ return `${event.kind}:${dTag[1]}`;
579
+ }
580
+ }
581
+ // Otherwise use event id
582
+ return event.id;
583
+ }
584
+
585
+ /**
586
+ * Subscribe to events
587
+ * @param {Object} filter - Nostr filter object
588
+ * @param {Function} onEvent - Callback for each event
589
+ * @param {Object} options - Subscription options
590
+ * @returns {Object} Subscription object with unsubscribe method
591
+ */
592
+ async subscribe(filter, onEvent, options = {}) {
593
+ // Ensure initialization is complete
594
+ await this._initReady;
595
+
596
+ const subId = `sub-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
597
+
598
+ // If no relays, check cache for matching events and trigger callbacks
599
+ if (this.relays.length === 0) {
600
+ // Trigger with cached events that match the filter
601
+ const matchingEvents = this._getMatchingCachedEvents(filter);
602
+
603
+ // Create a mock subscription that watches for new cache entries
604
+ const mockSub = {
605
+ filter,
606
+ onEvent,
607
+ active: true
608
+ };
609
+
610
+ this._subscriptions.set(subId, mockSub);
611
+
612
+ // Trigger callbacks asynchronously to mimic real subscription behavior
613
+ // Check active flag to avoid triggering if unsubscribed immediately
614
+ setTimeout(() => {
615
+ if (mockSub.active) {
616
+ matchingEvents.forEach(event => onEvent(event));
617
+ if (options.onEOSE) options.onEOSE();
618
+ }
619
+ }, 10);
620
+
621
+ return {
622
+ id: subId,
623
+ unsubscribe: () => {
624
+ mockSub.active = false;
625
+ this._subscriptions.delete(subId);
626
+ // Clear the subscription's onEvent to prevent any further calls
627
+ mockSub.onEvent = () => {};
628
+ // Note: active flag is checked before triggering callbacks
629
+ },
630
+ };
631
+ }
632
+
633
+ const sub = this.pool.subscribeMany(
634
+ this.relays,
635
+ [filter],
636
+ {
637
+ onevent: (event) => {
638
+ // CRITICAL: Filter out events from other authors immediately
639
+ // Many relays don't respect the authors filter, so we must do it client-side
640
+ if (filter.authors && filter.authors.length > 0) {
641
+ if (!filter.authors.includes(event.pubkey)) {
642
+ // Silently reject events from other authors
643
+ return;
644
+ }
645
+ }
646
+
647
+ this._cacheEvent(event);
648
+ onEvent(event);
649
+ },
650
+ oneose: () => {
651
+ if (options.onEOSE) options.onEOSE();
652
+ },
653
+ }
654
+ );
655
+
656
+ this._subscriptions.set(subId, sub);
657
+
658
+ return {
659
+ id: subId,
660
+ unsubscribe: () => {
661
+ if (sub.close) sub.close();
662
+ this._subscriptions.delete(subId);
663
+ },
664
+ };
665
+ }
666
+
667
+ /**
668
+ * Cache event in memory (synchronous version for loading)
669
+ * @private
670
+ */
671
+ _cacheEventSync(event) {
672
+ const key = event.id;
673
+ this._eventCache.set(key, {
674
+ events: [event],
675
+ timestamp: Date.now(),
676
+ });
677
+
678
+ // For replaceable events (kind 30000-39999), also cache by d-tag
679
+ if (event.kind >= 30000 && event.kind < 40000) {
680
+ const dTag = event.tags.find(t => t[0] === 'd');
681
+ if (dTag && dTag[1]) {
682
+ const dTagKey = `d:${event.kind}:${dTag[1]}`;
683
+ const existing = this._eventCache.get(dTagKey);
684
+
685
+ // Replace if newer, or if same timestamp but different event ID (for events in same second)
686
+ const shouldReplace = !existing || !existing.events[0] ||
687
+ event.created_at > existing.events[0].created_at ||
688
+ (event.created_at === existing.events[0].created_at && event.id !== existing.events[0].id);
689
+
690
+ if (shouldReplace) {
691
+ this._eventCache.set(dTagKey, {
692
+ events: [event],
693
+ timestamp: Date.now(),
694
+ });
695
+
696
+ // Remove old event from cache if it exists
697
+ if (existing && existing.events[0]) {
698
+ this._eventCache.delete(existing.events[0].id);
699
+ }
700
+
701
+ // Invalidate any query caches that might contain stale data for this d-tag
702
+ // Query caches are JSON-stringified filters, so we look for any that match this event
703
+ this._invalidateQueryCachesForEvent(event);
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Invalidate query caches that might be affected by a new event
711
+ * @private
712
+ */
713
+ _invalidateQueryCachesForEvent(event) {
714
+ // Find and remove query cache entries that could match this event
715
+ // Query cache keys are JSON-stringified filters
716
+ const keysToDelete = [];
717
+
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;
721
+
722
+ try {
723
+ const filter = JSON.parse(cacheKey);
724
+ // Check if this event would match the filter
725
+ if (this._eventMatchesFilter(event, filter)) {
726
+ keysToDelete.push(cacheKey);
727
+ }
728
+ } catch {
729
+ // Not a JSON key, skip
730
+ }
731
+ }
732
+
733
+ for (const key of keysToDelete) {
734
+ this._eventCache.delete(key);
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Cache event in memory and persist
740
+ * @private
741
+ */
742
+ async _cacheEvent(event) {
743
+ // Cache in memory
744
+ this._cacheEventSync(event);
745
+
746
+ // Persist to storage
747
+ 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
+ }
756
+ }
757
+
758
+ await this.persistentStorage.put(storageKey, event);
759
+ } catch (error) {
760
+ console.warn('Failed to persist event:', error);
761
+ }
762
+ }
763
+
764
+ // Trigger any active subscriptions that match this event
765
+ if (this.relays.length === 0) {
766
+ for (const sub of this._subscriptions.values()) {
767
+ if (sub.active && this._eventMatchesFilter(event, sub.filter)) {
768
+ // Trigger callback asynchronously, but check active status again
769
+ setTimeout(() => {
770
+ if (sub.active) {
771
+ sub.onEvent(event);
772
+ }
773
+ }, 10);
774
+ }
775
+ }
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Get cached events matching a filter
781
+ * @private
782
+ */
783
+ _getMatchingCachedEvents(filter) {
784
+ const matchingEvents = [];
785
+ const seenDTags = new Map(); // For deduplicating replaceable events
786
+
787
+ for (const cached of this._eventCache.values()) {
788
+ for (const event of (cached.events || [])) {
789
+ if (this._eventMatchesFilter(event, filter)) {
790
+ // For replaceable events (kind 30000-39999), keep only the newest
791
+ if (event.kind >= 30000 && event.kind < 40000) {
792
+ const dTag = event.tags.find(t => t[0] === 'd');
793
+ if (dTag && dTag[1]) {
794
+ const dTagKey = `${event.kind}:${dTag[1]}`;
795
+ const existing = seenDTags.get(dTagKey);
796
+
797
+ // Replace if newer, or if same timestamp with _deleted flag (tombstones)
798
+ const shouldReplace = !existing ||
799
+ event.created_at > existing.created_at ||
800
+ (event.created_at === existing.created_at &&
801
+ JSON.parse(event.content)?._deleted &&
802
+ !JSON.parse(existing.content)?._deleted);
803
+
804
+ if (shouldReplace) {
805
+ // Remove old event if exists
806
+ if (existing) {
807
+ const idx = matchingEvents.indexOf(existing);
808
+ if (idx > -1) matchingEvents.splice(idx, 1);
809
+ }
810
+ seenDTags.set(dTagKey, event);
811
+ matchingEvents.push(event);
812
+ }
813
+ continue;
814
+ }
815
+ }
816
+
817
+ // Regular events or replaceable events without d-tag
818
+ matchingEvents.push(event);
819
+ }
820
+ }
821
+ }
822
+
823
+ return matchingEvents;
824
+ }
825
+
826
+ /**
827
+ * Check if event matches filter
828
+ * @private
829
+ */
830
+ _eventMatchesFilter(event, filter) {
831
+ // Check kinds
832
+ if (filter.kinds && !filter.kinds.includes(event.kind)) {
833
+ return false;
834
+ }
835
+
836
+ // Check IDs
837
+ if (filter.ids && !filter.ids.includes(event.id)) {
838
+ return false;
839
+ }
840
+
841
+ // Check authors
842
+ if (filter.authors && !filter.authors.includes(event.pubkey)) {
843
+ return false;
844
+ }
845
+
846
+ // Check #d tag (for replaceable events)
847
+ if (filter['#d']) {
848
+ const dTag = event.tags.find(t => t[0] === 'd');
849
+ if (!dTag || !filter['#d'].includes(dTag[1])) {
850
+ return false;
851
+ }
852
+ }
853
+
854
+ // Check since/until
855
+ if (filter.since && event.created_at < filter.since) {
856
+ return false;
857
+ }
858
+ if (filter.until && event.created_at > filter.until) {
859
+ return false;
860
+ }
861
+
862
+ return true;
863
+ }
864
+
865
+ /**
866
+ * Get single event by ID
867
+ * @param {string} eventId - Event ID (hex)
868
+ * @returns {Promise<Object|null>} Event or null
869
+ */
870
+ async getEvent(eventId) {
871
+ const events = await this.query({ ids: [eventId] });
872
+ return events.length > 0 ? events[0] : null;
873
+ }
874
+
875
+ /**
876
+ * Clear the event cache (or specific entries matching a pattern)
877
+ * @param {string} [pattern] - Optional d-tag pattern to match (clears all if not provided)
878
+ */
879
+ clearCache(pattern = null) {
880
+ if (!pattern) {
881
+ this._eventCache.clear();
882
+ return;
883
+ }
884
+
885
+ // Clear entries matching the pattern
886
+ for (const key of this._eventCache.keys()) {
887
+ if (key.includes(pattern)) {
888
+ this._eventCache.delete(key);
889
+ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Close all connections and subscriptions
895
+ */
896
+ close() {
897
+ // Stop background sync service
898
+ if (this.syncService) {
899
+ this.syncService.stop();
900
+ }
901
+
902
+ // Close all subscriptions
903
+ for (const sub of this._subscriptions.values()) {
904
+ if (sub.close) {
905
+ sub.close();
906
+ } else if (sub.active !== undefined) {
907
+ // Mock subscription
908
+ sub.active = false;
909
+ }
910
+ }
911
+ this._subscriptions.clear();
912
+
913
+ // Close pool
914
+ this.pool.close(this.relays);
915
+
916
+ // Clear cache
917
+ this._eventCache.clear();
918
+ }
919
+
920
+ /**
921
+ * Get relay status
922
+ * @returns {Array} Relay connection statuses
923
+ */
924
+ getRelayStatus() {
925
+ return this.relays.map(relay => ({
926
+ url: relay,
927
+ connected: true, // SimplePool manages this internally
928
+ }));
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Create NostrClient instance
934
+ * @param {Object} config - Configuration options
935
+ * @returns {NostrClient} Client instance
936
+ */
937
+ export function createClient(config) {
938
+ return new NostrClient(config);
939
+ }