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 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 { resolveHolograms = true, validationOptions = {}, visited } = options;
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
- // This means resolveHologram determined the target doesn't exist or encountered an error
458
- console.warn(`Broken hologram detected at ${holon}/${lens}/${key}. Removing it...`);
459
-
460
- try {
461
- // Delete the broken hologram
462
- await holoInstance.delete(holon, lens, key, password);
463
- console.log(`Successfully removed broken hologram from ${holon}/${lens}/${key}`);
464
- } catch (cleanupError) {
465
- console.error(`Failed to remove broken hologram at ${holon}/${lens}/${key}:`, cleanupError);
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
- dataPath.once(handleData);
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
- // Retry once if empty Gun's .once() reads from local cache, which
588
- // may be cold immediately after startup before peers have synced.
589
- const shallowOnce = () => new Promise((res) => dataPath.once((d) => res(d)));
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
- console.warn(`Broken hologram detected in getAll for key ${key}. Removing it...`);
634
- try {
635
- await holoInstance.delete(holon, lens, key, password);
636
- } catch (cleanupError) {
637
- console.error(`Failed to remove broken hologram at ${holon}/${lens}/${key}:`, cleanupError);
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
- return new Promise((resolveItem) => {
682
- dataPath.get(key).once((itemData) => {
683
- processItem(itemData, key).then(resolveItem, resolveItem);
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
- let spaces = fedInfo.outbound;
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 {Promise<{ unsubscribe: () => void }>}
755
+ * @returns {{ unsubscribe: () => void, stop: () => void }}
753
756
  */
754
- export async function subscribeGlobal(holoInstance, tableName, key, callback, options = {}) {
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
- return attachHologramMeta(originalData, hologram.soul);
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
- console.warn(`!!! Original data NOT FOUND for soul: ${hologram.soul}. Removing broken hologram.`);
181
-
182
- // Return null to indicate the hologram should be removed
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 {