holosphere 1.1.19 → 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 +476 -531
  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 -1022
  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 -34549
  140. package/holosphere-bundle.js +0 -34580
  141. package/holosphere-bundle.min.js +0 -49
  142. package/holosphere.d.ts +0 -604
  143. package/holosphere.js +0 -739
  144. package/node.js +0 -246
  145. package/schema.js +0 -139
  146. package/utils.js +0 -302
@@ -0,0 +1,811 @@
1
+ /**
2
+ * Nostr Async Utilities
3
+ * Provides Promise-based wrappers and async patterns for Nostr operations
4
+ */
5
+
6
+ /**
7
+ * Global subscription manager to prevent duplicate subscriptions
8
+ * Maps: pathPrefix -> subscription object
9
+ */
10
+ const globalSubscriptions = new Map();
11
+
12
+ /**
13
+ * Write data as Nostr event (parameterized replaceable event)
14
+ * @param {Object} client - NostrClient instance
15
+ * @param {string} path - Path identifier (encoded in d-tag)
16
+ * @param {Object} data - Data to store
17
+ * @param {number} kind - Event kind (default: 30000 for parameterized replaceable)
18
+ * @returns {Promise<Object>} Published event result
19
+ */
20
+ export async function nostrPut(client, path, data, kind = 30000) {
21
+ const dataEvent = {
22
+ kind,
23
+ created_at: Math.floor(Date.now() / 1000),
24
+ tags: [
25
+ ['d', path], // d-tag for parameterized replaceable events
26
+ ],
27
+ content: JSON.stringify(data),
28
+ };
29
+
30
+ const result = await client.publish(dataEvent);
31
+ return result;
32
+ }
33
+
34
+ /**
35
+ * Read data from Nostr (query by d-tag)
36
+ * LOCAL-FIRST: Checks persistent storage first, never blocks on network
37
+ * @param {Object} client - NostrClient instance
38
+ * @param {string} path - Path identifier
39
+ * @param {number} kind - Event kind (default: 30000)
40
+ * @param {Object} options - Query options
41
+ * @param {string[]} options.authors - Array of public keys to query (default: [client.publicKey])
42
+ * @param {boolean} options.includeAuthor - If true, adds _author field to returned data
43
+ * @param {boolean} options.skipPersistent - If true, skip persistent storage check (default: false)
44
+ * @returns {Promise<Object|null>} Data or null if not found
45
+ */
46
+ export async function nostrGet(client, path, kind = 30000, options = {}) {
47
+ const timeout = options.timeout !== undefined ? options.timeout : 30000;
48
+ const authors = options.authors || [client.publicKey];
49
+
50
+ // LOCAL-FIRST: Check persistent storage FIRST (never blocks on network)
51
+ if (!options.skipPersistent && client.persistentGet) {
52
+ const persistedEvent = await client.persistentGet(path);
53
+ if (persistedEvent && persistedEvent.content) {
54
+ try {
55
+ const data = JSON.parse(persistedEvent.content);
56
+
57
+ // Skip deleted items
58
+ if (data._deleted) {
59
+ return null;
60
+ }
61
+
62
+ // Optionally include author information
63
+ if (options.includeAuthor) {
64
+ data._author = persistedEvent.pubkey;
65
+ }
66
+
67
+ // Trigger background refresh from relays (fire-and-forget)
68
+ if (client.refreshPathInBackground) {
69
+ client.refreshPathInBackground(path, kind, { authors, timeout });
70
+ }
71
+
72
+ return data;
73
+ } catch (error) {
74
+ // Fall through to relay query if parsing fails
75
+ console.warn('[nostrGet] Failed to parse persisted event:', error);
76
+ }
77
+ }
78
+ }
79
+
80
+ // Fallback to relay query (existing logic)
81
+ const filter = {
82
+ kinds: [kind],
83
+ authors: authors, // Support multiple authors for cross-holosphere queries
84
+ '#d': [path],
85
+ limit: authors.length, // Increase limit to get events from all authors
86
+ };
87
+
88
+ const events = await client.query(filter, { timeout });
89
+
90
+ if (events.length === 0) {
91
+ return null;
92
+ }
93
+
94
+ // Get most recent event (across all authors)
95
+ const event = events.sort((a, b) => b.created_at - a.created_at)[0];
96
+
97
+ try {
98
+ const data = JSON.parse(event.content);
99
+ // Optionally include author information
100
+ if (options.includeAuthor) {
101
+ data._author = event.pubkey;
102
+ }
103
+ return data;
104
+ } catch (error) {
105
+ console.error('Failed to parse event content:', error);
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Query all events under a path prefix
112
+ * LOCAL-FIRST: Checks persistent storage first, never blocks on network
113
+ * @param {Object} client - NostrClient instance
114
+ * @param {string} pathPrefix - Path prefix to match
115
+ * @param {number} kind - Event kind (default: 30000)
116
+ * @param {Object} options - Query options
117
+ * @param {string[]} options.authors - Array of public keys to query (default: [client.publicKey])
118
+ * @param {boolean} options.includeAuthor - If true, adds _author field to returned data
119
+ * @param {boolean} options.skipPersistent - If true, skip persistent storage check (default: false)
120
+ * @returns {Promise<Array>} Array of data objects
121
+ */
122
+ export async function nostrGetAll(client, pathPrefix, kind = 30000, options = {}) {
123
+ const timeout = options.timeout !== undefined ? options.timeout : 30000;
124
+ const limit = options.limit || 1000; // Increased limit to get more events
125
+ const authors = options.authors || [client.publicKey];
126
+
127
+ // LOCAL-FIRST: Check persistent storage FIRST (never blocks on network)
128
+ if (!options.skipPersistent && client.persistentGetAll) {
129
+ const persistedEvents = await client.persistentGetAll(pathPrefix);
130
+ if (persistedEvents.length > 0) {
131
+ // Parse content and group by d-tag (keep latest only)
132
+ const byPath = new Map();
133
+ for (const event of persistedEvents) {
134
+ if (!event || !event.tags) continue;
135
+
136
+ const dTag = event.tags.find(t => t[0] === 'd');
137
+ if (!dTag || !dTag[1] || !dTag[1].startsWith(pathPrefix)) continue;
138
+
139
+ const path = dTag[1];
140
+ const existing = byPath.get(path);
141
+
142
+ if (!existing || event.created_at > existing.created_at) {
143
+ try {
144
+ const data = JSON.parse(event.content);
145
+
146
+ // Skip deleted items
147
+ if (data._deleted) continue;
148
+
149
+ // Optionally include author information
150
+ if (options.includeAuthor) {
151
+ data._author = event.pubkey;
152
+ }
153
+ byPath.set(path, { data, created_at: event.created_at });
154
+ } catch (error) {
155
+ // Skip invalid events
156
+ }
157
+ }
158
+ }
159
+
160
+ // Trigger background refresh from relays (fire-and-forget)
161
+ if (client.refreshPrefixInBackground) {
162
+ client.refreshPrefixInBackground(pathPrefix, kind, { authors, timeout, limit });
163
+ }
164
+
165
+ // Return array of data objects
166
+ return Array.from(byPath.values()).map(item => item.data);
167
+ }
168
+ }
169
+
170
+ // Fallback to relay query (existing logic)
171
+ const filter = {
172
+ kinds: [kind],
173
+ authors: authors,
174
+ limit,
175
+ };
176
+
177
+ const events = await client.query(filter, { timeout });
178
+
179
+ // Filter by path prefix in application layer
180
+ const matching = events.filter(event => {
181
+ const dTag = event.tags.find(t => t[0] === 'd');
182
+ return dTag && dTag[1] && dTag[1].startsWith(pathPrefix);
183
+ });
184
+
185
+ // Parse content and group by d-tag (keep latest only, across all authors)
186
+ const byPath = new Map();
187
+ for (const event of matching) {
188
+ const dTag = event.tags.find(t => t[0] === 'd')[1];
189
+ const existing = byPath.get(dTag);
190
+
191
+ if (!existing || event.created_at > existing.created_at) {
192
+ try {
193
+ const data = JSON.parse(event.content);
194
+
195
+ // Skip deleted items
196
+ if (data._deleted) continue;
197
+
198
+ // Optionally include author information
199
+ if (options.includeAuthor) {
200
+ data._author = event.pubkey;
201
+ }
202
+ byPath.set(dTag, { data, created_at: event.created_at });
203
+ } catch (error) {
204
+ console.error('Failed to parse event content:', error);
205
+ }
206
+ }
207
+ }
208
+
209
+ // Return array of data objects
210
+ return Array.from(byPath.values()).map(item => item.data);
211
+ }
212
+
213
+ /**
214
+ * Query all events under a path prefix (HYBRID MODE - local + relay)
215
+ * Checks local cache first, then merges with relay data
216
+ * @param {Object} client - NostrClient instance
217
+ * @param {string} pathPrefix - Path prefix to match
218
+ * @param {number} kind - Event kind (default: 30000)
219
+ * @param {Object} options - Query options
220
+ * @param {string[]} options.authors - Array of public keys to query (default: [client.publicKey])
221
+ * @param {boolean} options.includeAuthor - If true, adds _author field to returned data
222
+ * @returns {Promise<Array>} Array of data objects (merged from local + relay)
223
+ */
224
+ export async function nostrGetAllHybrid(client, pathPrefix, kind = 30000, options = {}) {
225
+ const timeout = options.timeout !== undefined ? options.timeout : 30000;
226
+ const limit = options.limit || 1000;
227
+ const authors = options.authors || [client.publicKey];
228
+
229
+ // Use hybrid query if available
230
+ const queryMethod = client.queryHybrid || client.query;
231
+
232
+ const filter = {
233
+ kinds: [kind],
234
+ authors: authors,
235
+ limit,
236
+ };
237
+
238
+ const events = await queryMethod.call(client, filter, { timeout });
239
+
240
+ // Filter by path prefix in application layer
241
+ const matching = events.filter(event => {
242
+ const dTag = event.tags.find(t => t[0] === 'd');
243
+ return dTag && dTag[1] && dTag[1].startsWith(pathPrefix);
244
+ });
245
+
246
+ // Parse content and group by d-tag (keep latest only)
247
+ const byPath = new Map();
248
+ for (const event of matching) {
249
+ const dTag = event.tags.find(t => t[0] === 'd')[1];
250
+ const existing = byPath.get(dTag);
251
+
252
+ if (!existing || event.created_at > existing.created_at) {
253
+ try {
254
+ const data = JSON.parse(event.content);
255
+ // Optionally include author information
256
+ if (options.includeAuthor) {
257
+ data._author = event.pubkey;
258
+ }
259
+ byPath.set(dTag, { data, created_at: event.created_at });
260
+ } catch (error) {
261
+ console.error('Failed to parse event content:', error);
262
+ }
263
+ }
264
+ }
265
+
266
+ // Return array of data objects
267
+ return Array.from(byPath.values()).map(item => item.data);
268
+ }
269
+
270
+ /**
271
+ * Delete data (publish deletion event - NIP-09)
272
+ * @param {Object} client - NostrClient instance
273
+ * @param {string} path - Path identifier
274
+ * @param {number} kind - Original event kind (default: 30000)
275
+ * @returns {Promise<Object>} Deletion event result
276
+ */
277
+ export async function nostrDelete(client, path, kind = 30000) {
278
+ // Read existing data first
279
+ const existing = await nostrGet(client, path, kind);
280
+
281
+ if (!existing) {
282
+ return { reason: 'not_found', results: [] };
283
+ }
284
+
285
+ // Create minimal tombstone - just the deleted flag, no original data
286
+ const tombstone = {
287
+ _deleted: true,
288
+ _deletedAt: Date.now()
289
+ };
290
+
291
+ // Publish tombstone as replacement event
292
+ // This ensures reads will see _deleted flag
293
+ const result = await nostrPut(client, path, tombstone, kind);
294
+
295
+ // Also delete from persistent storage to prevent resurrection on restart
296
+ if (client.persistentStorage) {
297
+ try {
298
+ await client.persistentStorage.delete(path);
299
+ } catch (e) {
300
+ // Ignore storage deletion errors
301
+ }
302
+ }
303
+
304
+ // Clear from memory cache
305
+ if (client.clearCache) {
306
+ client.clearCache(path);
307
+ }
308
+
309
+ // Also publish NIP-09 deletion event for relay compliance
310
+ const coordinate = `${kind}:${client.publicKey}:${path}`;
311
+ const deletionEvent = {
312
+ kind: 5, // NIP-09 deletion event
313
+ created_at: Math.floor(Date.now() / 1000),
314
+ tags: [
315
+ ['a', coordinate], // 'a' tag for parameterized replaceable events
316
+ ],
317
+ content: '', // Optional deletion reason/note
318
+ };
319
+
320
+ // Publish deletion event (best effort, don't block on failure)
321
+ try {
322
+ const publishResult = await client.publish(deletionEvent);
323
+ console.log(`✅ NIP-09 deletion event published for ${path}:`, {
324
+ coordinate,
325
+ relays: publishResult?.relays?.length || 0,
326
+ successful: publishResult?.successful || []
327
+ });
328
+ } catch (error) {
329
+ console.error(`❌ Failed to publish NIP-09 deletion event for ${path}:`, error.message);
330
+ }
331
+
332
+ return result;
333
+ }
334
+
335
+ /**
336
+ * Delete all data with path prefix (publish deletion events - NIP-09)
337
+ * @param {Object} client - NostrClient instance
338
+ * @param {string} pathPrefix - Path prefix to delete all items under
339
+ * @param {number} kind - Original event kind (default: 30000)
340
+ * @returns {Promise<Object>} Deletion results
341
+ */
342
+ export async function nostrDeleteAll(client, pathPrefix, kind = 30000) {
343
+ // Query events from relay
344
+ const filter = {
345
+ kinds: [kind],
346
+ authors: [client.publicKey],
347
+ limit: 1000,
348
+ };
349
+
350
+ const relayEvents = await client.query(filter, { timeout: 30000 });
351
+
352
+ // Also get events from persistent storage (may have events not yet synced to relay)
353
+ let persistentEvents = [];
354
+ if (client.persistentStorage) {
355
+ try {
356
+ persistentEvents = await client.persistentStorage.getAll(pathPrefix);
357
+ } catch (e) {
358
+ // Ignore storage read errors
359
+ }
360
+ }
361
+
362
+ // Merge events from both sources, using d-tag as key
363
+ const eventsByDTag = new Map();
364
+
365
+ // Add relay events
366
+ for (const event of relayEvents) {
367
+ const dTag = event.tags?.find(t => t[0] === 'd');
368
+ if (dTag && dTag[1] && dTag[1].startsWith(pathPrefix)) {
369
+ eventsByDTag.set(dTag[1], event);
370
+ }
371
+ }
372
+
373
+ // Add persistent storage events (these might be newer or only exist locally)
374
+ for (const event of persistentEvents) {
375
+ const dTag = event.tags?.find(t => t[0] === 'd');
376
+ if (dTag && dTag[1] && dTag[1].startsWith(pathPrefix)) {
377
+ const existing = eventsByDTag.get(dTag[1]);
378
+ // Use the persistent event if it's newer or doesn't exist in relay results
379
+ if (!existing || event.created_at > existing.created_at) {
380
+ eventsByDTag.set(dTag[1], event);
381
+ }
382
+ }
383
+ }
384
+
385
+ const matching = Array.from(eventsByDTag.values());
386
+
387
+ if (matching.length === 0) {
388
+ return { success: true, count: 0, results: [] };
389
+ }
390
+
391
+ // Publish tombstone events for each matching item
392
+ const tombstoneResults = [];
393
+ for (const event of matching) {
394
+ const dTag = event.tags.find(t => t[0] === 'd')[1];
395
+
396
+ try {
397
+ // Create minimal tombstone - just the deleted flag, no original data
398
+ const tombstone = {
399
+ _deleted: true,
400
+ _deletedAt: Date.now()
401
+ };
402
+
403
+ // Publish tombstone replacement
404
+ const result = await nostrPut(client, dTag, tombstone, kind);
405
+ tombstoneResults.push(result);
406
+
407
+ // Also delete from persistent storage to prevent resurrection on restart
408
+ if (client.persistentStorage) {
409
+ try {
410
+ await client.persistentStorage.delete(dTag);
411
+ } catch (e) {
412
+ // Ignore storage deletion errors
413
+ }
414
+ }
415
+
416
+ // Clear from memory cache
417
+ if (client.clearCache) {
418
+ client.clearCache(dTag);
419
+ }
420
+ } catch (error) {
421
+ console.error(`Failed to delete item ${dTag}:`, error);
422
+ }
423
+ }
424
+
425
+ // Collect all coordinates for NIP-09 deletion event
426
+ const coordinates = matching.map(event => {
427
+ const dTag = event.tags.find(t => t[0] === 'd')[1];
428
+ return `${kind}:${client.publicKey}:${dTag}`;
429
+ });
430
+
431
+ // Publish a single kind 5 deletion event with all coordinates
432
+ // This is for relay compliance (best effort)
433
+ try {
434
+ const deletionEvent = {
435
+ kind: 5, // NIP-09 deletion event
436
+ created_at: Math.floor(Date.now() / 1000),
437
+ tags: coordinates.map(coord => ['a', coord]),
438
+ content: '', // Optional deletion reason/note
439
+ };
440
+
441
+ const publishResult = await client.publish(deletionEvent);
442
+ console.log(`✅ NIP-09 bulk deletion event published for ${pathPrefix}:`, {
443
+ itemsDeleted: matching.length,
444
+ coordinates: coordinates.length,
445
+ relays: publishResult?.relays?.length || 0,
446
+ successful: publishResult?.successful || []
447
+ });
448
+ } catch (error) {
449
+ console.error(`❌ Failed to publish NIP-09 bulk deletion event for ${pathPrefix}:`, error.message);
450
+ }
451
+
452
+ return {
453
+ success: true,
454
+ count: matching.length,
455
+ results: tombstoneResults,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Subscribe to path changes
461
+ * @param {Object} client - NostrClient instance
462
+ * @param {string} path - Path to subscribe to
463
+ * @param {Function} callback - Callback function (data, event) => void
464
+ * @param {Object} options - Subscription options
465
+ * @returns {Object} Subscription object with unsubscribe method
466
+ */
467
+ export function nostrSubscribe(client, path, callback, options = {}) {
468
+ const kind = options.kind || 30000;
469
+ const includeInitial = options.includeInitial !== false;
470
+
471
+ const filter = {
472
+ kinds: [kind],
473
+ authors: [client.publicKey], // Filter by our public key
474
+ '#d': [path],
475
+ limit: 10, // Limit results for single item subscription
476
+ };
477
+
478
+ let initialLoadComplete = false;
479
+
480
+ const subscription = client.subscribe(
481
+ filter,
482
+ (event) => {
483
+ // Verify event is from our public key (relay may not respect author filter)
484
+ if (event.pubkey !== client.publicKey) {
485
+ console.warn('[nostrSubscribe] Rejecting event from different author:', {
486
+ expected: client.publicKey,
487
+ received: event.pubkey,
488
+ eventId: event.id
489
+ });
490
+ return;
491
+ }
492
+
493
+ try {
494
+ const data = JSON.parse(event.content);
495
+ callback(data, event);
496
+ } catch (error) {
497
+ console.error('Failed to parse event in subscription:', error);
498
+ }
499
+ },
500
+ {
501
+ onEOSE: () => {
502
+ initialLoadComplete = true;
503
+ },
504
+ }
505
+ );
506
+
507
+ return subscription;
508
+ }
509
+
510
+ /**
511
+ * Subscribe to path prefix (multiple paths)
512
+ *
513
+ * Subscribes to data events and uses Page Visibility API to refresh when tab becomes visible.
514
+ * Note: Nostr relays do not broadcast replaceable event updates to active subscriptions.
515
+ *
516
+ * @param {Object} client - NostrClient instance
517
+ * @param {string} pathPrefix - Path prefix to match
518
+ * @param {Function} callback - Callback function (data, path, event) => void
519
+ * @param {Object} options - Subscription options
520
+ * @returns {Promise<Object>} Subscription object with unsubscribe method
521
+ */
522
+ export async function nostrSubscribeMany(client, pathPrefix, callback, options = {}) {
523
+ const kind = options.kind || 30000;
524
+
525
+ // Create unique key for this subscription
526
+ const subscriptionKey = `${client.publicKey}:${kind}:${pathPrefix}`;
527
+
528
+ // Check if we already have an active subscription for this path
529
+ const existingSub = globalSubscriptions.get(subscriptionKey);
530
+ if (existingSub) {
531
+ // Register this callback with the existing subscription
532
+ existingSub.callbacks.push(callback);
533
+
534
+ // Return a wrapper that removes only this callback on unsubscribe
535
+ return {
536
+ unsubscribe: () => {
537
+ const idx = existingSub.callbacks.indexOf(callback);
538
+ if (idx > -1) {
539
+ existingSub.callbacks.splice(idx, 1);
540
+ }
541
+
542
+ // If no more callbacks, unsubscribe the whole thing
543
+ if (existingSub.callbacks.length === 0) {
544
+ existingSub.actualSubscription.unsubscribe();
545
+ globalSubscriptions.delete(subscriptionKey);
546
+ }
547
+ }
548
+ };
549
+ }
550
+
551
+ const seenEventIds = new Set(); // Track event IDs to prevent duplicate callbacks
552
+ const callbacks = [callback]; // Array of callbacks to notify
553
+
554
+ // Track rejected events for metrics
555
+ let rejectedCount = 0;
556
+ let acceptedCount = 0;
557
+ let lastLogTime = Date.now();
558
+ const LOG_INTERVAL = 10000; // Log summary every 10 seconds
559
+
560
+ // Handler for data events (kind 30000)
561
+ const handleDataEvent = (event) => {
562
+ // Skip duplicates
563
+ if (seenEventIds.has(event.id)) {
564
+ return;
565
+ }
566
+ seenEventIds.add(event.id);
567
+
568
+ // Check if event is from a different author (relay not respecting filter)
569
+ if (event.pubkey !== client.publicKey) {
570
+ rejectedCount++;
571
+
572
+ // Only log periodically to avoid spam
573
+ const now = Date.now();
574
+ if (now - lastLogTime > LOG_INTERVAL) {
575
+ console.warn('[nostrSubscribeMany] ⚠️ Relay not respecting authors filter!', {
576
+ rejectedCount,
577
+ acceptedCount,
578
+ expected: client.publicKey,
579
+ message: 'Consider using a different relay or implementing private relay'
580
+ });
581
+ lastLogTime = now;
582
+ }
583
+ return; // Skip events from other authors
584
+ }
585
+
586
+ // Extract d-tag
587
+ const dTagArray = event.tags.find(t => t[0] === 'd');
588
+ const dTag = dTagArray?.[1];
589
+
590
+ // Client-side prefix filter
591
+ if (dTag && dTag.startsWith(pathPrefix)) {
592
+ acceptedCount++;
593
+ try {
594
+ const data = JSON.parse(event.content);
595
+ // Notify all registered callbacks
596
+ for (const cb of callbacks) {
597
+ cb(data, dTag, event);
598
+ }
599
+ } catch (error) {
600
+ console.error('[nostrSubscribeMany] Failed to parse event:', error);
601
+ }
602
+ }
603
+ };
604
+
605
+ // Subscribe to data events (kind 30000) for initial load
606
+ // Use 'since' to only get events from subscription time onward for real-time updates
607
+ const subscriptionStartTime = Math.floor(Date.now() / 1000);
608
+ const dataFilter = {
609
+ kinds: [kind],
610
+ authors: [client.publicKey],
611
+ since: subscriptionStartTime // Only get new events after subscription
612
+ };
613
+ const dataSubscription = await client.subscribe(dataFilter, handleDataEvent);
614
+
615
+ // Use Page Visibility API to refresh when tab becomes visible
616
+ let isDocumentHidden = typeof document !== 'undefined' && document.hidden;
617
+ let lastVisibilityCheck = subscriptionStartTime;
618
+
619
+ const handleVisibilityChange = async () => {
620
+ if (typeof document === 'undefined') return;
621
+
622
+ const wasHidden = isDocumentHidden;
623
+ isDocumentHidden = document.hidden;
624
+
625
+ // Tab became visible - only check for new events since we were hidden
626
+ if (wasHidden && !isDocumentHidden) {
627
+ const now = Math.floor(Date.now() / 1000);
628
+ try {
629
+ const visibilityFilter = {
630
+ ...dataFilter,
631
+ since: lastVisibilityCheck, // Only events since last check
632
+ until: now
633
+ };
634
+ const events = await client.query(visibilityFilter, { timeout: 30000 });
635
+
636
+ for (const event of events) {
637
+ handleDataEvent(event);
638
+ }
639
+ lastVisibilityCheck = now;
640
+ } catch (error) {
641
+ // Silently handle visibility refresh errors
642
+ }
643
+ }
644
+ };
645
+
646
+ // Only add listener in browser environment
647
+ if (typeof document !== 'undefined') {
648
+ document.addEventListener('visibilitychange', handleVisibilityChange);
649
+ }
650
+
651
+ // Store subscription info for reuse
652
+ const actualSubscription = {
653
+ unsubscribe: () => {
654
+ if (dataSubscription && dataSubscription.unsubscribe) {
655
+ dataSubscription.unsubscribe();
656
+ }
657
+ if (typeof document !== 'undefined') {
658
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
659
+ }
660
+ globalSubscriptions.delete(subscriptionKey);
661
+ }
662
+ };
663
+
664
+ const subscriptionInfo = {
665
+ callbacks,
666
+ actualSubscription
667
+ };
668
+
669
+ globalSubscriptions.set(subscriptionKey, subscriptionInfo);
670
+
671
+ return actualSubscription;
672
+ }
673
+
674
+ /**
675
+ * Update data (merge with existing)
676
+ * @param {Object} client - NostrClient instance
677
+ * @param {string} path - Path identifier
678
+ * @param {Object} updates - Fields to update
679
+ * @param {number} kind - Event kind (default: 30000)
680
+ * @returns {Promise<Object>} Updated event result
681
+ */
682
+ export async function nostrUpdate(client, path, updates, kind = 30000) {
683
+ // Read existing data
684
+ const existing = await nostrGet(client, path, kind);
685
+
686
+ if (!existing) {
687
+ throw new Error(`No data found at path: ${path}`);
688
+ }
689
+
690
+ // Merge updates
691
+ const merged = { ...existing, ...updates };
692
+
693
+ // Publish updated event
694
+ return nostrPut(client, path, merged, kind);
695
+ }
696
+
697
+ /**
698
+ * Batch read multiple paths
699
+ * @param {Object} client - NostrClient instance
700
+ * @param {string[]} paths - Array of paths
701
+ * @param {number} kind - Event kind (default: 30000)
702
+ * @returns {Promise<Object>} Object mapping paths to data
703
+ */
704
+ export async function nostrBatchGet(client, paths, kind = 30000) {
705
+ const filter = {
706
+ kinds: [kind],
707
+ '#d': paths,
708
+ limit: paths.length,
709
+ };
710
+
711
+ const events = await client.query(filter);
712
+
713
+ // Group by d-tag (keep latest only)
714
+ const byPath = new Map();
715
+ for (const event of events) {
716
+ const dTag = event.tags.find(t => t[0] === 'd');
717
+ if (!dTag || !dTag[1]) continue;
718
+
719
+ const path = dTag[1];
720
+ const existing = byPath.get(path);
721
+
722
+ if (!existing || event.created_at > existing.created_at) {
723
+ try {
724
+ const data = JSON.parse(event.content);
725
+ byPath.set(path, data);
726
+ } catch (error) {
727
+ console.error('Failed to parse event content:', error);
728
+ }
729
+ }
730
+ }
731
+
732
+ // Return object mapping paths to data
733
+ const result = {};
734
+ for (const path of paths) {
735
+ result[path] = byPath.get(path) || null;
736
+ }
737
+
738
+ return result;
739
+ }
740
+
741
+ /**
742
+ * Batch write multiple paths
743
+ * @param {Object} client - NostrClient instance
744
+ * @param {Object} pathDataMap - Object mapping paths to data
745
+ * @param {number} kind - Event kind (default: 30000)
746
+ * @returns {Promise<Array>} Array of publish results
747
+ */
748
+ export async function nostrBatchPut(client, pathDataMap, kind = 30000) {
749
+ const promises = Object.entries(pathDataMap).map(([path, data]) =>
750
+ nostrPut(client, path, data, kind)
751
+ );
752
+
753
+ return Promise.all(promises);
754
+ }
755
+
756
+ /**
757
+ * Retry operation with exponential backoff
758
+ * @param {Function} operation - Async function to retry
759
+ * @param {number} maxRetries - Max retry attempts (default: 3)
760
+ * @param {number} baseDelay - Base delay in ms (default: 100ms)
761
+ * @returns {Promise<any>} Promise resolving to operation result
762
+ */
763
+ export async function nostrRetry(operation, maxRetries = 3, baseDelay = 100) {
764
+ let lastError;
765
+
766
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
767
+ try {
768
+ return await operation();
769
+ } catch (error) {
770
+ lastError = error;
771
+ if (attempt < maxRetries) {
772
+ const delay = baseDelay * Math.pow(2, attempt);
773
+ await new Promise(resolve => setTimeout(resolve, delay));
774
+ }
775
+ }
776
+ }
777
+
778
+ throw lastError;
779
+ }
780
+
781
+ /**
782
+ * Wait for specific condition on data
783
+ * @param {Object} client - NostrClient instance
784
+ * @param {string} path - Path to watch
785
+ * @param {Function} predicate - Condition function (data) => boolean
786
+ * @param {number} timeout - Timeout in ms (default: 5000ms)
787
+ * @returns {Promise<any>} Promise resolving when condition is met
788
+ */
789
+ export async function nostrWaitFor(client, path, predicate, timeout = 5000) {
790
+ return new Promise((resolve, reject) => {
791
+ let timeoutId;
792
+ let subscription;
793
+
794
+ const cleanup = () => {
795
+ if (timeoutId) clearTimeout(timeoutId);
796
+ if (subscription) subscription.unsubscribe();
797
+ };
798
+
799
+ subscription = nostrSubscribe(client, path, (data) => {
800
+ if (predicate(data)) {
801
+ cleanup();
802
+ resolve(data);
803
+ }
804
+ });
805
+
806
+ timeoutId = setTimeout(() => {
807
+ cleanup();
808
+ reject(new Error('Timeout waiting for condition'));
809
+ }, timeout);
810
+ });
811
+ }