holosphere 1.3.0-alpha3 → 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 +194 -37
- package/federation.js +167 -29
- package/global.js +5 -2
- package/hologram.js +21 -4
- package/holosphere-bundle.esm.js +571 -283
- package/holosphere-bundle.js +571 -283
- 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,96 @@
|
|
|
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
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Recursively sanitizes a value for storage in GunDB.
|
|
37
|
+
*
|
|
38
|
+
* Drops keys whose values would corrupt the graph or round-trip incorrectly:
|
|
39
|
+
* - undefined, NaN, Infinity, -Infinity (JSON.stringify silently turns
|
|
40
|
+
* these into nothing or "null", which is how malformed payloads like
|
|
41
|
+
* `"initiated":,` end up in the graph and surface later as per-character
|
|
42
|
+
* parse warnings).
|
|
43
|
+
* - functions, symbols, bigints (not JSON-representable).
|
|
44
|
+
* Preserves null (legitimate Gun tombstone / explicit empty value).
|
|
45
|
+
* Guards against circular references.
|
|
46
|
+
*
|
|
47
|
+
* Logs one warning per dropped path so the caller can fix the producer.
|
|
48
|
+
*/
|
|
49
|
+
function sanitizeForStorage(value, path = '', seen = new WeakSet(), warnings = []) {
|
|
50
|
+
if (value === null) return null;
|
|
51
|
+
const t = typeof value;
|
|
52
|
+
|
|
53
|
+
if (t === 'number') {
|
|
54
|
+
if (!Number.isFinite(value)) {
|
|
55
|
+
warnings.push(`${path || '<root>'}: ${value} (non-finite number)`);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
if (t === 'string' || t === 'boolean') return value;
|
|
61
|
+
if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') {
|
|
62
|
+
warnings.push(`${path || '<root>'}: ${t}`);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (t === 'object') {
|
|
67
|
+
if (seen.has(value)) {
|
|
68
|
+
warnings.push(`${path || '<root>'}: circular reference`);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
seen.add(value);
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (let i = 0; i < value.length; i++) {
|
|
76
|
+
const cleaned = sanitizeForStorage(value[i], `${path}[${i}]`, seen, warnings);
|
|
77
|
+
out.push(cleaned === undefined ? null : cleaned);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const k of Object.keys(value)) {
|
|
84
|
+
const cleaned = sanitizeForStorage(value[k], path ? `${path}.${k}` : k, seen, warnings);
|
|
85
|
+
if (cleaned !== undefined) out[k] = cleaned;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
warnings.push(`${path || '<root>'}: unsupported type ${t}`);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
3
94
|
/**
|
|
4
95
|
* Stores content in the specified holon and lens.
|
|
5
96
|
* If the target path already contains a hologram, the put operation will be
|
|
@@ -142,15 +233,38 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
|
|
|
142
233
|
|
|
143
234
|
return new Promise((resolve, reject) => {
|
|
144
235
|
try {
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
236
|
+
// Sanitize before serialization so undefined/NaN/Infinity etc.
|
|
237
|
+
// can never produce a malformed payload like `"initiated":,`
|
|
238
|
+
// (which is what causes per-character parse warnings on read).
|
|
239
|
+
const sanitizeWarnings = [];
|
|
240
|
+
let dataToStore = sanitizeForStorage(data, '', new WeakSet(), sanitizeWarnings) || {};
|
|
241
|
+
if (sanitizeWarnings.length > 0) {
|
|
242
|
+
console.warn(
|
|
243
|
+
`holosphere.put: sanitized ${sanitizeWarnings.length} field(s) at ${targetHolon}/${targetLens}/${targetKey} (id=${data.id}):`,
|
|
244
|
+
sanitizeWarnings
|
|
245
|
+
);
|
|
153
246
|
}
|
|
247
|
+
// Strip read-side envelopes that must never be persisted
|
|
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.
|
|
265
|
+
if (dataToStore._meta !== undefined) delete dataToStore._meta;
|
|
266
|
+
if (dataToStore._hologram !== undefined) delete dataToStore._hologram;
|
|
267
|
+
if (!isHologram && dataToStore._federation !== undefined) delete dataToStore._federation;
|
|
154
268
|
const payload = JSON.stringify(dataToStore); // The data being stored
|
|
155
269
|
|
|
156
270
|
const putCallback = async (ack) => {
|
|
@@ -335,7 +449,19 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
335
449
|
}
|
|
336
450
|
|
|
337
451
|
// Destructure options, including visited
|
|
338
|
-
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;
|
|
339
465
|
|
|
340
466
|
// Get schema for validation if in strict mode
|
|
341
467
|
let schema = null;
|
|
@@ -379,6 +505,14 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
379
505
|
return;
|
|
380
506
|
}
|
|
381
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
|
+
|
|
382
516
|
// Check if this is a hologram that needs to be resolved
|
|
383
517
|
if (resolveHolograms && holoInstance.isHologram(parsed)) {
|
|
384
518
|
const resolvedValue = await holoInstance.resolveHologram(parsed, {
|
|
@@ -389,17 +523,20 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
389
523
|
});
|
|
390
524
|
|
|
391
525
|
if (resolvedValue === null) {
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.`);
|
|
403
540
|
resolve(null);
|
|
404
541
|
return;
|
|
405
542
|
}
|
|
@@ -407,7 +544,7 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
407
544
|
// If it returned the hologram itself (if we ever revert to that), this logic would need adjustment.
|
|
408
545
|
// For now, assume resolvedValue is either the resolved data or we've returned null above.
|
|
409
546
|
|
|
410
|
-
if (resolvedValue !== parsed) {
|
|
547
|
+
if (resolvedValue !== parsed) {
|
|
411
548
|
parsed = resolvedValue;
|
|
412
549
|
}
|
|
413
550
|
}
|
|
@@ -440,7 +577,10 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
440
577
|
user.get('private').get(lens).get(key) :
|
|
441
578
|
holoInstance.gun.get(holoInstance.appname).get(holon).get(lens).get(key);
|
|
442
579
|
|
|
443
|
-
|
|
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);
|
|
444
584
|
});
|
|
445
585
|
} catch (error) {
|
|
446
586
|
console.error('Error in get:', error);
|
|
@@ -454,12 +594,23 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
|
|
|
454
594
|
* @param {string} holon - The holon identifier.
|
|
455
595
|
* @param {string} lens - The lens from which to retrieve content.
|
|
456
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.
|
|
457
602
|
* @returns {Promise<Array<object>>} - The retrieved content.
|
|
458
603
|
*/
|
|
459
|
-
export async function getAll(holoInstance, holon, lens, password = null) {
|
|
604
|
+
export async function getAll(holoInstance, holon, lens, password = null, options = {}) {
|
|
460
605
|
if (!holon || !lens) {
|
|
461
606
|
throw new Error('getAll: Missing required parameters');
|
|
462
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;
|
|
463
614
|
|
|
464
615
|
const schema = await holoInstance.getSchema(lens);
|
|
465
616
|
if (!schema && holoInstance.strict) {
|
|
@@ -519,9 +670,12 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
519
670
|
holoInstance.gun.get(holoInstance.appname).get(holon).get(lens);
|
|
520
671
|
|
|
521
672
|
// PASS 1: Get shallow node to determine expected item count.
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
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);
|
|
525
679
|
|
|
526
680
|
const processShallow = (data) => {
|
|
527
681
|
if (!data) {
|
|
@@ -556,6 +710,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
556
710
|
const parsed = await holoInstance.parse(itemData);
|
|
557
711
|
if (!parsed || !parsed.id) return;
|
|
558
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
|
+
|
|
559
718
|
if (holoInstance.isHologram(parsed)) {
|
|
560
719
|
try {
|
|
561
720
|
const resolved = await holoInstance.resolveHologram(parsed, {
|
|
@@ -565,12 +724,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
565
724
|
});
|
|
566
725
|
|
|
567
726
|
if (resolved === null) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
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.`);
|
|
574
732
|
return;
|
|
575
733
|
}
|
|
576
734
|
|
|
@@ -613,11 +771,10 @@ export async function getAll(holoInstance, holon, lens, password = null) {
|
|
|
613
771
|
return processItem(inline, key);
|
|
614
772
|
}
|
|
615
773
|
// Otherwise fetch the leaf — sub-graph reference path.
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
});
|
|
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));
|
|
621
778
|
})).then(() => resolve(Array.from(output.values())));
|
|
622
779
|
};
|
|
623
780
|
|
package/federation.js
CHANGED
|
@@ -6,6 +6,35 @@
|
|
|
6
6
|
import * as h3 from 'h3-js';
|
|
7
7
|
import { attachHologramMeta } from './hologram.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Look up a holon's display name from its `settings` lens.
|
|
11
|
+
*
|
|
12
|
+
* Returns the `name` field of the holon's settings document, or null if
|
|
13
|
+
* settings are missing or unnamed. Callers should fall back to the bare
|
|
14
|
+
* holon id when this returns null.
|
|
15
|
+
*
|
|
16
|
+
* @param {HoloSphere} holosphere
|
|
17
|
+
* @param {string} space - holon id
|
|
18
|
+
* @returns {Promise<string|null>}
|
|
19
|
+
*/
|
|
20
|
+
async function getHolonName(holosphere, space) {
|
|
21
|
+
if (!holosphere || !space) return null;
|
|
22
|
+
try {
|
|
23
|
+
const settings = await holosphere.get(space, 'settings', space);
|
|
24
|
+
if (!settings) return null;
|
|
25
|
+
if (Array.isArray(settings)) {
|
|
26
|
+
const found = settings.find(s => s && typeof s.name === 'string' && s.name.trim() !== '');
|
|
27
|
+
return found ? found.name : null;
|
|
28
|
+
}
|
|
29
|
+
if (typeof settings.name === 'string' && settings.name.trim() !== '') {
|
|
30
|
+
return settings.name;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
/**
|
|
10
39
|
* Creates a directional federation relationship between two spaces.
|
|
11
40
|
*
|
|
@@ -149,7 +178,18 @@ export async function subscribeFederation(holosphere, spaceId, password = null,
|
|
|
149
178
|
const subscriptions = [];
|
|
150
179
|
let lastNotificationTime = {};
|
|
151
180
|
|
|
181
|
+
// Cache partner display names once at setup. Each callback firing for
|
|
182
|
+
// a given partner uses the same name without re-reading settings.
|
|
183
|
+
const partnerNames = new Map();
|
|
184
|
+
|
|
152
185
|
if (fedInfo.inbound && fedInfo.inbound.length > 0) {
|
|
186
|
+
await Promise.all(
|
|
187
|
+
fedInfo.inbound.map(async space => {
|
|
188
|
+
const name = await getHolonName(holosphere, space);
|
|
189
|
+
partnerNames.set(space, name);
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
|
|
153
193
|
for (const federatedSpace of fedInfo.inbound) {
|
|
154
194
|
// For each lens specified (or all if '*')
|
|
155
195
|
for (const lens of lenses) {
|
|
@@ -158,27 +198,34 @@ export async function subscribeFederation(holosphere, spaceId, password = null,
|
|
|
158
198
|
try {
|
|
159
199
|
// Skip if data is missing or not from federated space
|
|
160
200
|
if (!data || !data.id) return;
|
|
161
|
-
|
|
201
|
+
|
|
162
202
|
// Apply throttling if configured
|
|
163
203
|
const now = Date.now();
|
|
164
204
|
const key = `${federatedSpace}_${lens}_${data.id}`;
|
|
165
|
-
|
|
205
|
+
|
|
166
206
|
if (throttle > 0) {
|
|
167
|
-
if (lastNotificationTime[key] &&
|
|
207
|
+
if (lastNotificationTime[key] &&
|
|
168
208
|
(now - lastNotificationTime[key]) < throttle) {
|
|
169
209
|
return; // Skip this notification (throttled)
|
|
170
210
|
}
|
|
171
211
|
lastNotificationTime[key] = now;
|
|
172
212
|
}
|
|
173
|
-
|
|
174
|
-
// Add federation metadata if not present
|
|
175
|
-
|
|
176
|
-
|
|
213
|
+
|
|
214
|
+
// Add federation metadata if not present.
|
|
215
|
+
// Use the canonical `_federation` envelope so it
|
|
216
|
+
// matches what propagate() and getFederated()
|
|
217
|
+
// produce, and what consumers (UI badges, etc.)
|
|
218
|
+
// already check for.
|
|
219
|
+
if (!data._federation) {
|
|
220
|
+
const partnerName = partnerNames.get(federatedSpace);
|
|
221
|
+
data._federation = {
|
|
177
222
|
origin: federatedSpace,
|
|
178
|
-
|
|
223
|
+
sourceLens: lens,
|
|
224
|
+
timestamp: now,
|
|
225
|
+
...(partnerName ? { originName: partnerName } : {})
|
|
179
226
|
};
|
|
180
227
|
}
|
|
181
|
-
|
|
228
|
+
|
|
182
229
|
// Execute callback with the data
|
|
183
230
|
await callback(data, federatedSpace, lens);
|
|
184
231
|
} catch (error) {
|
|
@@ -433,7 +480,7 @@ export async function removeNotify(holosphere, spaceId1, spaceId2, password1 = n
|
|
|
433
480
|
*/
|
|
434
481
|
export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
435
482
|
// Set default options and extract queryIds
|
|
436
|
-
const {
|
|
483
|
+
const {
|
|
437
484
|
queryIds = null, // New option
|
|
438
485
|
aggregate = false,
|
|
439
486
|
idField = 'id',
|
|
@@ -443,9 +490,16 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
443
490
|
mergeStrategy = null,
|
|
444
491
|
includeLocal = true,
|
|
445
492
|
includeFederated = true,
|
|
446
|
-
resolveReferences = true,
|
|
493
|
+
resolveReferences = true,
|
|
447
494
|
maxFederatedSpaces = -1,
|
|
448
|
-
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
|
|
449
503
|
} = options;
|
|
450
504
|
|
|
451
505
|
console.log(`resolveReferences option: ${resolveReferences}`);
|
|
@@ -476,10 +530,37 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
476
530
|
spacesToQuery = spacesToQuery.concat(federatedSpaces);
|
|
477
531
|
}
|
|
478
532
|
|
|
533
|
+
// Resolve display names for federated partner spaces in parallel, so
|
|
534
|
+
// every tagged item can carry the holon's name. Compute once per call.
|
|
535
|
+
const remoteSpaces = spacesToQuery.filter(s => s !== holon);
|
|
536
|
+
const spaceNames = new Map();
|
|
537
|
+
await Promise.all(
|
|
538
|
+
remoteSpaces.map(async space => {
|
|
539
|
+
const name = await getHolonName(holosphere, space);
|
|
540
|
+
spaceNames.set(space, name);
|
|
541
|
+
})
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
// Tag items pulled from a federated partner with the space they came
|
|
545
|
+
// from (and its resolved display name, if any). Local items are left
|
|
546
|
+
// untouched so consumers can distinguish own vs. external by absence
|
|
547
|
+
// or presence of `_federation`.
|
|
548
|
+
const tagWithSource = (item, space) => {
|
|
549
|
+
if (!item || space === holon) return item;
|
|
550
|
+
const originName = spaceNames.get(space);
|
|
551
|
+
const fed = {
|
|
552
|
+
...(item._federation || {}),
|
|
553
|
+
origin: space,
|
|
554
|
+
sourceLens: lens
|
|
555
|
+
};
|
|
556
|
+
if (originName) fed.originName = originName;
|
|
557
|
+
return { ...item, _federation: fed };
|
|
558
|
+
};
|
|
559
|
+
|
|
479
560
|
// Fetch data from all relevant spaces
|
|
480
561
|
for (const currentSpace of spacesToQuery) {
|
|
481
562
|
if (queryIds && Array.isArray(queryIds)) {
|
|
482
|
-
// --- Fetch specific IDs using holosphere.get ---
|
|
563
|
+
// --- Fetch specific IDs using holosphere.get ---
|
|
483
564
|
console.log(`Fetching specific IDs from ${currentSpace}: ${queryIds.join(', ')}`);
|
|
484
565
|
for (const itemId of queryIds) {
|
|
485
566
|
if (fetchedItems.has(itemId)) continue; // Skip if already fetched
|
|
@@ -487,7 +568,7 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
487
568
|
holosphere.get(currentSpace, lens, itemId)
|
|
488
569
|
.then(item => {
|
|
489
570
|
if (item) {
|
|
490
|
-
fetchedItems.set(itemId, item);
|
|
571
|
+
fetchedItems.set(itemId, tagWithSource(item, currentSpace));
|
|
491
572
|
}
|
|
492
573
|
})
|
|
493
574
|
.catch(err => console.warn(`Error fetching item ${itemId} from ${currentSpace}: ${err.message}`))
|
|
@@ -504,7 +585,7 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
504
585
|
.then(items => {
|
|
505
586
|
for (const item of items) {
|
|
506
587
|
if (item && item[idField] && !fetchedItems.has(item[idField])) {
|
|
507
|
-
fetchedItems.set(item[idField], item);
|
|
588
|
+
fetchedItems.set(item[idField], tagWithSource(item, currentSpace));
|
|
508
589
|
}
|
|
509
590
|
}
|
|
510
591
|
})
|
|
@@ -554,7 +635,26 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
554
635
|
if (originalData) {
|
|
555
636
|
// Replace the reference with the resolved data, attaching
|
|
556
637
|
// the canonical _hologram envelope (single source of truth).
|
|
557
|
-
|
|
638
|
+
const withMeta = attachHologramMeta(originalData, item.soul);
|
|
639
|
+
// Stamp the source holon's display name so consumers
|
|
640
|
+
// don't need a second round-trip to render it. Use
|
|
641
|
+
// the per-call cache when possible to avoid duplicate
|
|
642
|
+
// settings reads across many holograms from the same
|
|
643
|
+
// source.
|
|
644
|
+
if (withMeta._hologram?.sourceHolon) {
|
|
645
|
+
let sourceHolonName = spaceNames.get(withMeta._hologram.sourceHolon);
|
|
646
|
+
if (sourceHolonName === undefined) {
|
|
647
|
+
sourceHolonName = await getHolonName(holosphere, withMeta._hologram.sourceHolon);
|
|
648
|
+
spaceNames.set(withMeta._hologram.sourceHolon, sourceHolonName);
|
|
649
|
+
}
|
|
650
|
+
if (sourceHolonName) {
|
|
651
|
+
withMeta._hologram = {
|
|
652
|
+
...withMeta._hologram,
|
|
653
|
+
sourceHolonName
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
result[i] = withMeta;
|
|
558
658
|
} else {
|
|
559
659
|
// Original data not found — keep the id so callers can
|
|
560
660
|
// identify the broken reference, and surface the error.
|
|
@@ -644,14 +744,28 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
|
|
|
644
744
|
count: group.length,
|
|
645
745
|
timestamp: Date.now()
|
|
646
746
|
};
|
|
647
|
-
|
|
747
|
+
|
|
648
748
|
return base;
|
|
649
749
|
});
|
|
650
|
-
|
|
651
|
-
return aggregatedData;
|
|
750
|
+
|
|
751
|
+
return includeUnresolvedStubs ? aggregatedData : aggregatedData.filter(isResolved);
|
|
652
752
|
}
|
|
653
|
-
|
|
654
|
-
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;
|
|
655
769
|
}
|
|
656
770
|
|
|
657
771
|
/**
|
|
@@ -702,7 +816,12 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
702
816
|
|
|
703
817
|
// Only propagate if we have outbound partners configured.
|
|
704
818
|
if (fedInfo && fedInfo.outbound && fedInfo.outbound.length > 0) {
|
|
705
|
-
|
|
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));
|
|
706
825
|
|
|
707
826
|
if (targetSpaces && Array.isArray(targetSpaces) && targetSpaces.length > 0) {
|
|
708
827
|
spaces = spaces.filter(space => targetSpaces.includes(space));
|
|
@@ -732,6 +851,11 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
732
851
|
// Check if data is already a hologram
|
|
733
852
|
const isAlreadyHologram = holosphere.isHologram(data);
|
|
734
853
|
|
|
854
|
+
// Resolve our own holon's name once so every propagated
|
|
855
|
+
// payload carries it. Falls back to undefined (the field
|
|
856
|
+
// is omitted) so consumers can use the bare holon id.
|
|
857
|
+
const ownName = await getHolonName(holosphere, holon);
|
|
858
|
+
|
|
735
859
|
// For each target space, propagate the data
|
|
736
860
|
const propagatePromises = spaces.map(async (targetSpace) => {
|
|
737
861
|
try {
|
|
@@ -740,7 +864,8 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
740
864
|
origin: holon, // The space from which this data is being propagated
|
|
741
865
|
sourceLens: lens, // The lens from which this data is being propagated
|
|
742
866
|
propagatedAt: Date.now(),
|
|
743
|
-
originalId: data.id
|
|
867
|
+
originalId: data.id,
|
|
868
|
+
...(ownName ? { originName: ownName } : {})
|
|
744
869
|
};
|
|
745
870
|
|
|
746
871
|
if (useHolograms && !isAlreadyHologram) {
|
|
@@ -761,11 +886,19 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
761
886
|
}
|
|
762
887
|
};
|
|
763
888
|
}
|
|
764
|
-
|
|
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
|
+
|
|
765
898
|
// Store in the target space with redirection disabled and no further auto-propagation
|
|
766
|
-
await holosphere.put(targetSpace, lens, payloadToPut, null, {
|
|
767
|
-
disableHologramRedirection: true,
|
|
768
|
-
autoPropagate: false
|
|
899
|
+
await holosphere.put(targetSpace, lens, payloadToPut, null, {
|
|
900
|
+
disableHologramRedirection: true,
|
|
901
|
+
autoPropagate: false
|
|
769
902
|
});
|
|
770
903
|
|
|
771
904
|
result.success++;
|
|
@@ -847,7 +980,11 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
847
980
|
|
|
848
981
|
// Check if data is already a hologram (reuse from federation section)
|
|
849
982
|
const isAlreadyHologram = holosphere.isHologram(data);
|
|
850
|
-
|
|
983
|
+
|
|
984
|
+
// Resolve our own holon's name once for parent propagation
|
|
985
|
+
// (same as the federation block above).
|
|
986
|
+
const ownNameParent = await getHolonName(holosphere, holon);
|
|
987
|
+
|
|
851
988
|
// Propagate to each parent hexagon
|
|
852
989
|
const parentPropagatePromises = parentHexagons.map(async (parentHexagon) => {
|
|
853
990
|
try {
|
|
@@ -858,7 +995,8 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
|
|
|
858
995
|
propagatedAt: Date.now(),
|
|
859
996
|
originalId: data.id,
|
|
860
997
|
propagationType: 'parent', // Indicate this is parent propagation
|
|
861
|
-
parentLevel: holonResolution - h3.getResolution(parentHexagon) // How many levels up
|
|
998
|
+
parentLevel: holonResolution - h3.getResolution(parentHexagon), // How many levels up
|
|
999
|
+
...(ownNameParent ? { originName: ownNameParent } : {})
|
|
862
1000
|
};
|
|
863
1001
|
|
|
864
1002
|
if (useHolograms && !isAlreadyHologram) {
|