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.
- package/.env.example +36 -0
- package/.eslintrc.json +16 -0
- package/.prettierrc.json +7 -0
- package/README.md +476 -531
- package/bin/holosphere-activitypub.js +158 -0
- package/cleanup-test-data.js +204 -0
- package/examples/demo.html +1333 -0
- package/examples/example-bot.js +197 -0
- package/package.json +47 -87
- package/scripts/check-bundle-size.js +54 -0
- package/scripts/check-quest-ids.js +77 -0
- package/scripts/import-holons.js +578 -0
- package/scripts/publish-to-relay.js +101 -0
- package/scripts/read-example.js +186 -0
- package/scripts/relay-diagnostic.js +59 -0
- package/scripts/relay-example.js +179 -0
- package/scripts/resync-to-relay.js +245 -0
- package/scripts/revert-import.js +196 -0
- package/scripts/test-hybrid-mode.js +108 -0
- package/scripts/test-local-storage.js +63 -0
- package/scripts/test-nostr-direct.js +55 -0
- package/scripts/test-read-data.js +45 -0
- package/scripts/test-write-read.js +63 -0
- package/scripts/verify-import.js +95 -0
- package/scripts/verify-relay-data.js +139 -0
- package/src/ai/aggregation.js +319 -0
- package/src/ai/breakdown.js +511 -0
- package/src/ai/classifier.js +217 -0
- package/src/ai/council.js +228 -0
- package/src/ai/embeddings.js +279 -0
- package/src/ai/federation-ai.js +324 -0
- package/src/ai/h3-ai.js +955 -0
- package/src/ai/index.js +112 -0
- package/src/ai/json-ops.js +225 -0
- package/src/ai/llm-service.js +205 -0
- package/src/ai/nl-query.js +223 -0
- package/src/ai/relationships.js +353 -0
- package/src/ai/schema-extractor.js +218 -0
- package/src/ai/spatial.js +293 -0
- package/src/ai/tts.js +194 -0
- package/src/content/social-protocols.js +168 -0
- package/src/core/holosphere.js +273 -0
- package/src/crypto/secp256k1.js +259 -0
- package/src/federation/discovery.js +334 -0
- package/src/federation/hologram.js +1042 -0
- package/src/federation/registry.js +386 -0
- package/src/hierarchical/upcast.js +110 -0
- package/src/index.js +2669 -0
- package/src/schema/validator.js +91 -0
- package/src/spatial/h3-operations.js +110 -0
- package/src/storage/backend-factory.js +125 -0
- package/src/storage/backend-interface.js +142 -0
- package/src/storage/backends/activitypub/server.js +653 -0
- package/src/storage/backends/activitypub-backend.js +272 -0
- package/src/storage/backends/gundb-backend.js +233 -0
- package/src/storage/backends/nostr-backend.js +136 -0
- package/src/storage/filesystem-storage-browser.js +41 -0
- package/src/storage/filesystem-storage.js +138 -0
- package/src/storage/global-tables.js +81 -0
- package/src/storage/gun-async.js +281 -0
- package/src/storage/gun-wrapper.js +221 -0
- package/src/storage/indexeddb-storage.js +122 -0
- package/src/storage/key-storage-simple.js +76 -0
- package/src/storage/key-storage.js +136 -0
- package/src/storage/memory-storage.js +59 -0
- package/src/storage/migration.js +338 -0
- package/src/storage/nostr-async.js +811 -0
- package/src/storage/nostr-client.js +939 -0
- package/src/storage/nostr-wrapper.js +211 -0
- package/src/storage/outbox-queue.js +208 -0
- package/src/storage/persistent-storage.js +109 -0
- package/src/storage/sync-service.js +164 -0
- package/src/subscriptions/manager.js +142 -0
- package/test-ai-real-api.js +202 -0
- package/tests/unit/ai/aggregation.test.js +295 -0
- package/tests/unit/ai/breakdown.test.js +446 -0
- package/tests/unit/ai/classifier.test.js +294 -0
- package/tests/unit/ai/council.test.js +262 -0
- package/tests/unit/ai/embeddings.test.js +384 -0
- package/tests/unit/ai/federation-ai.test.js +344 -0
- package/tests/unit/ai/h3-ai.test.js +458 -0
- package/tests/unit/ai/index.test.js +304 -0
- package/tests/unit/ai/json-ops.test.js +307 -0
- package/tests/unit/ai/llm-service.test.js +390 -0
- package/tests/unit/ai/nl-query.test.js +383 -0
- package/tests/unit/ai/relationships.test.js +311 -0
- package/tests/unit/ai/schema-extractor.test.js +384 -0
- package/tests/unit/ai/spatial.test.js +279 -0
- package/tests/unit/ai/tts.test.js +279 -0
- package/tests/unit/content.test.js +332 -0
- package/tests/unit/contract/core.test.js +88 -0
- package/tests/unit/contract/crypto.test.js +198 -0
- package/tests/unit/contract/data.test.js +223 -0
- package/tests/unit/contract/federation.test.js +181 -0
- package/tests/unit/contract/hierarchical.test.js +113 -0
- package/tests/unit/contract/schema.test.js +114 -0
- package/tests/unit/contract/social.test.js +217 -0
- package/tests/unit/contract/spatial.test.js +110 -0
- package/tests/unit/contract/subscriptions.test.js +128 -0
- package/tests/unit/contract/utils.test.js +159 -0
- package/tests/unit/core.test.js +152 -0
- package/tests/unit/crypto.test.js +328 -0
- package/tests/unit/federation.test.js +234 -0
- package/tests/unit/gun-async.test.js +252 -0
- package/tests/unit/hierarchical.test.js +399 -0
- package/tests/unit/integration/scenario-01-geographic-storage.test.js +74 -0
- package/tests/unit/integration/scenario-02-federation.test.js +76 -0
- package/tests/unit/integration/scenario-03-subscriptions.test.js +102 -0
- package/tests/unit/integration/scenario-04-validation.test.js +129 -0
- package/tests/unit/integration/scenario-05-hierarchy.test.js +125 -0
- package/tests/unit/integration/scenario-06-social.test.js +135 -0
- package/tests/unit/integration/scenario-07-persistence.test.js +130 -0
- package/tests/unit/integration/scenario-08-authorization.test.js +161 -0
- package/tests/unit/integration/scenario-09-cross-dimensional.test.js +139 -0
- package/tests/unit/integration/scenario-10-cross-holosphere-capabilities.test.js +357 -0
- package/tests/unit/integration/scenario-11-cross-holosphere-federation.test.js +410 -0
- package/tests/unit/integration/scenario-12-capability-federated-read.test.js +719 -0
- package/tests/unit/performance/benchmark.test.js +85 -0
- package/tests/unit/schema.test.js +213 -0
- package/tests/unit/spatial.test.js +158 -0
- package/tests/unit/storage.test.js +195 -0
- package/tests/unit/subscriptions.test.js +328 -0
- package/tests/unit/test-data-permanence-debug.js +197 -0
- package/tests/unit/test-data-permanence.js +340 -0
- package/tests/unit/test-key-persistence-fixed.js +148 -0
- package/tests/unit/test-key-persistence.js +172 -0
- package/tests/unit/test-relay-permanence.js +376 -0
- package/tests/unit/test-second-node.js +95 -0
- package/tests/unit/test-simple-write.js +89 -0
- package/vite.config.js +49 -0
- package/vitest.config.js +20 -0
- package/FEDERATION.md +0 -213
- package/compute.js +0 -298
- package/content.js +0 -1022
- package/federation.js +0 -1234
- package/global.js +0 -736
- package/hexlib.js +0 -335
- package/hologram.js +0 -183
- package/holosphere-bundle.esm.js +0 -34549
- package/holosphere-bundle.js +0 -34580
- package/holosphere-bundle.min.js +0 -49
- package/holosphere.d.ts +0 -604
- package/holosphere.js +0 -739
- package/node.js +0 -246
- package/schema.js +0 -139
- 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
|
+
}
|