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.
- package/.env.example +36 -0
- package/.eslintrc.json +16 -0
- package/.prettierrc.json +7 -0
- package/README.md +483 -367
- 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 -980
- 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 -33256
- package/holosphere-bundle.js +0 -33287
- package/holosphere-bundle.min.js +0 -39
- package/holosphere.d.ts +0 -601
- package/holosphere.js +0 -719
- package/node.js +0 -246
- package/schema.js +0 -139
- 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
|
+
}
|