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 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 { disableHologramRedirection = false } = options; // Extract new option
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
- return new Promise((resolve, reject) => {
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 (for actual data being stored) ---
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
- if (!isHologram && !options.isHologramUpdate) {
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 data
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 // Only active holograms (deleted ones are null/removed)
364
+ const hologramSouls = Object.keys(hologramsSet).filter(k =>
365
+ k !== '_' && hologramsSet[k] === true && !cascadeVisited.has(k)
257
366
  );
258
-
259
- if (hologramSouls.length > 0) {
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 // Prevent recursive hologram updates
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 { resolveHolograms = true, validationOptions = {}, visited } = options;
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
- // 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
-
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
- dataPath.once(handleData);
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
- // 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)));
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
- 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
- }
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
- return new Promise((resolveItem) => {
682
- dataPath.get(key).once((itemData) => {
683
- processItem(itemData, key).then(resolveItem, resolveItem);
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
- 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
@@ -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
- return new Promise((resolve, reject) => {
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 {Promise<{ unsubscribe: () => void }>}
797
+ * @returns {{ unsubscribe: () => void, stop: () => void }}
753
798
  */
754
- export async function subscribeGlobal(holoInstance, tableName, key, callback, options = {}) {
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
- 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 {