holosphere 1.3.0-alpha4 → 1.3.0-alpha7
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/content.js +219 -46
- package/federation.js +47 -13
- package/global.js +49 -4
- package/hologram.js +21 -4
- package/holosphere-bundle.esm.js +532 -283
- package/holosphere-bundle.js +532 -283
- package/holosphere-bundle.min.js +8 -8
- package/holosphere.d.ts +96 -9
- package/holosphere.js +53 -8
- package/package.json +1 -1
- package/utils.js +29 -7
package/content.js
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
// holo_content.js
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Default deadline (ms) for the read paths' `.once()` calls. Gun's `.once()`
|
|
5
|
+
* never fires when the requested node isn't in the local graph and no peer
|
|
6
|
+
* answers (cold-start, offline, partitioned mesh) — past consumers worked
|
|
7
|
+
* around this by wrapping every `getAll` in their own `Promise.race(reject,
|
|
8
|
+
* setTimeout(8000))`. Owning it here means the spinner can never hang
|
|
9
|
+
* indefinitely on a cold path and every caller stops needing the wrapper.
|
|
10
|
+
*
|
|
11
|
+
* Pick `READ_TIMEOUT_MS = 0` (or pass `{ timeout: 0 }`) to opt out of the
|
|
12
|
+
* fallback and keep the historical "wait forever" behaviour.
|
|
13
|
+
*/
|
|
14
|
+
const READ_TIMEOUT_MS = 8000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default deadline (ms) for the put-path's Gun ack callback. Gun fires the
|
|
18
|
+
* ack only after the local commit + at least one peer ack chain — with no
|
|
19
|
+
* reachable peer (cold start, offline, partitioned mesh) it never fires
|
|
20
|
+
* and the consumer's `await holosphere.put(...)` hangs forever. UIs
|
|
21
|
+
* worked around this by racing every `put` against their own timeout;
|
|
22
|
+
* owning the deadline here makes every consumer local-first by default.
|
|
23
|
+
*
|
|
24
|
+
* On timeout the returned promise resolves with a `queued: true` sentinel
|
|
25
|
+
* (see `put` return shape) — Gun keeps the write in its local queue and
|
|
26
|
+
* the ack callback's side effects (subscriber notification, hologram
|
|
27
|
+
* cascade, federation propagation) run later when a peer reappears.
|
|
28
|
+
*
|
|
29
|
+
* Pick `WRITE_TIMEOUT_MS = 0` (or pass `{ timeout: 0 }`) to opt out of
|
|
30
|
+
* the fallback and keep the historical "wait for ack" behaviour.
|
|
31
|
+
*/
|
|
32
|
+
const WRITE_TIMEOUT_MS = 5000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `.once()` wrapped in a deadline. Resolves with the value when Gun fires
|
|
36
|
+
* back, or `null` after `timeoutMs` if it hasn't. Pass `timeoutMs <= 0` to
|
|
37
|
+
* disable the deadline.
|
|
38
|
+
*
|
|
39
|
+
* The first responder wins — both branches are idempotent so a late
|
|
40
|
+
* `.once()` callback after timeout is harmlessly ignored.
|
|
41
|
+
*/
|
|
42
|
+
function onceWithTimeout(node, timeoutMs = READ_TIMEOUT_MS) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let done = false;
|
|
45
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
46
|
+
node.once((data) => finish(data));
|
|
47
|
+
if (timeoutMs > 0) {
|
|
48
|
+
setTimeout(() => finish(null), timeoutMs);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Race a put-result promise against a deadline. If the underlying Gun
|
|
55
|
+
* ack never arrives, resolve with `queuedResult` so the caller stops
|
|
56
|
+
* waiting; Gun keeps the write in its local queue and replays it when
|
|
57
|
+
* a peer is available.
|
|
58
|
+
*
|
|
59
|
+
* The first responder wins — both branches are idempotent so the late
|
|
60
|
+
* ack-driven resolution after timeout is harmlessly ignored.
|
|
61
|
+
*/
|
|
62
|
+
function withWriteTimeout(promise, timeoutMs, queuedResult) {
|
|
63
|
+
if (!timeoutMs || timeoutMs <= 0) return promise;
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
let done = false;
|
|
66
|
+
const finish = (fn, val) => { if (!done) { done = true; fn(val); } };
|
|
67
|
+
promise.then(
|
|
68
|
+
(v) => finish(resolve, v),
|
|
69
|
+
(e) => finish(reject, e)
|
|
70
|
+
);
|
|
71
|
+
setTimeout(() => finish(resolve, queuedResult), timeoutMs);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
3
75
|
/**
|
|
4
76
|
* Recursively sanitizes a value for storage in GunDB.
|
|
5
77
|
*
|
|
@@ -86,7 +158,11 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
86
158
|
throw new Error('put: Missing required holon or lens parameters:', holon, lens);
|
|
87
159
|
}
|
|
88
160
|
|
|
89
|
-
const {
|
|
161
|
+
const {
|
|
162
|
+
disableHologramRedirection = false,
|
|
163
|
+
timeout: writeTimeoutOverride
|
|
164
|
+
} = options;
|
|
165
|
+
const writeTimeoutMs = writeTimeoutOverride !== undefined ? writeTimeoutOverride : WRITE_TIMEOUT_MS;
|
|
90
166
|
|
|
91
167
|
let targetHolon = holon;
|
|
92
168
|
let targetLens = lens;
|
|
@@ -199,7 +275,7 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
199
275
|
});
|
|
200
276
|
}
|
|
201
277
|
|
|
202
|
-
|
|
278
|
+
const ackPromise = new Promise((resolve, reject) => {
|
|
203
279
|
try {
|
|
204
280
|
// Sanitize before serialization so undefined/NaN/Infinity etc.
|
|
205
281
|
// can never produce a malformed payload like `"initiated":,`
|
|
@@ -214,8 +290,25 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
214
290
|
}
|
|
215
291
|
// Strip read-side envelopes that must never be persisted
|
|
216
292
|
// (they're attached at resolution time).
|
|
293
|
+
//
|
|
294
|
+
// `_hologram` and `_meta` are always read-side-only.
|
|
295
|
+
//
|
|
296
|
+
// `_federation` is also read-side for ordinary writes — it
|
|
297
|
+
// describes where data was fetched from, not where it
|
|
298
|
+
// currently lives. The one legitimate carrier is federation
|
|
299
|
+
// propagation, which writes hologram envelopes (top-level
|
|
300
|
+
// `id` + `soul`) tagged with `_federation` provenance; we
|
|
301
|
+
// detect that via `isHologram(data)` and leave it alone.
|
|
302
|
+
// Without this, a UI that reads a federated record and puts
|
|
303
|
+
// it back ends up persisting stale `_federation.origin`
|
|
304
|
+
// metadata, which then drives downstream code (federation
|
|
305
|
+
// propagators, hologram resolvers) to write or follow
|
|
306
|
+
// pointers that don't match the current storage location —
|
|
307
|
+
// producing "broken hologram" garbage-collection cascades
|
|
308
|
+
// that silently delete the user's write.
|
|
217
309
|
if (dataToStore._meta !== undefined) delete dataToStore._meta;
|
|
218
310
|
if (dataToStore._hologram !== undefined) delete dataToStore._hologram;
|
|
311
|
+
if (!isHologram && dataToStore._federation !== undefined) delete dataToStore._federation;
|
|
219
312
|
const payload = JSON.stringify(dataToStore); // The data being stored
|
|
220
313
|
|
|
221
314
|
const putCallback = async (ack) => {
|
|
@@ -241,22 +334,38 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
241
334
|
}
|
|
242
335
|
// --- End: Hologram Tracking Logic ---
|
|
243
336
|
|
|
244
|
-
// --- Start: Active Hologram Update Logic
|
|
337
|
+
// --- Start: Active Hologram Update Logic ---
|
|
338
|
+
//
|
|
339
|
+
// Walks this node's `_holograms` set and stamps every
|
|
340
|
+
// registered hologram with `updated: now` so consumers
|
|
341
|
+
// re-resolve and see the latest source data.
|
|
342
|
+
//
|
|
343
|
+
// Runs for BOTH original-data puts and hologram-update
|
|
344
|
+
// puts so updates cascade through multi-hop forwards
|
|
345
|
+
// (A → B → C → …) even when the second hop's
|
|
346
|
+
// cross-holon registration on the original source
|
|
347
|
+
// can't be relied on to make it across the Gun mesh.
|
|
348
|
+
// Each hop maintains its own local `_holograms` set
|
|
349
|
+
// tracking the next hops, and we walk that set on
|
|
350
|
+
// every put — cycle-protected via
|
|
351
|
+
// `options._cascadeVisited`.
|
|
245
352
|
let updatedHolograms = [];
|
|
246
|
-
|
|
353
|
+
const currentDataSoul = `${holoInstance.appname}/${targetHolon}/${targetLens}/${targetKey}`;
|
|
354
|
+
const cascadeVisited = new Set(options._cascadeVisited || []);
|
|
355
|
+
if (!cascadeVisited.has(currentDataSoul)) {
|
|
356
|
+
cascadeVisited.add(currentDataSoul);
|
|
247
357
|
try {
|
|
248
|
-
const currentDataSoul = `${holoInstance.appname}/${targetHolon}/${targetLens}/${targetKey}`;
|
|
249
358
|
const currentNodeRef = holoInstance.getNodeRef(currentDataSoul);
|
|
250
|
-
|
|
251
|
-
// Get the _holograms set for this
|
|
359
|
+
|
|
360
|
+
// Get the _holograms set for this node
|
|
252
361
|
await new Promise((resolveHologramUpdate) => {
|
|
253
362
|
currentNodeRef.get('_holograms').once(async (hologramsSet) => {
|
|
254
363
|
if (hologramsSet) {
|
|
255
|
-
const hologramSouls = Object.keys(hologramsSet).filter(k =>
|
|
256
|
-
k !== '_' && hologramsSet[k] === true
|
|
364
|
+
const hologramSouls = Object.keys(hologramsSet).filter(k =>
|
|
365
|
+
k !== '_' && hologramsSet[k] === true && !cascadeVisited.has(k)
|
|
257
366
|
);
|
|
258
|
-
|
|
259
|
-
|
|
367
|
+
|
|
368
|
+
if (hologramSouls.length > 0) {
|
|
260
369
|
// Update each active hologram with an 'updated' timestamp
|
|
261
370
|
const updatePromises = hologramSouls.map(async (hologramSoul) => {
|
|
262
371
|
try {
|
|
@@ -270,26 +379,31 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
270
379
|
null,
|
|
271
380
|
{ resolveHolograms: false }
|
|
272
381
|
);
|
|
273
|
-
|
|
382
|
+
|
|
274
383
|
if (currentHologram) {
|
|
275
384
|
// Update the hologram with an 'updated' timestamp
|
|
276
385
|
const updatedHologram = {
|
|
277
386
|
...currentHologram,
|
|
278
387
|
updated: Date.now()
|
|
279
388
|
};
|
|
280
|
-
|
|
389
|
+
|
|
281
390
|
await holoInstance.put(
|
|
282
391
|
hologramSoulInfo.holon,
|
|
283
392
|
hologramSoulInfo.lens,
|
|
284
393
|
updatedHologram,
|
|
285
394
|
null,
|
|
286
|
-
{
|
|
395
|
+
{
|
|
287
396
|
autoPropagate: false, // Don't auto-propagate hologram updates
|
|
288
397
|
disableHologramRedirection: true, // Prevent redirection when updating holograms
|
|
289
|
-
isHologramUpdate: true
|
|
398
|
+
isHologramUpdate: true,
|
|
399
|
+
// Carry the visited set forward so the
|
|
400
|
+
// recursive put keeps cascading through
|
|
401
|
+
// this hop's `_holograms` set without
|
|
402
|
+
// looping back through us.
|
|
403
|
+
_cascadeVisited: cascadeVisited
|
|
290
404
|
}
|
|
291
405
|
);
|
|
292
|
-
|
|
406
|
+
|
|
293
407
|
// Add to the list of updated holograms
|
|
294
408
|
updatedHolograms.push({
|
|
295
409
|
soul: hologramSoul,
|
|
@@ -305,7 +419,7 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
305
419
|
console.warn(`Error updating hologram ${hologramSoul}:`, hologramUpdateError);
|
|
306
420
|
}
|
|
307
421
|
});
|
|
308
|
-
|
|
422
|
+
|
|
309
423
|
await Promise.all(updatePromises);
|
|
310
424
|
}
|
|
311
425
|
}
|
|
@@ -375,6 +489,22 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
375
489
|
reject(error);
|
|
376
490
|
}
|
|
377
491
|
});
|
|
492
|
+
|
|
493
|
+
// Bound the wait on Gun's put ack so an offline/partitioned mesh
|
|
494
|
+
// doesn't hang the caller forever. Gun keeps the local write and
|
|
495
|
+
// its eventual ack callback (subscriber notify, hologram cascade,
|
|
496
|
+
// propagation) still runs when a peer reappears — we just stop
|
|
497
|
+
// blocking the awaiting consumer in the meantime.
|
|
498
|
+
return withWriteTimeout(ackPromise, writeTimeoutMs, {
|
|
499
|
+
success: true,
|
|
500
|
+
queued: true,
|
|
501
|
+
isHologramAtPath: isHologram,
|
|
502
|
+
pathHolon: targetHolon,
|
|
503
|
+
pathLens: targetLens,
|
|
504
|
+
pathKey: targetKey,
|
|
505
|
+
propagationResult: null,
|
|
506
|
+
updatedHolograms: []
|
|
507
|
+
});
|
|
378
508
|
} catch (error) {
|
|
379
509
|
console.error('Error in put:', error);
|
|
380
510
|
throw error;
|
|
@@ -400,7 +530,19 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
400
530
|
}
|
|
401
531
|
|
|
402
532
|
// Destructure options, including visited
|
|
403
|
-
const {
|
|
533
|
+
const {
|
|
534
|
+
resolveHolograms = true,
|
|
535
|
+
validationOptions = {},
|
|
536
|
+
visited,
|
|
537
|
+
timeout = READ_TIMEOUT_MS,
|
|
538
|
+
// `_deleted: true` is the soft-tombstone convention used by the bot,
|
|
539
|
+
// the web dashboard, and the MCP council tools. Pre-this fix the
|
|
540
|
+
// library was unaware of it and every caller filtered defensively.
|
|
541
|
+
// Now `get` returns `null` for tombstoned records by default; pass
|
|
542
|
+
// `includeDeleted: true` to surface them (admin/debug views,
|
|
543
|
+
// history reconstruction, etc.).
|
|
544
|
+
includeDeleted = false,
|
|
545
|
+
} = options;
|
|
404
546
|
|
|
405
547
|
// Get schema for validation if in strict mode
|
|
406
548
|
let schema = null;
|
|
@@ -444,6 +586,14 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
444
586
|
return;
|
|
445
587
|
}
|
|
446
588
|
|
|
589
|
+
// Treat the harvest-side `_deleted: true` soft-tombstone
|
|
590
|
+
// as "not found" by default. Callers that want to see
|
|
591
|
+
// tombstones can pass `{ includeDeleted: true }`.
|
|
592
|
+
if (!includeDeleted && parsed._deleted === true) {
|
|
593
|
+
resolve(null);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
447
597
|
// Check if this is a hologram that needs to be resolved
|
|
448
598
|
if (resolveHolograms && holoInstance.isHologram(parsed)) {
|
|
449
599
|
const resolvedValue = await holoInstance.resolveHologram(parsed, {
|
|
@@ -454,17 +604,20 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
454
604
|
});
|
|
455
605
|
|
|
456
606
|
if (resolvedValue === null) {
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
607
|
+
// `resolveHologram` returned null. DON'T treat this
|
|
608
|
+
// as a permission to delete — null fires for several
|
|
609
|
+
// transient reasons:
|
|
610
|
+
// - source soul not in our local Gun graph yet
|
|
611
|
+
// (peer offline, federation propagation in flight)
|
|
612
|
+
// - maxDepth (10) reached on a deep hologram chain
|
|
613
|
+
// - circular reference detected mid-chain
|
|
614
|
+
// - any internal resolve error
|
|
615
|
+
// None of these prove the pointer is permanently
|
|
616
|
+
// broken, but the old behaviour `await delete(...)`
|
|
617
|
+
// here permanently destroyed real data on the first
|
|
618
|
+
// transient miss. Skip the entry instead; a real
|
|
619
|
+
// garbage collector should own dead-pointer cleanup.
|
|
620
|
+
console.warn(`Hologram at ${holon}/${lens}/${key} did not resolve (soul=${parsed.soul}); skipping.`);
|
|
468
621
|
resolve(null);
|
|
469
622
|
return;
|
|
470
623
|
}
|
|
@@ -472,7 +625,7 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
472
625
|
// If it returned the hologram itself (if we ever revert to that), this logic would need adjustment.
|
|
473
626
|
// For now, assume resolvedValue is either the resolved data or we've returned null above.
|
|
474
627
|
|
|
475
|
-
if (resolvedValue !== parsed) {
|
|
628
|
+
if (resolvedValue !== parsed) {
|
|
476
629
|
parsed = resolvedValue;
|
|
477
630
|
}
|
|
478
631
|
}
|
|
@@ -505,7 +658,10 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
505
658
|
user.get('private').get(lens).get(key) :
|
|
506
659
|
holoInstance.gun.get(holoInstance.appname).get(holon).get(lens).get(key);
|
|
507
660
|
|
|
508
|
-
|
|
661
|
+
// `.once()` wrapped in a deadline — cold-path reads (peer offline,
|
|
662
|
+
// never-written key) used to hang forever otherwise. After
|
|
663
|
+
// `timeout` ms with no Gun response we treat it as "not found".
|
|
664
|
+
onceWithTimeout(dataPath, timeout).then(handleData);
|
|
509
665
|
});
|
|
510
666
|
} catch (error) {
|
|
511
667
|
console.error('Error in get:', error);
|
|
@@ -519,12 +675,23 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
519
675
|
* @param {string} holon - The holon identifier.
|
|
520
676
|
* @param {string} lens - The lens from which to retrieve content.
|
|
521
677
|
* @param {string} [password] - Optional password for private holon.
|
|
678
|
+
* @param {object} [options] - Additional options.
|
|
679
|
+
* @param {number} [options.timeout=READ_TIMEOUT_MS] - Per-`.once()` deadline
|
|
680
|
+
* (ms); the shallow probe falls back to "empty" after this on cold paths
|
|
681
|
+
* where Gun never responds. Pass `0` to disable and keep the historical
|
|
682
|
+
* "wait forever" behaviour.
|
|
522
683
|
* @returns {Promise<Array<object>>} - The retrieved content.
|
|
523
684
|
*/
|
|
524
|
-
export async function getAll(holoInstance, holon, lens, password = null) {
|
|
685
|
+
export async function getAll(holoInstance, holon, lens, password = null, options = {}) {
|
|
525
686
|
if (!holon || !lens) {
|
|
526
687
|
throw new Error('getAll: Missing required parameters');
|
|
527
688
|
}
|
|
689
|
+
const {
|
|
690
|
+
timeout = READ_TIMEOUT_MS,
|
|
691
|
+
// See `get` above: `_deleted: true` records are dropped from the
|
|
692
|
+
// response unless the caller opts in.
|
|
693
|
+
includeDeleted = false,
|
|
694
|
+
} = options;
|
|
528
695
|
|
|
529
696
|
const schema = await holoInstance.getSchema(lens);
|
|
530
697
|
if (!schema && holoInstance.strict) {
|
|
@@ -584,9 +751,12 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
584
751
|
holoInstance.gun.get(holoInstance.appname).get(holon).get(lens);
|
|
585
752
|
|
|
586
753
|
// PASS 1: Get shallow node to determine expected item count.
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
754
|
+
// Wrapped in a deadline (see `onceWithTimeout`) so cold paths
|
|
755
|
+
// resolve `null` after `timeout` ms instead of hanging forever.
|
|
756
|
+
// We still retry once below — Gun's `.once()` reads from local
|
|
757
|
+
// cache, which may be cold immediately after startup before
|
|
758
|
+
// peers have synced.
|
|
759
|
+
const shallowOnce = () => onceWithTimeout(dataPath, timeout);
|
|
590
760
|
|
|
591
761
|
const processShallow = (data) => {
|
|
592
762
|
if (!data) {
|
|
@@ -621,6 +791,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
621
791
|
const parsed = await holoInstance.parse(itemData);
|
|
622
792
|
if (!parsed || !parsed.id) return;
|
|
623
793
|
|
|
794
|
+
// Drop `_deleted: true` soft-tombstones by default;
|
|
795
|
+
// callers wanting them in the result pass
|
|
796
|
+
// `{ includeDeleted: true }` to `getAll`.
|
|
797
|
+
if (!includeDeleted && parsed._deleted === true) return;
|
|
798
|
+
|
|
624
799
|
if (holoInstance.isHologram(parsed)) {
|
|
625
800
|
try {
|
|
626
801
|
const resolved = await holoInstance.resolveHologram(parsed, {
|
|
@@ -630,12 +805,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
630
805
|
});
|
|
631
806
|
|
|
632
807
|
if (resolved === null) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
808
|
+
// See `get()` above: null is not proof of a
|
|
809
|
+
// permanently dead pointer. Skip the entry
|
|
810
|
+
// without deleting so transient resolution
|
|
811
|
+
// failures don't destroy real data.
|
|
812
|
+
console.warn(`Hologram at ${holon}/${lens}/${key} did not resolve (soul=${parsed.soul}); skipping.`);
|
|
639
813
|
return;
|
|
640
814
|
}
|
|
641
815
|
|
|
@@ -678,11 +852,10 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
678
852
|
return processItem(inline, key);
|
|
679
853
|
}
|
|
680
854
|
// Otherwise fetch the leaf — sub-graph reference path.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
});
|
|
855
|
+
// Same deadline as the shallow probe; a single missing
|
|
856
|
+
// leaf can't stall the whole batch.
|
|
857
|
+
return onceWithTimeout(dataPath.get(key), timeout)
|
|
858
|
+
.then((itemData) => processItem(itemData, key));
|
|
686
859
|
})).then(() => resolve(Array.from(output.values())));
|
|
687
860
|
};
|
|
688
861
|
|
package/federation.js
CHANGED
|
@@ -480,7 +480,7 @@ export async function removeNotify(holosphere, spaceId1, spaceId2, password1 = n
|
|
|
480
480
|
*/
|
|
481
481
|
export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
482
482
|
// Set default options and extract queryIds
|
|
483
|
-
const {
|
|
483
|
+
const {
|
|
484
484
|
queryIds = null, // New option
|
|
485
485
|
aggregate = false,
|
|
486
486
|
idField = 'id',
|
|
@@ -490,9 +490,16 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
490
490
|
mergeStrategy = null,
|
|
491
491
|
includeLocal = true,
|
|
492
492
|
includeFederated = true,
|
|
493
|
-
resolveReferences = true,
|
|
493
|
+
resolveReferences = true,
|
|
494
494
|
maxFederatedSpaces = -1,
|
|
495
|
-
timeout = 10000
|
|
495
|
+
timeout = 10000,
|
|
496
|
+
// When `resolveReferences` is on, source-soul fetches that fail get
|
|
497
|
+
// replaced with `{ id, _hologram: { isHologram: false, error } }`
|
|
498
|
+
// error stubs (federation.js around line 654). By default we filter
|
|
499
|
+
// those out of the response so consumers never render broken-card
|
|
500
|
+
// placeholders. Set `includeUnresolvedStubs: true` if you actually
|
|
501
|
+
// want to surface the failure to the user (e.g. an admin/debug view).
|
|
502
|
+
includeUnresolvedStubs = false
|
|
496
503
|
} = options;
|
|
497
504
|
|
|
498
505
|
console.log(`resolveReferences option: ${resolveReferences}`);
|
|
@@ -737,14 +744,28 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
737
744
|
count: group.length,
|
|
738
745
|
timestamp: Date.now()
|
|
739
746
|
};
|
|
740
|
-
|
|
747
|
+
|
|
741
748
|
return base;
|
|
742
749
|
});
|
|
743
|
-
|
|
744
|
-
return aggregatedData;
|
|
750
|
+
|
|
751
|
+
return includeUnresolvedStubs ? aggregatedData : aggregatedData.filter(isResolved);
|
|
745
752
|
}
|
|
746
|
-
|
|
747
|
-
return result;
|
|
753
|
+
|
|
754
|
+
return includeUnresolvedStubs ? result : result.filter(isResolved);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* True for any record that isn't an unresolved-reference error stub.
|
|
759
|
+
*
|
|
760
|
+
* `getFederated` produces stubs of the shape
|
|
761
|
+
* `{ id, _hologram: { isHologram: false, error, ... } }` when a source
|
|
762
|
+
* soul can't be resolved (peer offline, federation in flight, etc.).
|
|
763
|
+
* Default response now filters those out so consumers don't render
|
|
764
|
+
* broken-card placeholders.
|
|
765
|
+
*/
|
|
766
|
+
function isResolved(item) {
|
|
767
|
+
if (!item || typeof item !== 'object') return false;
|
|
768
|
+
return item._hologram?.isHologram !== false;
|
|
748
769
|
}
|
|
749
770
|
|
|
750
771
|
/**
|
|
@@ -795,7 +816,12 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
795
816
|
|
|
796
817
|
// Only propagate if we have outbound partners configured.
|
|
797
818
|
if (fedInfo && fedInfo.outbound && fedInfo.outbound.length > 0) {
|
|
798
|
-
|
|
819
|
+
// Never propagate back to ourselves. Writing a hologram to the
|
|
820
|
+
// source holon overwrites the original data with a self-
|
|
821
|
+
// referencing pointer, and HoloSphere's get() then auto-deletes
|
|
822
|
+
// it as a "broken hologram" (resolveHologram returns null because
|
|
823
|
+
// the soul resolves to itself), silently destroying the entry.
|
|
824
|
+
let spaces = fedInfo.outbound.filter(s => String(s) !== String(holon));
|
|
799
825
|
|
|
800
826
|
if (targetSpaces && Array.isArray(targetSpaces) && targetSpaces.length > 0) {
|
|
801
827
|
spaces = spaces.filter(space => targetSpaces.includes(space));
|
|
@@ -860,11 +886,19 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
860
886
|
}
|
|
861
887
|
};
|
|
862
888
|
}
|
|
863
|
-
|
|
889
|
+
|
|
890
|
+
// Defensive: even if the outbound list somehow
|
|
891
|
+
// contains the source holon, never write a self-
|
|
892
|
+
// hologram. The source already has the original.
|
|
893
|
+
if (String(targetSpace) === String(holon)) {
|
|
894
|
+
result.skipped++;
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
|
|
864
898
|
// Store in the target space with redirection disabled and no further auto-propagation
|
|
865
|
-
await holosphere.put(targetSpace, lens, payloadToPut, null, {
|
|
866
|
-
disableHologramRedirection: true,
|
|
867
|
-
autoPropagate: false
|
|
899
|
+
await holosphere.put(targetSpace, lens, payloadToPut, null, {
|
|
900
|
+
disableHologramRedirection: true,
|
|
901
|
+
autoPropagate: false
|
|
868
902
|
});
|
|
869
903
|
|
|
870
904
|
result.success++;
|
package/global.js
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
// holo_global.js
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Default deadline (ms) for the put-path's Gun ack callback. See
|
|
5
|
+
* `WRITE_TIMEOUT_MS` in content.js for the rationale — same hang, same
|
|
6
|
+
* fix, kept local here so global.js doesn't depend on content.js's
|
|
7
|
+
* internals.
|
|
8
|
+
*/
|
|
9
|
+
const WRITE_TIMEOUT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
function withWriteTimeout(promise, timeoutMs, queuedResult) {
|
|
12
|
+
if (!timeoutMs || timeoutMs <= 0) return promise;
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let done = false;
|
|
15
|
+
const finish = (fn, val) => { if (!done) { done = true; fn(val); } };
|
|
16
|
+
promise.then(
|
|
17
|
+
(v) => finish(resolve, v),
|
|
18
|
+
(e) => finish(reject, e)
|
|
19
|
+
);
|
|
20
|
+
setTimeout(() => finish(resolve, queuedResult), timeoutMs);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
3
24
|
/**
|
|
4
25
|
* Stores data in a global (non-holon-specific) table.
|
|
5
26
|
* @param {HoloSphere} holoInstance - The HoloSphere instance.
|
|
6
27
|
* @param {string} tableName - The table name to store data in.
|
|
7
28
|
* @param {object} data - The data to store. If it has an 'id' field, it will be used as the key.
|
|
8
29
|
* @param {string} [password] - Optional password for private holon.
|
|
30
|
+
* @param {object} [options] - Additional options
|
|
31
|
+
* @param {number} [options.timeout=5000] - Ack deadline in ms; resolves anyway after this so an offline mesh can't hang the caller. Pass `0` to disable.
|
|
9
32
|
* @returns {Promise<void>}
|
|
10
33
|
*/
|
|
11
|
-
export async function putGlobal(holoInstance, tableName, data, password = null) {
|
|
34
|
+
export async function putGlobal(holoInstance, tableName, data, password = null, options = {}) {
|
|
12
35
|
try {
|
|
13
36
|
if (!tableName || !data) {
|
|
14
37
|
throw new Error('Table name and data are required');
|
|
@@ -63,7 +86,9 @@ export async function putGlobal(holoInstance, tableName, data, password = null)
|
|
|
63
86
|
});
|
|
64
87
|
}
|
|
65
88
|
|
|
66
|
-
|
|
89
|
+
const writeTimeoutMs = options.timeout !== undefined ? options.timeout : WRITE_TIMEOUT_MS;
|
|
90
|
+
|
|
91
|
+
const ackPromise = new Promise((resolve, reject) => {
|
|
67
92
|
try {
|
|
68
93
|
// Create a copy of data, stripping read-side envelopes that
|
|
69
94
|
// must never be persisted (they're attached at resolution time).
|
|
@@ -143,6 +168,23 @@ export async function putGlobal(holoInstance, tableName, data, password = null)
|
|
|
143
168
|
reject(error);
|
|
144
169
|
}
|
|
145
170
|
});
|
|
171
|
+
|
|
172
|
+
// Bound the wait on Gun's put ack so an offline mesh doesn't hang
|
|
173
|
+
// the caller forever. Gun keeps the write locally and replays it
|
|
174
|
+
// when a peer reappears. We tag the ack-arrived branch with a
|
|
175
|
+
// unique sentinel so we can warn (once) when the timeout fired
|
|
176
|
+
// and still keep the public Promise<void> contract.
|
|
177
|
+
const ACK_OK = Symbol('ackOk');
|
|
178
|
+
return withWriteTimeout(
|
|
179
|
+
ackPromise.then(() => ACK_OK),
|
|
180
|
+
writeTimeoutMs,
|
|
181
|
+
undefined
|
|
182
|
+
).then((result) => {
|
|
183
|
+
if (result !== ACK_OK) {
|
|
184
|
+
console.warn(`putGlobal: no ack within ${writeTimeoutMs}ms for table=${tableName} — write queued locally, will replay on reconnect`);
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
});
|
|
146
188
|
} catch (error) {
|
|
147
189
|
console.error('Error in putGlobal:', error);
|
|
148
190
|
throw error;
|
|
@@ -743,15 +785,18 @@ export async function deleteAllGlobal(holoInstance, tableName, password = null)
|
|
|
743
785
|
|
|
744
786
|
/**
|
|
745
787
|
* Subscribe to real-time changes in a global table.
|
|
788
|
+
*
|
|
789
|
+
* Returns synchronously — see {@link subscribe} for the same rationale.
|
|
790
|
+
*
|
|
746
791
|
* @param {HoloSphere} holoInstance - The HoloSphere instance.
|
|
747
792
|
* @param {string} tableName - The table name to subscribe to.
|
|
748
793
|
* @param {string|null} key - Specific key to subscribe to, or null for all keys.
|
|
749
794
|
* @param {function} callback - Callback for data changes.
|
|
750
795
|
* @param {object} [options] - Subscription options.
|
|
751
796
|
* @param {boolean} [options.realtimeOnly] - Only fire for new changes.
|
|
752
|
-
* @returns {
|
|
797
|
+
* @returns {{ unsubscribe: () => void, stop: () => void }}
|
|
753
798
|
*/
|
|
754
|
-
export
|
|
799
|
+
export function subscribeGlobal(holoInstance, tableName, key, callback, options = {}) {
|
|
755
800
|
const dataPath = holoInstance.gun.get(holoInstance.appname).get(tableName);
|
|
756
801
|
let active = true;
|
|
757
802
|
|
package/hologram.js
CHANGED
|
@@ -175,11 +175,28 @@ export async function resolveHologram(holoInstance, hologram, options = {}) {
|
|
|
175
175
|
if (originalData && !originalData._invalidHologram) {
|
|
176
176
|
// Attach the canonical `_hologram` envelope. This is the only
|
|
177
177
|
// resolved-hologram indicator HoloSphere emits.
|
|
178
|
-
|
|
178
|
+
const withMeta = attachHologramMeta(originalData, hologram.soul);
|
|
179
|
+
// Stamp the source holon's display name so every consumer
|
|
180
|
+
// (subscribe, get, getAll, getFederated) has it without a
|
|
181
|
+
// second round-trip. Cached on the instance, so a batch of
|
|
182
|
+
// holograms from the same source resolves the name once.
|
|
183
|
+
if (withMeta._hologram?.sourceHolon && typeof holoInstance.getHolonName === 'function') {
|
|
184
|
+
try {
|
|
185
|
+
const sourceHolonName = await holoInstance.getHolonName(withMeta._hologram.sourceHolon);
|
|
186
|
+
if (sourceHolonName) {
|
|
187
|
+
withMeta._hologram = { ...withMeta._hologram, sourceHolonName };
|
|
188
|
+
}
|
|
189
|
+
} catch { /* best-effort — name lookup must not fail the resolve */ }
|
|
190
|
+
}
|
|
191
|
+
return withMeta;
|
|
179
192
|
} else {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
193
|
+
// Note: this is informational, not a permission to delete. The
|
|
194
|
+
// source soul may simply not be reachable yet (peer offline,
|
|
195
|
+
// federation propagation in flight). Callers must decide on
|
|
196
|
+
// their own GC policy; this function only reports the miss.
|
|
197
|
+
console.warn(`Could not resolve hologram soul: ${hologram.soul} (target not present locally)`);
|
|
198
|
+
|
|
199
|
+
// Return null so the caller skips this entry.
|
|
183
200
|
return null;
|
|
184
201
|
}
|
|
185
202
|
} else {
|