holosphere 1.1.20 → 2.0.0-alpha1
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/LICENSE +162 -38
- 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,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr Storage Wrapper
|
|
3
|
+
* Drop-in replacement for gun-wrapper.js with same API
|
|
4
|
+
* Handles path construction and CRUD operations using Nostr relays
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
nostrPut,
|
|
9
|
+
nostrGet,
|
|
10
|
+
nostrGetAll,
|
|
11
|
+
nostrGetAllHybrid,
|
|
12
|
+
nostrUpdate as nostrAsyncUpdate,
|
|
13
|
+
nostrDelete,
|
|
14
|
+
nostrDeleteAll,
|
|
15
|
+
nostrSubscribe,
|
|
16
|
+
nostrSubscribeMany,
|
|
17
|
+
} from './nostr-async.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build path from components
|
|
21
|
+
* @param {string} appname - Application namespace
|
|
22
|
+
* @param {string} holon - Holon ID (H3 or URI)
|
|
23
|
+
* @param {string} lens - Lens name
|
|
24
|
+
* @param {string} key - Data key (optional)
|
|
25
|
+
* @returns {string} Path string
|
|
26
|
+
*/
|
|
27
|
+
export function buildPath(appname, holon, lens, key = null) {
|
|
28
|
+
// Encode components to handle special characters
|
|
29
|
+
const encodedHolon = encodePathComponent(holon);
|
|
30
|
+
const encodedLens = encodePathComponent(lens);
|
|
31
|
+
|
|
32
|
+
if (key) {
|
|
33
|
+
const encodedKey = encodePathComponent(key);
|
|
34
|
+
return `${appname}/${encodedHolon}/${encodedLens}/${encodedKey}`;
|
|
35
|
+
}
|
|
36
|
+
return `${appname}/${encodedHolon}/${encodedLens}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encode path component to handle special characters
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
function encodePathComponent(component) {
|
|
44
|
+
return encodeURIComponent(component).replace(/%2F/g, '/');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write data to Nostr
|
|
49
|
+
* @param {Object} client - NostrClient instance
|
|
50
|
+
* @param {string} path - Path
|
|
51
|
+
* @param {Object} data - Data to write
|
|
52
|
+
* @returns {Promise<boolean>} Success indicator
|
|
53
|
+
*/
|
|
54
|
+
export async function write(client, path, data) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await nostrPut(client, path, data);
|
|
57
|
+
// Check if at least one relay accepted the event
|
|
58
|
+
// If no relays (testing mode), consider it successful if event was created
|
|
59
|
+
const success = result.results.length === 0 ? true : result.results.some(r => r.status === 'fulfilled');
|
|
60
|
+
return success;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('Nostr write error:', error);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read data from Nostr
|
|
69
|
+
* @param {Object} client - NostrClient instance
|
|
70
|
+
* @param {string} path - Path
|
|
71
|
+
* @param {Object} options - Query options
|
|
72
|
+
* @param {string[]} options.authors - Array of public keys to query (for cross-holosphere reads)
|
|
73
|
+
* @param {boolean} options.includeAuthor - If true, adds _author field to returned data
|
|
74
|
+
* @returns {Promise<Object|null>} Data or null if not found
|
|
75
|
+
*/
|
|
76
|
+
export async function read(client, path, options = {}) {
|
|
77
|
+
const data = await nostrGet(client, path, 30000, options);
|
|
78
|
+
|
|
79
|
+
// Return null if deleted or not found
|
|
80
|
+
if (!data || data._deleted) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read all data under a path (lens query)
|
|
89
|
+
* @param {Object} client - NostrClient instance
|
|
90
|
+
* @param {string} path - Path prefix
|
|
91
|
+
* @param {Object} options - Query options
|
|
92
|
+
* @param {boolean} options.hybrid - Use hybrid mode (local + relay) - default: false
|
|
93
|
+
* @param {string[]} options.authors - Array of public keys to query (for cross-holosphere reads)
|
|
94
|
+
* @param {boolean} options.includeAuthor - If true, adds _author field to returned data
|
|
95
|
+
* @returns {Promise<Object[]>} Array of data objects
|
|
96
|
+
*/
|
|
97
|
+
export async function readAll(client, path, options = {}) {
|
|
98
|
+
// Use hybrid mode if enabled and client supports it
|
|
99
|
+
const useHybrid = options.hybrid && typeof client.queryHybrid === 'function';
|
|
100
|
+
const queryFn = useHybrid ? nostrGetAllHybrid : nostrGetAll;
|
|
101
|
+
|
|
102
|
+
const results = await queryFn(client, path, 30000, options);
|
|
103
|
+
|
|
104
|
+
// Filter out deleted items
|
|
105
|
+
return results.filter(data => {
|
|
106
|
+
if (!data || typeof data !== 'object') return false;
|
|
107
|
+
if (data._deleted) return false;
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Update data (merge fields)
|
|
114
|
+
* @param {Object} client - NostrClient instance
|
|
115
|
+
* @param {string} path - Path
|
|
116
|
+
* @param {Object} updates - Fields to update
|
|
117
|
+
* @returns {Promise<boolean>} Success indicator
|
|
118
|
+
*/
|
|
119
|
+
export async function update(client, path, updates) {
|
|
120
|
+
try {
|
|
121
|
+
const existing = await nostrGet(client, path);
|
|
122
|
+
|
|
123
|
+
if (!existing || !existing.id || existing._deleted) {
|
|
124
|
+
return false; // Not found or deleted
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Merge updates
|
|
128
|
+
const merged = { ...existing, ...updates };
|
|
129
|
+
|
|
130
|
+
// Update timestamp
|
|
131
|
+
if (!merged._meta) merged._meta = {};
|
|
132
|
+
merged._meta.timestamp = Date.now();
|
|
133
|
+
|
|
134
|
+
// Write merged data
|
|
135
|
+
const result = await nostrPut(client, path, merged);
|
|
136
|
+
// If no relays (testing mode), consider it successful if event was created
|
|
137
|
+
const success = result.results.length === 0 ? true : result.results.some(r => r.status === 'fulfilled');
|
|
138
|
+
return success;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('Nostr update error:', error);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Delete data (tombstone)
|
|
147
|
+
* @param {Object} client - NostrClient instance
|
|
148
|
+
* @param {string} path - Path
|
|
149
|
+
* @returns {Promise<boolean>} Success indicator
|
|
150
|
+
*/
|
|
151
|
+
export async function deleteData(client, path) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await nostrDelete(client, path);
|
|
154
|
+
|
|
155
|
+
if (result.reason === 'not_found') {
|
|
156
|
+
return false; // Item doesn't exist
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// If no relays (testing mode), consider it successful if event was created
|
|
160
|
+
const success = result.results.length === 0 ? true : result.results.some(r => r.status === 'fulfilled');
|
|
161
|
+
return success;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('Nostr delete error:', error);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Delete all data under path prefix (tombstone)
|
|
170
|
+
* @param {Object} client - NostrClient instance
|
|
171
|
+
* @param {string} path - Path prefix
|
|
172
|
+
* @returns {Promise<Object>} Deletion results
|
|
173
|
+
*/
|
|
174
|
+
export async function deleteAll(client, path) {
|
|
175
|
+
try {
|
|
176
|
+
const result = await nostrDeleteAll(client, path);
|
|
177
|
+
return result;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('Nostr deleteAll error:', error);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Subscribe to data changes
|
|
186
|
+
* @param {Object} client - NostrClient instance
|
|
187
|
+
* @param {string} path - Path
|
|
188
|
+
* @param {Function} callback - Called on data changes
|
|
189
|
+
* @param {Object} options - Subscription options
|
|
190
|
+
* @param {boolean} options.realtimeOnly - Only receive new events (default: true)
|
|
191
|
+
* @returns {Promise<Object>} Subscription object with unsubscribe method
|
|
192
|
+
*/
|
|
193
|
+
export async function subscribe(client, path, callback, options = {}) {
|
|
194
|
+
// Determine if this is a prefix subscription (no key) or single item
|
|
195
|
+
const pathParts = path.split('/');
|
|
196
|
+
const isPrefix = pathParts.length <= 3; // appname/holon/lens
|
|
197
|
+
|
|
198
|
+
if (isPrefix) {
|
|
199
|
+
// Subscribe to all items under this prefix
|
|
200
|
+
return await nostrSubscribeMany(client, path + '/', (data, fullPath) => {
|
|
201
|
+
// Extract key from full path
|
|
202
|
+
const key = fullPath.split('/').pop();
|
|
203
|
+
callback(data, key);
|
|
204
|
+
}, options);
|
|
205
|
+
} else {
|
|
206
|
+
// Subscribe to single item
|
|
207
|
+
return nostrSubscribe(client, path, (data) => {
|
|
208
|
+
callback(data, path.split('/').pop());
|
|
209
|
+
}, options);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutboxQueue - Persistent queue for reliable relay delivery
|
|
3
|
+
*
|
|
4
|
+
* Implements guaranteed delivery with exponential backoff retry.
|
|
5
|
+
* Events are persisted to local storage and retried until delivered
|
|
6
|
+
* or max retries exceeded.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class OutboxQueue {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} persistentStorage - Storage adapter (IndexedDB/FileSystem)
|
|
12
|
+
* @param {Object} options - Configuration options
|
|
13
|
+
* @param {number} options.maxRetries - Max retry attempts (default: 5)
|
|
14
|
+
* @param {number} options.baseDelay - Base delay in ms (default: 1000)
|
|
15
|
+
* @param {number} options.maxDelay - Max delay cap in ms (default: 60000)
|
|
16
|
+
* @param {number} options.failedTTL - TTL for failed events in ms (default: 24 hours)
|
|
17
|
+
*/
|
|
18
|
+
constructor(persistentStorage, options = {}) {
|
|
19
|
+
this.storage = persistentStorage;
|
|
20
|
+
this.queuePrefix = '_outbox/';
|
|
21
|
+
this.maxRetries = options.maxRetries || 5;
|
|
22
|
+
this.baseDelay = options.baseDelay || 1000; // 1 second
|
|
23
|
+
this.maxDelay = options.maxDelay || 60000; // 1 minute
|
|
24
|
+
this.failedTTL = options.failedTTL || 24 * 60 * 60 * 1000; // 24 hours
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Add an event to the outbox queue
|
|
29
|
+
* @param {Object} event - Signed Nostr event
|
|
30
|
+
* @param {string[]} relays - Target relay URLs
|
|
31
|
+
* @returns {Promise<Object>} Queue entry
|
|
32
|
+
*/
|
|
33
|
+
async enqueue(event, relays) {
|
|
34
|
+
const entry = {
|
|
35
|
+
id: event.id,
|
|
36
|
+
event,
|
|
37
|
+
relays,
|
|
38
|
+
status: 'pending',
|
|
39
|
+
retries: 0,
|
|
40
|
+
createdAt: Date.now(),
|
|
41
|
+
nextRetryAt: Date.now(),
|
|
42
|
+
failedRelays: [],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const key = `${this.queuePrefix}${event.id}`;
|
|
46
|
+
await this.storage.put(key, entry);
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mark an event as sent (removes from queue if successful)
|
|
52
|
+
* @param {string} eventId - Event ID
|
|
53
|
+
* @param {string[]} successfulRelays - Relays that accepted the event
|
|
54
|
+
*/
|
|
55
|
+
async markSent(eventId, successfulRelays) {
|
|
56
|
+
const key = `${this.queuePrefix}${eventId}`;
|
|
57
|
+
const entry = await this.storage.get(key);
|
|
58
|
+
if (!entry) return;
|
|
59
|
+
|
|
60
|
+
if (successfulRelays.length > 0) {
|
|
61
|
+
// At least one relay accepted - remove from queue
|
|
62
|
+
await this.storage.delete(key);
|
|
63
|
+
} else {
|
|
64
|
+
// All relays failed - schedule retry
|
|
65
|
+
await this._scheduleRetry(key, entry);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Schedule a retry with exponential backoff
|
|
71
|
+
* @private
|
|
72
|
+
*/
|
|
73
|
+
async _scheduleRetry(key, entry) {
|
|
74
|
+
entry.retries++;
|
|
75
|
+
|
|
76
|
+
if (entry.retries >= this.maxRetries) {
|
|
77
|
+
entry.status = 'failed';
|
|
78
|
+
entry.failedAt = Date.now();
|
|
79
|
+
} else {
|
|
80
|
+
entry.status = 'pending';
|
|
81
|
+
// Exponential backoff with jitter
|
|
82
|
+
const delay = Math.min(
|
|
83
|
+
this.baseDelay * Math.pow(2, entry.retries) + Math.random() * 1000,
|
|
84
|
+
this.maxDelay
|
|
85
|
+
);
|
|
86
|
+
entry.nextRetryAt = Date.now() + delay;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await this.storage.put(key, entry);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get all pending events ready for retry
|
|
94
|
+
* @returns {Promise<Object[]>} Pending queue entries sorted by creation time
|
|
95
|
+
*/
|
|
96
|
+
async getPendingEvents() {
|
|
97
|
+
const allEntries = await this.storage.getAll(this.queuePrefix);
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
|
|
100
|
+
return allEntries
|
|
101
|
+
.filter(e => e && e.status === 'pending' && e.nextRetryAt <= now)
|
|
102
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get all failed events (exceeded max retries)
|
|
107
|
+
* @returns {Promise<Object[]>} Failed queue entries
|
|
108
|
+
*/
|
|
109
|
+
async getFailedEvents() {
|
|
110
|
+
const allEntries = await this.storage.getAll(this.queuePrefix);
|
|
111
|
+
return allEntries.filter(e => e && e.status === 'failed');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get queue statistics
|
|
116
|
+
* @returns {Promise<Object>} Stats object
|
|
117
|
+
*/
|
|
118
|
+
async getStats() {
|
|
119
|
+
const allEntries = await this.storage.getAll(this.queuePrefix);
|
|
120
|
+
|
|
121
|
+
const stats = {
|
|
122
|
+
total: allEntries.length,
|
|
123
|
+
pending: 0,
|
|
124
|
+
failed: 0,
|
|
125
|
+
oldestPending: null,
|
|
126
|
+
oldestFailed: null,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
for (const entry of allEntries) {
|
|
130
|
+
if (!entry) continue;
|
|
131
|
+
|
|
132
|
+
if (entry.status === 'pending') {
|
|
133
|
+
stats.pending++;
|
|
134
|
+
if (!stats.oldestPending || entry.createdAt < stats.oldestPending) {
|
|
135
|
+
stats.oldestPending = entry.createdAt;
|
|
136
|
+
}
|
|
137
|
+
} else if (entry.status === 'failed') {
|
|
138
|
+
stats.failed++;
|
|
139
|
+
if (!stats.oldestFailed || entry.failedAt < stats.oldestFailed) {
|
|
140
|
+
stats.oldestFailed = entry.failedAt;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return stats;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Purge failed events older than maxAge
|
|
150
|
+
* @param {number} maxAge - Max age in ms (default: 24 hours)
|
|
151
|
+
* @returns {Promise<number>} Number of entries purged
|
|
152
|
+
*/
|
|
153
|
+
async purgeOldFailed(maxAge = this.failedTTL) {
|
|
154
|
+
const allEntries = await this.storage.getAll(this.queuePrefix);
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
let purged = 0;
|
|
157
|
+
|
|
158
|
+
for (const entry of allEntries) {
|
|
159
|
+
if (entry && entry.status === 'failed' && (now - entry.failedAt) > maxAge) {
|
|
160
|
+
await this.storage.delete(`${this.queuePrefix}${entry.id}`);
|
|
161
|
+
purged++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return purged;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clear all entries from the queue
|
|
170
|
+
* @returns {Promise<number>} Number of entries cleared
|
|
171
|
+
*/
|
|
172
|
+
async clear() {
|
|
173
|
+
const allEntries = await this.storage.getAll(this.queuePrefix);
|
|
174
|
+
let cleared = 0;
|
|
175
|
+
|
|
176
|
+
for (const entry of allEntries) {
|
|
177
|
+
if (entry && entry.id) {
|
|
178
|
+
await this.storage.delete(`${this.queuePrefix}${entry.id}`);
|
|
179
|
+
cleared++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return cleared;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Manually retry a failed event
|
|
188
|
+
* @param {string} eventId - Event ID to retry
|
|
189
|
+
* @returns {Promise<Object|null>} Updated entry or null if not found
|
|
190
|
+
*/
|
|
191
|
+
async retryFailed(eventId) {
|
|
192
|
+
const key = `${this.queuePrefix}${eventId}`;
|
|
193
|
+
const entry = await this.storage.get(key);
|
|
194
|
+
|
|
195
|
+
if (!entry || entry.status !== 'failed') {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Reset for retry
|
|
200
|
+
entry.status = 'pending';
|
|
201
|
+
entry.retries = 0;
|
|
202
|
+
entry.nextRetryAt = Date.now();
|
|
203
|
+
delete entry.failedAt;
|
|
204
|
+
|
|
205
|
+
await this.storage.put(key, entry);
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent Storage Interface
|
|
3
|
+
* Provides a unified API for local persistence across browser and Node.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base class for persistent storage adapters
|
|
8
|
+
*/
|
|
9
|
+
class PersistentStorage {
|
|
10
|
+
/**
|
|
11
|
+
* Initialize storage
|
|
12
|
+
* @param {string} namespace - Storage namespace (appName)
|
|
13
|
+
*/
|
|
14
|
+
async init(namespace) {
|
|
15
|
+
throw new Error('init() must be implemented by subclass');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Store event
|
|
20
|
+
* @param {string} key - Event ID or d-tag
|
|
21
|
+
* @param {Object} event - Nostr event
|
|
22
|
+
*/
|
|
23
|
+
async put(key, event) {
|
|
24
|
+
throw new Error('put() must be implemented by subclass');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Retrieve event
|
|
29
|
+
* @param {string} key - Event ID or d-tag
|
|
30
|
+
* @returns {Promise<Object|null>} Event or null
|
|
31
|
+
*/
|
|
32
|
+
async get(key) {
|
|
33
|
+
throw new Error('get() must be implemented by subclass');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get all events matching prefix
|
|
38
|
+
* @param {string} prefix - Key prefix
|
|
39
|
+
* @returns {Promise<Object[]>} Array of events
|
|
40
|
+
*/
|
|
41
|
+
async getAll(prefix) {
|
|
42
|
+
throw new Error('getAll() must be implemented by subclass');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delete event
|
|
47
|
+
* @param {string} key - Event ID or d-tag
|
|
48
|
+
*/
|
|
49
|
+
async delete(key) {
|
|
50
|
+
throw new Error('delete() must be implemented by subclass');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear all data in namespace
|
|
55
|
+
*/
|
|
56
|
+
async clear() {
|
|
57
|
+
throw new Error('clear() must be implemented by subclass');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Close storage
|
|
62
|
+
*/
|
|
63
|
+
async close() {
|
|
64
|
+
throw new Error('close() must be implemented by subclass');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detect environment and return appropriate storage adapter
|
|
70
|
+
* @param {string} namespace - Storage namespace
|
|
71
|
+
* @param {Object} options - Storage options
|
|
72
|
+
* @returns {Promise<PersistentStorage>} Storage instance
|
|
73
|
+
*/
|
|
74
|
+
async function createPersistentStorage(namespace, options = {}) {
|
|
75
|
+
// Detect environment
|
|
76
|
+
const isBrowser = typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined';
|
|
77
|
+
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
|
|
78
|
+
|
|
79
|
+
if (isBrowser) {
|
|
80
|
+
// Use IndexedDB in browser
|
|
81
|
+
const { IndexedDBStorage } = await import('./indexeddb-storage.js');
|
|
82
|
+
const storage = new IndexedDBStorage();
|
|
83
|
+
await storage.init(namespace);
|
|
84
|
+
return storage;
|
|
85
|
+
} else if (isNode) {
|
|
86
|
+
// Use file system in Node.js
|
|
87
|
+
try {
|
|
88
|
+
const { FileSystemStorage } = await import('./filesystem-storage.js');
|
|
89
|
+
const storage = new FileSystemStorage(options.dataDir);
|
|
90
|
+
await storage.init(namespace);
|
|
91
|
+
return storage;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Fall back to memory storage if filesystem module fails to load
|
|
94
|
+
console.warn('FileSystemStorage not available, using MemoryStorage instead');
|
|
95
|
+
const { MemoryStorage } = await import('./memory-storage.js');
|
|
96
|
+
const storage = new MemoryStorage();
|
|
97
|
+
await storage.init(namespace);
|
|
98
|
+
return storage;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Fallback to memory-only storage
|
|
102
|
+
const { MemoryStorage } = await import('./memory-storage.js');
|
|
103
|
+
const storage = new MemoryStorage();
|
|
104
|
+
await storage.init(namespace);
|
|
105
|
+
return storage;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { PersistentStorage, createPersistentStorage };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncService - Background service for reliable sync
|
|
3
|
+
*
|
|
4
|
+
* Periodically processes the outbox queue to retry failed deliveries
|
|
5
|
+
* and purge old failed events.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class SyncService {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} client - NostrClient instance
|
|
11
|
+
* @param {Object} options - Configuration options
|
|
12
|
+
* @param {number} options.interval - Sync interval in ms (default: 10000)
|
|
13
|
+
* @param {boolean} options.autoStart - Start automatically (default: false)
|
|
14
|
+
*/
|
|
15
|
+
constructor(client, options = {}) {
|
|
16
|
+
this.client = client;
|
|
17
|
+
this.interval = options.interval || 10000; // 10 seconds
|
|
18
|
+
this.running = false;
|
|
19
|
+
this.timer = null;
|
|
20
|
+
this._processing = false;
|
|
21
|
+
|
|
22
|
+
if (options.autoStart) {
|
|
23
|
+
this.start();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start the background sync service
|
|
29
|
+
*/
|
|
30
|
+
start() {
|
|
31
|
+
if (this.running) return;
|
|
32
|
+
this.running = true;
|
|
33
|
+
this._scheduleNextRun();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stop the background sync service
|
|
38
|
+
*/
|
|
39
|
+
stop() {
|
|
40
|
+
this.running = false;
|
|
41
|
+
if (this.timer) {
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
this.timer = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if the service is running
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
isRunning() {
|
|
52
|
+
return this.running;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Force an immediate sync (useful for testing or manual trigger)
|
|
57
|
+
* @returns {Promise<Object>} Sync result
|
|
58
|
+
*/
|
|
59
|
+
async syncNow() {
|
|
60
|
+
return this._processOutbox();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Schedule the next run
|
|
65
|
+
* @private
|
|
66
|
+
*/
|
|
67
|
+
_scheduleNextRun() {
|
|
68
|
+
if (!this.running) return;
|
|
69
|
+
|
|
70
|
+
this.timer = setTimeout(() => {
|
|
71
|
+
this._runLoop();
|
|
72
|
+
}, this.interval);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Main loop iteration
|
|
77
|
+
* @private
|
|
78
|
+
*/
|
|
79
|
+
async _runLoop() {
|
|
80
|
+
if (!this.running) return;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await this._processOutbox();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.warn('[sync] Outbox processing failed:', error.message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Schedule next run
|
|
89
|
+
this._scheduleNextRun();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Process pending events in the outbox
|
|
94
|
+
* @private
|
|
95
|
+
* @returns {Promise<Object>} Processing result
|
|
96
|
+
*/
|
|
97
|
+
async _processOutbox() {
|
|
98
|
+
// Prevent concurrent processing
|
|
99
|
+
if (this._processing) {
|
|
100
|
+
return { skipped: true, reason: 'already processing' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this._processing = true;
|
|
104
|
+
|
|
105
|
+
const result = {
|
|
106
|
+
processed: 0,
|
|
107
|
+
succeeded: 0,
|
|
108
|
+
failed: 0,
|
|
109
|
+
purged: 0,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
if (!this.client.outboxQueue) {
|
|
114
|
+
return { ...result, skipped: true, reason: 'no outbox queue' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get pending events ready for retry
|
|
118
|
+
const pending = await this.client.outboxQueue.getPendingEvents();
|
|
119
|
+
result.processed = pending.length;
|
|
120
|
+
|
|
121
|
+
// Process each pending event
|
|
122
|
+
for (const entry of pending) {
|
|
123
|
+
try {
|
|
124
|
+
const deliveryResult = await this.client._attemptDelivery(entry.event, entry.relays);
|
|
125
|
+
|
|
126
|
+
if (deliveryResult.successful.length > 0) {
|
|
127
|
+
result.succeeded++;
|
|
128
|
+
} else {
|
|
129
|
+
result.failed++;
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn(`[sync] Retry failed for ${entry.id}:`, error.message);
|
|
133
|
+
result.failed++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Purge old failed events (24 hour TTL)
|
|
138
|
+
result.purged = await this.client.outboxQueue.purgeOldFailed();
|
|
139
|
+
|
|
140
|
+
} finally {
|
|
141
|
+
this._processing = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get sync service statistics
|
|
149
|
+
* @returns {Promise<Object>} Stats including queue status
|
|
150
|
+
*/
|
|
151
|
+
async getStats() {
|
|
152
|
+
const stats = {
|
|
153
|
+
running: this.running,
|
|
154
|
+
interval: this.interval,
|
|
155
|
+
queue: null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (this.client.outboxQueue) {
|
|
159
|
+
stats.queue = await this.client.outboxQueue.getStats();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return stats;
|
|
163
|
+
}
|
|
164
|
+
}
|