holosphere 2.0.0-alpha7 → 2.0.0-alpha8
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/dist/cjs/holosphere.cjs +1 -1
- package/dist/esm/holosphere.js +1 -1
- package/dist/{index-d6f4RJBM.js → index-4XHHKe6S.js} +356 -58
- package/dist/index-4XHHKe6S.js.map +1 -0
- package/dist/{index-jmTHEbR2.js → index-BjP1TXGz.js} +2 -2
- package/dist/{index-jmTHEbR2.js.map → index-BjP1TXGz.js.map} +1 -1
- package/dist/{index-C-IlLYlk.cjs → index-CKffQDmQ.cjs} +2 -2
- package/dist/{index-C-IlLYlk.cjs.map → index-CKffQDmQ.cjs.map} +1 -1
- package/dist/index-Dz5kOZMI.cjs +5 -0
- package/dist/index-Dz5kOZMI.cjs.map +1 -0
- package/dist/{indexeddb-storage-a8GipaDr.cjs → indexeddb-storage-DD7EFBVc.cjs} +2 -2
- package/dist/{indexeddb-storage-a8GipaDr.cjs.map → indexeddb-storage-DD7EFBVc.cjs.map} +1 -1
- package/dist/{indexeddb-storage-D8kOl0oK.js → indexeddb-storage-lExjjFlV.js} +2 -2
- package/dist/{indexeddb-storage-D8kOl0oK.js.map → indexeddb-storage-lExjjFlV.js.map} +1 -1
- package/dist/{memory-storage-DBQK622V.js → memory-storage-C68adso2.js} +2 -2
- package/dist/{memory-storage-DBQK622V.js.map → memory-storage-C68adso2.js.map} +1 -1
- package/dist/{memory-storage-gfRovk2O.cjs → memory-storage-DD_6yyXT.cjs} +2 -2
- package/dist/{memory-storage-gfRovk2O.cjs.map → memory-storage-DD_6yyXT.cjs.map} +1 -1
- package/dist/{secp256k1-BCAPF45D.cjs → secp256k1-DYELiqgx.cjs} +2 -2
- package/dist/{secp256k1-BCAPF45D.cjs.map → secp256k1-DYELiqgx.cjs.map} +1 -1
- package/dist/{secp256k1-DYm_CMqW.js → secp256k1-OM8siPyy.js} +2 -2
- package/dist/{secp256k1-DYm_CMqW.js.map → secp256k1-OM8siPyy.js.map} +1 -1
- package/examples/holosphere-widget.js +1242 -0
- package/examples/widget-demo.html +274 -0
- package/examples/widget.html +703 -0
- package/package.json +3 -1
- package/src/cdn-entry.js +22 -0
- package/src/contracts/queries.js +16 -1
- package/src/core/holosphere.js +2 -2
- package/src/crypto/nostr-utils.js +36 -2
- package/src/federation/handshake.js +16 -4
- package/src/index.js +16 -2
- package/src/storage/backends/gundb-backend.js +293 -9
- package/src/storage/gun-wrapper.js +64 -16
- package/src/storage/nostr-async.js +40 -25
- package/src/storage/unified-storage.js +31 -1
- package/vite.config.cdn.js +60 -0
- package/dist/index-Bvwyvd0T.cjs +0 -5
- package/dist/index-Bvwyvd0T.cjs.map +0 -1
- package/dist/index-d6f4RJBM.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "holosphere",
|
|
3
|
-
"version": "2.0.0-
|
|
3
|
+
"version": "2.0.0-alpha8",
|
|
4
4
|
"description": "Holonic geospatial communication infrastructure combining H3 hexagonal indexing with distributed P2P storage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"dev": "vite",
|
|
21
21
|
"build": "vite build",
|
|
22
|
+
"build:cdn": "vite build --config vite.config.cdn.js",
|
|
23
|
+
"build:all": "npm run build && npm run build:cdn",
|
|
22
24
|
"test": "vitest run",
|
|
23
25
|
"test:watch": "vitest",
|
|
24
26
|
"test:coverage": "vitest run --coverage",
|
package/src/cdn-entry.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN Entry Point for HoloSphere
|
|
3
|
+
*
|
|
4
|
+
* This file creates a clean global API for browser usage.
|
|
5
|
+
* After including the script, use: new HoloSphere({ appName: 'my-app' })
|
|
6
|
+
*
|
|
7
|
+
* Also exposes h3 library as window.h3 for hexagon visualization.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Import the main class
|
|
11
|
+
import { HoloSphere } from './index.js';
|
|
12
|
+
|
|
13
|
+
// Import h3-js for hexagon boundary visualization
|
|
14
|
+
import * as h3 from 'h3-js';
|
|
15
|
+
|
|
16
|
+
// Expose h3 globally for widget use
|
|
17
|
+
if (typeof window !== 'undefined') {
|
|
18
|
+
window.h3 = h3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Export only the main class as default
|
|
22
|
+
export default HoloSphere;
|
package/src/contracts/queries.js
CHANGED
|
@@ -6,7 +6,22 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ethers } from 'ethers';
|
|
9
|
-
|
|
9
|
+
// Import ABIs directly to avoid circular dependency with index.js
|
|
10
|
+
import SplitterABI from './abis/Splitter.json' with { type: 'json' };
|
|
11
|
+
import ManagedABI from './abis/Managed.json' with { type: 'json' };
|
|
12
|
+
import ZonedABI from './abis/Zoned.json' with { type: 'json' };
|
|
13
|
+
import AppreciativeABI from './abis/Appreciative.json' with { type: 'json' };
|
|
14
|
+
import BundleABI from './abis/Bundle.json' with { type: 'json' };
|
|
15
|
+
import HolonsABI from './abis/Holons.json' with { type: 'json' };
|
|
16
|
+
|
|
17
|
+
const ContractABIs = {
|
|
18
|
+
Splitter: SplitterABI,
|
|
19
|
+
Managed: ManagedABI,
|
|
20
|
+
Zoned: ZonedABI,
|
|
21
|
+
Appreciative: AppreciativeABI,
|
|
22
|
+
Bundle: BundleABI,
|
|
23
|
+
Holons: HolonsABI
|
|
24
|
+
};
|
|
10
25
|
|
|
11
26
|
/**
|
|
12
27
|
* Helper to format wei to ETH
|
package/src/core/holosphere.js
CHANGED
|
@@ -61,7 +61,7 @@ export class HoloSphere {
|
|
|
61
61
|
* @param {string} config.backend - Storage backend: 'nostr' | 'gundb' | 'activitypub' (default: 'nostr')
|
|
62
62
|
* @param {string[]} config.relays - Nostr relay URLs (default from HOLOSPHERE_RELAYS env or ['wss://relay.holons.io', 'wss://relay.nostr.band'])
|
|
63
63
|
* @param {string} config.privateKey - Private key for signing (hex format, optional)
|
|
64
|
-
* @param {string} config.logLevel - Log verbosity: ERROR|WARN|INFO|DEBUG (default: '
|
|
64
|
+
* @param {string} config.logLevel - Log verbosity: ERROR|WARN|INFO|DEBUG (default: 'INFO')
|
|
65
65
|
* @param {boolean} config.hybridMode - Enable hybrid mode (local + relay queries) (default: true)
|
|
66
66
|
* @param {Object} config.gundb - GunDB-specific configuration
|
|
67
67
|
* @param {Object} config.activitypub - ActivityPub-specific configuration
|
|
@@ -97,7 +97,7 @@ export class HoloSphere {
|
|
|
97
97
|
backend: config.backend || 'nostr',
|
|
98
98
|
relays: config.relays || getDefaultRelays(),
|
|
99
99
|
privateKey: config.privateKey || getEnv('HOLOSPHERE_PRIVATE_KEY'),
|
|
100
|
-
logLevel: config.logLevel || getEnv('HOLOSPHERE_LOG_LEVEL') || '
|
|
100
|
+
logLevel: config.logLevel || getEnv('HOLOSPHERE_LOG_LEVEL') || 'INFO',
|
|
101
101
|
hybridMode: config.hybridMode !== false, // Enable by default
|
|
102
102
|
};
|
|
103
103
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Nostr Utility Functions
|
|
3
3
|
*
|
|
4
|
-
* Provides browser-compatible utilities for Nostr key handling and
|
|
4
|
+
* Provides browser-compatible utilities for Nostr key handling and encryption.
|
|
5
|
+
* Supports both NIP-04 (legacy) and NIP-44 (modern, audited) encryption.
|
|
5
6
|
* Apps can use these without directly importing nostr-tools.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { nip04, nip19, getPublicKey as nostrGetPublicKey, finalizeEvent, verifyEvent as nostrVerifyEvent } from 'nostr-tools';
|
|
9
|
+
import { nip04, nip44, nip19, getPublicKey as nostrGetPublicKey, finalizeEvent, verifyEvent as nostrVerifyEvent } from 'nostr-tools';
|
|
9
10
|
|
|
10
11
|
// ============================================================================
|
|
11
12
|
// Key Conversion Utilities
|
|
@@ -160,6 +161,39 @@ export async function decryptNIP04(privateKey, senderPubKey, encryptedContent) {
|
|
|
160
161
|
return await nip04.decrypt(privateKey, senderPubKey, encryptedContent);
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// NIP-44 Encryption (Modern, Audited - Replaces NIP-04)
|
|
166
|
+
// ============================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Encrypt a message using NIP-44 (modern encryption standard)
|
|
170
|
+
* Uses ChaCha20 + HMAC-SHA256, audited by Cure53
|
|
171
|
+
* @param {string} privateKey - Sender's hex private key
|
|
172
|
+
* @param {string} recipientPubKey - Recipient's hex public key
|
|
173
|
+
* @param {string} content - Plain text content
|
|
174
|
+
* @returns {string} Encrypted content (base64)
|
|
175
|
+
*/
|
|
176
|
+
export function encryptNIP44(privateKey, recipientPubKey, content) {
|
|
177
|
+
const privKeyBytes = hexToBytes(privateKey);
|
|
178
|
+
// nostr-tools nip44 expects pubkey as hex string, not bytes
|
|
179
|
+
const conversationKey = nip44.v2.utils.getConversationKey(privKeyBytes, recipientPubKey);
|
|
180
|
+
return nip44.v2.encrypt(content, conversationKey);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Decrypt a NIP-44 encrypted message
|
|
185
|
+
* @param {string} privateKey - Recipient's hex private key
|
|
186
|
+
* @param {string} senderPubKey - Sender's hex public key
|
|
187
|
+
* @param {string} encryptedContent - Encrypted content (base64)
|
|
188
|
+
* @returns {string} Decrypted content
|
|
189
|
+
*/
|
|
190
|
+
export function decryptNIP44(privateKey, senderPubKey, encryptedContent) {
|
|
191
|
+
const privKeyBytes = hexToBytes(privateKey);
|
|
192
|
+
// nostr-tools nip44 expects pubkey as hex string, not bytes
|
|
193
|
+
const conversationKey = nip44.v2.utils.getConversationKey(privKeyBytes, senderPubKey);
|
|
194
|
+
return nip44.v2.decrypt(encryptedContent, conversationKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
163
197
|
// ============================================================================
|
|
164
198
|
// Event Creation
|
|
165
199
|
// ============================================================================
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Federation Handshake Protocol
|
|
3
3
|
*
|
|
4
|
-
* Uses NIP-
|
|
4
|
+
* Uses NIP-44 encrypted DMs (kind 4) for bidirectional federation request/response.
|
|
5
|
+
* Falls back to NIP-04 for backward compatibility when receiving messages.
|
|
5
6
|
* When user A federates with user B's pubkey, a DM is sent to B.
|
|
6
7
|
* B can accept/reject, creating a matching federation on their side.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import {
|
|
11
|
+
encryptNIP44,
|
|
12
|
+
decryptNIP44,
|
|
10
13
|
encryptNIP04,
|
|
11
14
|
decryptNIP04,
|
|
12
15
|
createDMEvent,
|
|
@@ -140,7 +143,7 @@ export function createFederationResponse({
|
|
|
140
143
|
export async function sendFederationRequest(client, privateKey, recipientPubKey, request) {
|
|
141
144
|
try {
|
|
142
145
|
const content = JSON.stringify(request);
|
|
143
|
-
const encrypted =
|
|
146
|
+
const encrypted = encryptNIP44(privateKey, recipientPubKey, content);
|
|
144
147
|
const event = createDMEvent(recipientPubKey, encrypted, privateKey);
|
|
145
148
|
|
|
146
149
|
if (client?.publish) {
|
|
@@ -168,7 +171,7 @@ export async function sendFederationRequest(client, privateKey, recipientPubKey,
|
|
|
168
171
|
export async function sendFederationResponse(client, privateKey, recipientPubKey, response) {
|
|
169
172
|
try {
|
|
170
173
|
const content = JSON.stringify(response);
|
|
171
|
-
const encrypted =
|
|
174
|
+
const encrypted = encryptNIP44(privateKey, recipientPubKey, content);
|
|
172
175
|
const event = createDMEvent(recipientPubKey, encrypted, privateKey);
|
|
173
176
|
|
|
174
177
|
if (client?.publish) {
|
|
@@ -218,7 +221,16 @@ export function subscribeToFederationDMs(client, privateKey, publicKey, handlers
|
|
|
218
221
|
if (!pTag || pTag[1] !== publicKey) return;
|
|
219
222
|
|
|
220
223
|
try {
|
|
221
|
-
|
|
224
|
+
let decrypted;
|
|
225
|
+
|
|
226
|
+
// Try NIP-44 first (modern encryption)
|
|
227
|
+
try {
|
|
228
|
+
decrypted = decryptNIP44(privateKey, event.pubkey, event.content);
|
|
229
|
+
} catch (nip44Error) {
|
|
230
|
+
// Fall back to NIP-04 for backward compatibility with older clients
|
|
231
|
+
decrypted = await decryptNIP04(privateKey, event.pubkey, event.content);
|
|
232
|
+
}
|
|
233
|
+
|
|
222
234
|
const payload = JSON.parse(decrypted);
|
|
223
235
|
|
|
224
236
|
if (payload.type === 'federation_request' && payload.version === '1.0') {
|
package/src/index.js
CHANGED
|
@@ -170,6 +170,7 @@ class HoloSphereBase extends HoloSphereCore {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
const path = storage.buildPath(this.config.appName, holonId, lensName, data.id);
|
|
173
|
+
this._log('DEBUG', 'write', { holonId, lensName, dataId: data.id, path });
|
|
173
174
|
const existingData = await storage.read(this.client, path);
|
|
174
175
|
|
|
175
176
|
// Handle hologram writes
|
|
@@ -286,10 +287,17 @@ class HoloSphereBase extends HoloSphereCore {
|
|
|
286
287
|
data.target.lensName,
|
|
287
288
|
data.target.dataId
|
|
288
289
|
);
|
|
290
|
+
this._log('DEBUG', 'resolving hologram', {
|
|
291
|
+
hologramId: data.id,
|
|
292
|
+
sourcePath,
|
|
293
|
+
targetHolon: data.target.holonId,
|
|
294
|
+
targetLens: data.target.lensName,
|
|
295
|
+
targetDataId: data.target.dataId
|
|
296
|
+
});
|
|
289
297
|
|
|
290
298
|
// Circular reference detection
|
|
291
299
|
if (visited.has(sourcePath)) {
|
|
292
|
-
|
|
300
|
+
this._log('WARN', 'Circular hologram reference detected', { sourcePath });
|
|
293
301
|
return null;
|
|
294
302
|
}
|
|
295
303
|
visited.add(sourcePath);
|
|
@@ -301,6 +309,7 @@ class HoloSphereBase extends HoloSphereCore {
|
|
|
301
309
|
}
|
|
302
310
|
|
|
303
311
|
const sourceData = await storage.read(this.client, sourcePath, resolveOptions);
|
|
312
|
+
this._log('DEBUG', 'hologram source fetched', { found: !!sourceData, sourcePath });
|
|
304
313
|
if (sourceData) {
|
|
305
314
|
// If source is also a hologram, recursively resolve it
|
|
306
315
|
let resolvedSource = sourceData;
|
|
@@ -369,14 +378,19 @@ class HoloSphereBase extends HoloSphereCore {
|
|
|
369
378
|
|
|
370
379
|
if (dataId) {
|
|
371
380
|
const path = storage.buildPath(this.config.appName, holonId, lensName, dataId);
|
|
381
|
+
this._log('DEBUG', 'read', { holonId, lensName, dataId, path });
|
|
372
382
|
result = await storage.read(this.client, path);
|
|
383
|
+
this._log('DEBUG', 'read result', { found: !!result, isHologram: result?.hologram === true });
|
|
373
384
|
} else {
|
|
374
385
|
const path = storage.buildPath(this.config.appName, holonId, lensName);
|
|
386
|
+
this._log('DEBUG', 'readAll', { holonId, lensName, path });
|
|
375
387
|
result = await storage.readAll(this.client, path);
|
|
388
|
+
this._log('DEBUG', 'readAll result', { count: Array.isArray(result) ? result.length : 0 });
|
|
376
389
|
}
|
|
377
390
|
|
|
378
391
|
const { resolveHolograms = true } = options;
|
|
379
|
-
if (resolveHolograms) {
|
|
392
|
+
if (resolveHolograms && result) {
|
|
393
|
+
this._log('DEBUG', 'resolving holograms', { itemCount: Array.isArray(result) ? result.length : 1 });
|
|
380
394
|
result = await this._resolveHolograms(result);
|
|
381
395
|
}
|
|
382
396
|
|
|
@@ -25,28 +25,61 @@ export class GunDBBackend extends StorageBackend {
|
|
|
25
25
|
// Subscription tracking
|
|
26
26
|
this.subscriptions = new Map();
|
|
27
27
|
this.subscriptionCounter = 0;
|
|
28
|
+
|
|
29
|
+
// Write cache for immediate consistency
|
|
30
|
+
// Gun's readAll/map().once() doesn't immediately see new writes - this cache bridges that gap
|
|
31
|
+
this.writeCache = new Map(); // path -> Map(id -> {data, timestamp})
|
|
32
|
+
this.writeCacheTTL = 300000; // Cache entries expire after 5 minutes (increased from 30s)
|
|
33
|
+
|
|
34
|
+
// Pending writes queue for retry
|
|
35
|
+
this.pendingWrites = new Map(); // path -> {data, retries, lastAttempt}
|
|
36
|
+
this.maxWriteRetries = 5;
|
|
37
|
+
this.writeRetryInterval = 10000; // 10 seconds between retries
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
async init() {
|
|
31
41
|
// Dynamically import Gun to avoid issues if not installed
|
|
32
42
|
let Gun;
|
|
33
43
|
try {
|
|
44
|
+
console.log('[gundb-backend] Importing Gun...');
|
|
34
45
|
const gunModule = await import('gun');
|
|
35
46
|
Gun = gunModule.default || gunModule;
|
|
47
|
+
console.log('[gundb-backend] Gun imported:', typeof Gun);
|
|
36
48
|
} catch (error) {
|
|
37
49
|
throw new Error(
|
|
38
50
|
'GunDB backend requires the "gun" package. Install it with: npm install gun'
|
|
39
51
|
);
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
// In Node.js, Gun needs a file path for radisk persistence
|
|
55
|
+
// Default to 'radata' if not specified
|
|
56
|
+
const dataDir = this.config.dataDir || 'radata';
|
|
57
|
+
|
|
42
58
|
const gunConfig = {
|
|
43
59
|
peers: this.config.peers || [],
|
|
44
60
|
radisk: this.config.radisk !== false,
|
|
45
61
|
localStorage: this.config.localStorage !== false,
|
|
46
|
-
file:
|
|
62
|
+
file: dataDir,
|
|
47
63
|
};
|
|
48
64
|
|
|
65
|
+
console.log('[gundb-backend] Gun config:', JSON.stringify(gunConfig));
|
|
66
|
+
|
|
49
67
|
this.gun = Gun(gunConfig);
|
|
68
|
+
console.log('[gundb-backend] Gun instance created:', !!this.gun);
|
|
69
|
+
|
|
70
|
+
// Test basic Gun functionality
|
|
71
|
+
try {
|
|
72
|
+
const testKey = `_test_${Date.now()}`;
|
|
73
|
+
this.gun.get(testKey).put({ test: true }, (ack) => {
|
|
74
|
+
console.log('[gundb-backend] Gun test write ack:', ack);
|
|
75
|
+
});
|
|
76
|
+
console.log('[gundb-backend] Gun test write initiated');
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('[gundb-backend] Gun test write failed:', e.message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add a small delay to let Gun initialize
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
50
83
|
|
|
51
84
|
// Generate or use provided key pair using Gun's SEA
|
|
52
85
|
try {
|
|
@@ -82,6 +115,111 @@ export class GunDBBackend extends StorageBackend {
|
|
|
82
115
|
await this.schemaValidator.init();
|
|
83
116
|
}
|
|
84
117
|
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// WRITE CACHE (for immediate consistency)
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Add item to the local write cache for immediate consistency
|
|
124
|
+
* @private
|
|
125
|
+
*/
|
|
126
|
+
_addToWriteCache(path, data) {
|
|
127
|
+
if (!data || !data.id) return;
|
|
128
|
+
|
|
129
|
+
// Extract the parent path (without the data id)
|
|
130
|
+
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
|
131
|
+
if (!parentPath) return;
|
|
132
|
+
|
|
133
|
+
if (!this.writeCache.has(parentPath)) {
|
|
134
|
+
this.writeCache.set(parentPath, new Map());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pathCache = this.writeCache.get(parentPath);
|
|
138
|
+
pathCache.set(data.id.toString(), {
|
|
139
|
+
data: data,
|
|
140
|
+
timestamp: Date.now()
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove item from write cache (e.g., on delete)
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
_removeFromWriteCache(path, id) {
|
|
149
|
+
// Extract the parent path
|
|
150
|
+
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
|
151
|
+
const pathCache = this.writeCache.get(parentPath);
|
|
152
|
+
if (pathCache) {
|
|
153
|
+
pathCache.delete(id.toString());
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get cached writes for a path, filtering out expired entries
|
|
159
|
+
* @private
|
|
160
|
+
*/
|
|
161
|
+
_getWriteCacheEntries(path) {
|
|
162
|
+
const pathCache = this.writeCache.get(path);
|
|
163
|
+
if (!pathCache) return [];
|
|
164
|
+
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const validEntries = [];
|
|
167
|
+
|
|
168
|
+
for (const [id, entry] of pathCache) {
|
|
169
|
+
if (now - entry.timestamp < this.writeCacheTTL) {
|
|
170
|
+
validEntries.push(entry.data);
|
|
171
|
+
} else {
|
|
172
|
+
// Clean up expired entries
|
|
173
|
+
pathCache.delete(id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return validEntries;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Merge cached writes with results from Gun
|
|
182
|
+
* @private
|
|
183
|
+
*/
|
|
184
|
+
_mergeWithWriteCache(path, gunResults) {
|
|
185
|
+
const cachedWrites = this._getWriteCacheEntries(path);
|
|
186
|
+
if (cachedWrites.length === 0) return gunResults;
|
|
187
|
+
|
|
188
|
+
// Create a map by ID for deduplication
|
|
189
|
+
const resultMap = new Map();
|
|
190
|
+
|
|
191
|
+
// Add Gun results first
|
|
192
|
+
for (const item of gunResults) {
|
|
193
|
+
if (item && item.id) {
|
|
194
|
+
resultMap.set(item.id.toString(), item);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Overlay cached writes (they're more recent)
|
|
199
|
+
for (const item of cachedWrites) {
|
|
200
|
+
if (item && item.id && !item._deleted) {
|
|
201
|
+
resultMap.set(item.id.toString(), item);
|
|
202
|
+
} else if (item && item.id && item._deleted) {
|
|
203
|
+
// Remove deleted items
|
|
204
|
+
resultMap.delete(item.id.toString());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return Array.from(resultMap.values());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Clear write cache for a specific path or all paths
|
|
213
|
+
* @param {string} [path] - Optional path to clear, clears all if not provided
|
|
214
|
+
*/
|
|
215
|
+
clearWriteCache(path = null) {
|
|
216
|
+
if (path) {
|
|
217
|
+
this.writeCache.delete(path);
|
|
218
|
+
} else {
|
|
219
|
+
this.writeCache.clear();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
85
223
|
// ============================================================================
|
|
86
224
|
// PATH BUILDING
|
|
87
225
|
// ============================================================================
|
|
@@ -116,7 +254,59 @@ export class GunDBBackend extends StorageBackend {
|
|
|
116
254
|
}
|
|
117
255
|
}
|
|
118
256
|
|
|
119
|
-
|
|
257
|
+
// Add to write cache FIRST for immediate consistency
|
|
258
|
+
// This ensures readAll returns the new data even if Gun write is slow/times out
|
|
259
|
+
this._addToWriteCache(path, data);
|
|
260
|
+
|
|
261
|
+
// Try to write to Gun with retry on timeout
|
|
262
|
+
const result = await this._writeWithRetry(path, data);
|
|
263
|
+
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Write to Gun with automatic retry on timeout
|
|
269
|
+
* @private
|
|
270
|
+
*/
|
|
271
|
+
async _writeWithRetry(path, data, attempt = 0) {
|
|
272
|
+
const result = await wrapper.write(this.gun, path, data);
|
|
273
|
+
|
|
274
|
+
if (result.timeout && attempt < this.maxWriteRetries) {
|
|
275
|
+
// Queue for background retry
|
|
276
|
+
this.pendingWrites.set(path, {
|
|
277
|
+
data,
|
|
278
|
+
retries: attempt + 1,
|
|
279
|
+
lastAttempt: Date.now()
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Schedule retry
|
|
283
|
+
this._scheduleRetry(path);
|
|
284
|
+
} else if (!result.timeout) {
|
|
285
|
+
// Success - remove from pending
|
|
286
|
+
this.pendingWrites.delete(path);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Schedule a background retry for a pending write
|
|
294
|
+
* @private
|
|
295
|
+
*/
|
|
296
|
+
_scheduleRetry(path) {
|
|
297
|
+
setTimeout(async () => {
|
|
298
|
+
const pending = this.pendingWrites.get(path);
|
|
299
|
+
if (!pending) return;
|
|
300
|
+
|
|
301
|
+
if (pending.retries >= this.maxWriteRetries) {
|
|
302
|
+
console.warn(`[gundb-backend] Max retries reached for: ${path}`);
|
|
303
|
+
this.pendingWrites.delete(path);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.log(`[gundb-backend] Retrying write (attempt ${pending.retries + 1}): ${path}`);
|
|
308
|
+
await this._writeWithRetry(path, pending.data, pending.retries);
|
|
309
|
+
}, this.writeRetryInterval);
|
|
120
310
|
}
|
|
121
311
|
|
|
122
312
|
async read(path, options = {}) {
|
|
@@ -131,7 +321,10 @@ export class GunDBBackend extends StorageBackend {
|
|
|
131
321
|
}
|
|
132
322
|
|
|
133
323
|
async readAll(path, options = {}) {
|
|
134
|
-
const
|
|
324
|
+
const gunItems = await wrapper.readAll(this.gun, path);
|
|
325
|
+
|
|
326
|
+
// Merge with write cache for immediate consistency
|
|
327
|
+
const items = this._mergeWithWriteCache(path, gunItems);
|
|
135
328
|
|
|
136
329
|
// Resolve references if requested
|
|
137
330
|
if (options.resolveReferences) {
|
|
@@ -157,7 +350,24 @@ export class GunDBBackend extends StorageBackend {
|
|
|
157
350
|
}
|
|
158
351
|
|
|
159
352
|
async delete(path) {
|
|
160
|
-
|
|
353
|
+
const result = await wrapper.deleteData(this.gun, path);
|
|
354
|
+
|
|
355
|
+
// Extract id from path and mark as deleted in cache
|
|
356
|
+
const pathParts = path.split('/');
|
|
357
|
+
const id = pathParts[pathParts.length - 1];
|
|
358
|
+
if (id) {
|
|
359
|
+
// Add tombstone to cache so readAll doesn't return this item
|
|
360
|
+
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
|
361
|
+
if (!this.writeCache.has(parentPath)) {
|
|
362
|
+
this.writeCache.set(parentPath, new Map());
|
|
363
|
+
}
|
|
364
|
+
this.writeCache.get(parentPath).set(id.toString(), {
|
|
365
|
+
data: { id, _deleted: true },
|
|
366
|
+
timestamp: Date.now()
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return result;
|
|
161
371
|
}
|
|
162
372
|
|
|
163
373
|
async deleteAll(path) {
|
|
@@ -180,7 +390,62 @@ export class GunDBBackend extends StorageBackend {
|
|
|
180
390
|
// ============================================================================
|
|
181
391
|
|
|
182
392
|
async writeGlobal(tableName, data) {
|
|
183
|
-
|
|
393
|
+
// Add to write cache FIRST for immediate consistency
|
|
394
|
+
// This ensures readAllGlobal returns the new data even if Gun write is slow/times out
|
|
395
|
+
const path = this.buildGlobalPath(tableName, data.id);
|
|
396
|
+
this._addToWriteCache(path, data);
|
|
397
|
+
|
|
398
|
+
// Try to write to Gun with retry on timeout
|
|
399
|
+
const result = await this._writeGlobalWithRetry(tableName, data);
|
|
400
|
+
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Write to global table with automatic retry on timeout
|
|
406
|
+
* @private
|
|
407
|
+
*/
|
|
408
|
+
async _writeGlobalWithRetry(tableName, data, attempt = 0) {
|
|
409
|
+
const path = this.buildGlobalPath(tableName, data.id);
|
|
410
|
+
const result = await wrapper.writeGlobal(this.gun, this.appName, tableName, data);
|
|
411
|
+
|
|
412
|
+
if (result.timeout && attempt < this.maxWriteRetries) {
|
|
413
|
+
// Queue for background retry
|
|
414
|
+
this.pendingWrites.set(path, {
|
|
415
|
+
data: { tableName, data },
|
|
416
|
+
isGlobal: true,
|
|
417
|
+
retries: attempt + 1,
|
|
418
|
+
lastAttempt: Date.now()
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Schedule retry
|
|
422
|
+
this._scheduleGlobalRetry(path, tableName);
|
|
423
|
+
} else if (!result.timeout) {
|
|
424
|
+
// Success - remove from pending
|
|
425
|
+
this.pendingWrites.delete(path);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Schedule a background retry for a pending global write
|
|
433
|
+
* @private
|
|
434
|
+
*/
|
|
435
|
+
_scheduleGlobalRetry(path, tableName) {
|
|
436
|
+
setTimeout(async () => {
|
|
437
|
+
const pending = this.pendingWrites.get(path);
|
|
438
|
+
if (!pending || !pending.isGlobal) return;
|
|
439
|
+
|
|
440
|
+
if (pending.retries >= this.maxWriteRetries) {
|
|
441
|
+
console.warn(`[gundb-backend] Max retries reached for global: ${path}`);
|
|
442
|
+
this.pendingWrites.delete(path);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
console.log(`[gundb-backend] Retrying global write (attempt ${pending.retries + 1}): ${path}`);
|
|
447
|
+
await this._writeGlobalWithRetry(tableName, pending.data.data, pending.retries);
|
|
448
|
+
}, this.writeRetryInterval);
|
|
184
449
|
}
|
|
185
450
|
|
|
186
451
|
async readGlobal(tableName, key) {
|
|
@@ -188,11 +453,27 @@ export class GunDBBackend extends StorageBackend {
|
|
|
188
453
|
}
|
|
189
454
|
|
|
190
455
|
async readAllGlobal(tableName, timeout = 5000) {
|
|
191
|
-
|
|
456
|
+
const gunItems = await wrapper.readAllGlobal(this.gun, this.appName, tableName, timeout);
|
|
457
|
+
|
|
458
|
+
// Merge with write cache for immediate consistency
|
|
459
|
+
const path = this.buildGlobalPath(tableName);
|
|
460
|
+
return this._mergeWithWriteCache(path, gunItems);
|
|
192
461
|
}
|
|
193
462
|
|
|
194
463
|
async deleteGlobal(tableName, key) {
|
|
195
|
-
|
|
464
|
+
const result = await wrapper.deleteGlobal(this.gun, this.appName, tableName, key);
|
|
465
|
+
|
|
466
|
+
// Mark as deleted in cache
|
|
467
|
+
const path = this.buildGlobalPath(tableName);
|
|
468
|
+
if (!this.writeCache.has(path)) {
|
|
469
|
+
this.writeCache.set(path, new Map());
|
|
470
|
+
}
|
|
471
|
+
this.writeCache.get(path).set(key.toString(), {
|
|
472
|
+
data: { id: key, _deleted: true },
|
|
473
|
+
timestamp: Date.now()
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return result;
|
|
196
477
|
}
|
|
197
478
|
|
|
198
479
|
async deleteAllGlobal(tableName) {
|
|
@@ -517,12 +798,15 @@ export class GunDBBackend extends StorageBackend {
|
|
|
517
798
|
this.schemaValidator.clearCache();
|
|
518
799
|
}
|
|
519
800
|
|
|
520
|
-
// 3.
|
|
801
|
+
// 3. Clear write cache
|
|
802
|
+
this.writeCache.clear();
|
|
803
|
+
|
|
804
|
+
// 4. Logout auth
|
|
521
805
|
if (this.auth) {
|
|
522
806
|
this.auth.logout();
|
|
523
807
|
}
|
|
524
808
|
|
|
525
|
-
//
|
|
809
|
+
// 5. Clean up Gun connections
|
|
526
810
|
if (this.gun) {
|
|
527
811
|
try {
|
|
528
812
|
// Clean up mesh connections
|