holosphere 1.3.0-alpha4 → 1.3.0-alpha5
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 +121 -29
- package/federation.js +47 -13
- package/global.js +5 -2
- package/hologram.js +21 -4
- package/holosphere-bundle.esm.js +451 -269
- package/holosphere-bundle.js +451 -269
- package/holosphere-bundle.min.js +8 -8
- package/holosphere.d.ts +67 -7
- package/holosphere.js +49 -4
- package/package.json +1 -1
- package/utils.js +29 -7
package/content.js
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
* `.once()` wrapped in a deadline. Resolves with the value when Gun fires
|
|
18
|
+
* back, or `null` after `timeoutMs` if it hasn't. Pass `timeoutMs <= 0` to
|
|
19
|
+
* disable the deadline.
|
|
20
|
+
*
|
|
21
|
+
* The first responder wins — both branches are idempotent so a late
|
|
22
|
+
* `.once()` callback after timeout is harmlessly ignored.
|
|
23
|
+
*/
|
|
24
|
+
function onceWithTimeout(node, timeoutMs = READ_TIMEOUT_MS) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let done = false;
|
|
27
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
28
|
+
node.once((data) => finish(data));
|
|
29
|
+
if (timeoutMs > 0) {
|
|
30
|
+
setTimeout(() => finish(null), timeoutMs);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
3
35
|
/**
|
|
4
36
|
* Recursively sanitizes a value for storage in GunDB.
|
|
5
37
|
*
|
|
@@ -214,8 +246,25 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
214
246
|
}
|
|
215
247
|
// Strip read-side envelopes that must never be persisted
|
|
216
248
|
// (they're attached at resolution time).
|
|
249
|
+
//
|
|
250
|
+
// `_hologram` and `_meta` are always read-side-only.
|
|
251
|
+
//
|
|
252
|
+
// `_federation` is also read-side for ordinary writes — it
|
|
253
|
+
// describes where data was fetched from, not where it
|
|
254
|
+
// currently lives. The one legitimate carrier is federation
|
|
255
|
+
// propagation, which writes hologram envelopes (top-level
|
|
256
|
+
// `id` + `soul`) tagged with `_federation` provenance; we
|
|
257
|
+
// detect that via `isHologram(data)` and leave it alone.
|
|
258
|
+
// Without this, a UI that reads a federated record and puts
|
|
259
|
+
// it back ends up persisting stale `_federation.origin`
|
|
260
|
+
// metadata, which then drives downstream code (federation
|
|
261
|
+
// propagators, hologram resolvers) to write or follow
|
|
262
|
+
// pointers that don't match the current storage location —
|
|
263
|
+
// producing "broken hologram" garbage-collection cascades
|
|
264
|
+
// that silently delete the user's write.
|
|
217
265
|
if (dataToStore._meta !== undefined) delete dataToStore._meta;
|
|
218
266
|
if (dataToStore._hologram !== undefined) delete dataToStore._hologram;
|
|
267
|
+
if (!isHologram && dataToStore._federation !== undefined) delete dataToStore._federation;
|
|
219
268
|
const payload = JSON.stringify(dataToStore); // The data being stored
|
|
220
269
|
|
|
221
270
|
const putCallback = async (ack) => {
|
|
@@ -400,7 +449,19 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
400
449
|
}
|
|
401
450
|
|
|
402
451
|
// Destructure options, including visited
|
|
403
|
-
const {
|
|
452
|
+
const {
|
|
453
|
+
resolveHolograms = true,
|
|
454
|
+
validationOptions = {},
|
|
455
|
+
visited,
|
|
456
|
+
timeout = READ_TIMEOUT_MS,
|
|
457
|
+
// `_deleted: true` is the soft-tombstone convention used by the bot,
|
|
458
|
+
// the web dashboard, and the MCP council tools. Pre-this fix the
|
|
459
|
+
// library was unaware of it and every caller filtered defensively.
|
|
460
|
+
// Now `get` returns `null` for tombstoned records by default; pass
|
|
461
|
+
// `includeDeleted: true` to surface them (admin/debug views,
|
|
462
|
+
// history reconstruction, etc.).
|
|
463
|
+
includeDeleted = false,
|
|
464
|
+
} = options;
|
|
404
465
|
|
|
405
466
|
// Get schema for validation if in strict mode
|
|
406
467
|
let schema = null;
|
|
@@ -444,6 +505,14 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
444
505
|
return;
|
|
445
506
|
}
|
|
446
507
|
|
|
508
|
+
// Treat the harvest-side `_deleted: true` soft-tombstone
|
|
509
|
+
// as "not found" by default. Callers that want to see
|
|
510
|
+
// tombstones can pass `{ includeDeleted: true }`.
|
|
511
|
+
if (!includeDeleted && parsed._deleted === true) {
|
|
512
|
+
resolve(null);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
447
516
|
// Check if this is a hologram that needs to be resolved
|
|
448
517
|
if (resolveHolograms && holoInstance.isHologram(parsed)) {
|
|
449
518
|
const resolvedValue = await holoInstance.resolveHologram(parsed, {
|
|
@@ -454,17 +523,20 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
454
523
|
});
|
|
455
524
|
|
|
456
525
|
if (resolvedValue === null) {
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
526
|
+
// `resolveHologram` returned null. DON'T treat this
|
|
527
|
+
// as a permission to delete — null fires for several
|
|
528
|
+
// transient reasons:
|
|
529
|
+
// - source soul not in our local Gun graph yet
|
|
530
|
+
// (peer offline, federation propagation in flight)
|
|
531
|
+
// - maxDepth (10) reached on a deep hologram chain
|
|
532
|
+
// - circular reference detected mid-chain
|
|
533
|
+
// - any internal resolve error
|
|
534
|
+
// None of these prove the pointer is permanently
|
|
535
|
+
// broken, but the old behaviour `await delete(...)`
|
|
536
|
+
// here permanently destroyed real data on the first
|
|
537
|
+
// transient miss. Skip the entry instead; a real
|
|
538
|
+
// garbage collector should own dead-pointer cleanup.
|
|
539
|
+
console.warn(`Hologram at ${holon}/${lens}/${key} did not resolve (soul=${parsed.soul}); skipping.`);
|
|
468
540
|
resolve(null);
|
|
469
541
|
return;
|
|
470
542
|
}
|
|
@@ -472,7 +544,7 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
472
544
|
// If it returned the hologram itself (if we ever revert to that), this logic would need adjustment.
|
|
473
545
|
// For now, assume resolvedValue is either the resolved data or we've returned null above.
|
|
474
546
|
|
|
475
|
-
if (resolvedValue !== parsed) {
|
|
547
|
+
if (resolvedValue !== parsed) {
|
|
476
548
|
parsed = resolvedValue;
|
|
477
549
|
}
|
|
478
550
|
}
|
|
@@ -505,7 +577,10 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
505
577
|
user.get('private').get(lens).get(key) :
|
|
506
578
|
holoInstance.gun.get(holoInstance.appname).get(holon).get(lens).get(key);
|
|
507
579
|
|
|
508
|
-
|
|
580
|
+
// `.once()` wrapped in a deadline — cold-path reads (peer offline,
|
|
581
|
+
// never-written key) used to hang forever otherwise. After
|
|
582
|
+
// `timeout` ms with no Gun response we treat it as "not found".
|
|
583
|
+
onceWithTimeout(dataPath, timeout).then(handleData);
|
|
509
584
|
});
|
|
510
585
|
} catch (error) {
|
|
511
586
|
console.error('Error in get:', error);
|
|
@@ -519,12 +594,23 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
519
594
|
* @param {string} holon - The holon identifier.
|
|
520
595
|
* @param {string} lens - The lens from which to retrieve content.
|
|
521
596
|
* @param {string} [password] - Optional password for private holon.
|
|
597
|
+
* @param {object} [options] - Additional options.
|
|
598
|
+
* @param {number} [options.timeout=READ_TIMEOUT_MS] - Per-`.once()` deadline
|
|
599
|
+
* (ms); the shallow probe falls back to "empty" after this on cold paths
|
|
600
|
+
* where Gun never responds. Pass `0` to disable and keep the historical
|
|
601
|
+
* "wait forever" behaviour.
|
|
522
602
|
* @returns {Promise<Array<object>>} - The retrieved content.
|
|
523
603
|
*/
|
|
524
|
-
export async function getAll(holoInstance, holon, lens, password = null) {
|
|
604
|
+
export async function getAll(holoInstance, holon, lens, password = null, options = {}) {
|
|
525
605
|
if (!holon || !lens) {
|
|
526
606
|
throw new Error('getAll: Missing required parameters');
|
|
527
607
|
}
|
|
608
|
+
const {
|
|
609
|
+
timeout = READ_TIMEOUT_MS,
|
|
610
|
+
// See `get` above: `_deleted: true` records are dropped from the
|
|
611
|
+
// response unless the caller opts in.
|
|
612
|
+
includeDeleted = false,
|
|
613
|
+
} = options;
|
|
528
614
|
|
|
529
615
|
const schema = await holoInstance.getSchema(lens);
|
|
530
616
|
if (!schema && holoInstance.strict) {
|
|
@@ -584,9 +670,12 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
584
670
|
holoInstance.gun.get(holoInstance.appname).get(holon).get(lens);
|
|
585
671
|
|
|
586
672
|
// PASS 1: Get shallow node to determine expected item count.
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
673
|
+
// Wrapped in a deadline (see `onceWithTimeout`) so cold paths
|
|
674
|
+
// resolve `null` after `timeout` ms instead of hanging forever.
|
|
675
|
+
// We still retry once below — Gun's `.once()` reads from local
|
|
676
|
+
// cache, which may be cold immediately after startup before
|
|
677
|
+
// peers have synced.
|
|
678
|
+
const shallowOnce = () => onceWithTimeout(dataPath, timeout);
|
|
590
679
|
|
|
591
680
|
const processShallow = (data) => {
|
|
592
681
|
if (!data) {
|
|
@@ -621,6 +710,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
621
710
|
const parsed = await holoInstance.parse(itemData);
|
|
622
711
|
if (!parsed || !parsed.id) return;
|
|
623
712
|
|
|
713
|
+
// Drop `_deleted: true` soft-tombstones by default;
|
|
714
|
+
// callers wanting them in the result pass
|
|
715
|
+
// `{ includeDeleted: true }` to `getAll`.
|
|
716
|
+
if (!includeDeleted && parsed._deleted === true) return;
|
|
717
|
+
|
|
624
718
|
if (holoInstance.isHologram(parsed)) {
|
|
625
719
|
try {
|
|
626
720
|
const resolved = await holoInstance.resolveHologram(parsed, {
|
|
@@ -630,12 +724,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
630
724
|
});
|
|
631
725
|
|
|
632
726
|
if (resolved === null) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
727
|
+
// See `get()` above: null is not proof of a
|
|
728
|
+
// permanently dead pointer. Skip the entry
|
|
729
|
+
// without deleting so transient resolution
|
|
730
|
+
// failures don't destroy real data.
|
|
731
|
+
console.warn(`Hologram at ${holon}/${lens}/${key} did not resolve (soul=${parsed.soul}); skipping.`);
|
|
639
732
|
return;
|
|
640
733
|
}
|
|
641
734
|
|
|
@@ -678,11 +771,10 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
678
771
|
return processItem(inline, key);
|
|
679
772
|
}
|
|
680
773
|
// Otherwise fetch the leaf — sub-graph reference path.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
});
|
|
774
|
+
// Same deadline as the shallow probe; a single missing
|
|
775
|
+
// leaf can't stall the whole batch.
|
|
776
|
+
return onceWithTimeout(dataPath.get(key), timeout)
|
|
777
|
+
.then((itemData) => processItem(itemData, key));
|
|
686
778
|
})).then(() => resolve(Array.from(output.values())));
|
|
687
779
|
};
|
|
688
780
|
|
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
|
@@ -743,15 +743,18 @@ export async function deleteAllGlobal(holoInstance, tableName, password = null)
|
|
|
743
743
|
|
|
744
744
|
/**
|
|
745
745
|
* Subscribe to real-time changes in a global table.
|
|
746
|
+
*
|
|
747
|
+
* Returns synchronously — see {@link subscribe} for the same rationale.
|
|
748
|
+
*
|
|
746
749
|
* @param {HoloSphere} holoInstance - The HoloSphere instance.
|
|
747
750
|
* @param {string} tableName - The table name to subscribe to.
|
|
748
751
|
* @param {string|null} key - Specific key to subscribe to, or null for all keys.
|
|
749
752
|
* @param {function} callback - Callback for data changes.
|
|
750
753
|
* @param {object} [options] - Subscription options.
|
|
751
754
|
* @param {boolean} [options.realtimeOnly] - Only fire for new changes.
|
|
752
|
-
* @returns {
|
|
755
|
+
* @returns {{ unsubscribe: () => void, stop: () => void }}
|
|
753
756
|
*/
|
|
754
|
-
export
|
|
757
|
+
export function subscribeGlobal(holoInstance, tableName, key, callback, options = {}) {
|
|
755
758
|
const dataPath = holoInstance.gun.get(holoInstance.appname).get(tableName);
|
|
756
759
|
let active = true;
|
|
757
760
|
|
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 {
|