holosphere 1.3.0-alpha0 → 1.3.0-alpha4

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/federation.js CHANGED
@@ -4,164 +4,144 @@
4
4
  */
5
5
 
6
6
  import * as h3 from 'h3-js';
7
+ import { attachHologramMeta } from './hologram.js';
7
8
 
8
9
  /**
9
- * Creates a federation relationship between two spaces
10
- * Federation is bidirectional by default, and data propagation uses soul references by default.
11
- *
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
+
38
+ /**
39
+ * Creates a directional federation relationship between two spaces.
40
+ *
41
+ * Directions are stated from spaceId1's perspective:
42
+ * - `lensConfig.inbound`: lenses spaceId1 receives FROM spaceId2.
43
+ * - `lensConfig.outbound`: lenses spaceId1 sends TO spaceId2.
44
+ *
45
+ * When `bidirectional` is true (default), spaceId2's record is mirrored with
46
+ * inverted directions so it agrees with the relationship from its own view.
47
+ *
12
48
  * @param {object} holosphere - The HoloSphere instance
13
49
  * @param {string} spaceId1 - The first space ID
14
50
  * @param {string} spaceId2 - The second space ID
15
51
  * @param {string} [password1] - Optional password for the first space
16
52
  * @param {string} [password2] - Optional password for the second space
17
- * @param {boolean} [bidirectional=true] - Whether to set up bidirectional notifications (default: true)
18
- * @param {object} [lensConfig] - Optional lens-specific configuration
19
- * @param {string[]} [lensConfig.federate] - List of lenses to federate (default: all)
20
- * @param {string[]} [lensConfig.notify] - List of lenses to notify (default: all)
53
+ * @param {boolean} [bidirectional=true] - Mirror the relationship onto spaceId2
54
+ * @param {object} [lensConfig] - Lens-direction config from spaceId1's perspective
55
+ * @param {string[]} [lensConfig.inbound] - Lenses spaceId1 receives from spaceId2
56
+ * @param {string[]} [lensConfig.outbound] - Lenses spaceId1 sends to spaceId2
21
57
  * @returns {Promise<boolean>} - True if federation was created successfully
22
58
  */
23
59
  export async function federate(holosphere, spaceId1, spaceId2, password1 = null, password2 = null, bidirectional = true, lensConfig = {}) {
24
60
  if (!spaceId1 || !spaceId2) {
25
61
  throw new Error('federate: Missing required space IDs');
26
62
  }
27
-
28
- // Prevent self-federation
29
63
  if (spaceId1 === spaceId2) {
30
64
  throw new Error('Cannot federate a space with itself');
31
65
  }
32
66
 
33
- // Validate lens configuration
34
- const { federate = [], notify = [] } = lensConfig;
35
- if (!Array.isArray(federate) || !Array.isArray(notify)) {
36
- throw new Error('federate: lensConfig.federate and lensConfig.notify must be arrays');
67
+ const { inbound = [], outbound = [] } = lensConfig;
68
+ if (!Array.isArray(inbound) || !Array.isArray(outbound)) {
69
+ throw new Error('federate: lensConfig.inbound and lensConfig.outbound must be arrays');
37
70
  }
38
71
 
39
- // Use the provided lens configurations directly
40
- const federateLenses = federate;
41
- const notifyLenses = notify;
42
-
43
- try {
44
- // Get or create federation info for first space (A)
45
- let fedInfo1 = null;
46
-
47
- try {
48
- fedInfo1 = await holosphere.getGlobal('federation', spaceId1, password1);
49
- } catch (error) {
50
- }
72
+ const applyDirection = (fedInfo, partnerId, partnerInbound, partnerOutbound) => {
73
+ if (!fedInfo.federated) fedInfo.federated = [];
74
+ if (!fedInfo.inbound) fedInfo.inbound = [];
75
+ if (!fedInfo.outbound) fedInfo.outbound = [];
76
+ if (!fedInfo.lensConfig) fedInfo.lensConfig = {};
51
77
 
52
- if (fedInfo1 == null) {
53
- fedInfo1 = {
54
- id: spaceId1,
55
- name: spaceId1,
56
- federation: [],
57
- notify: [],
58
- lensConfig: {}, // New field for lens-specific settings
59
- timestamp: Date.now()
60
- };
78
+ // `federated` is the canonical list — partner is recorded regardless of
79
+ // whether any lenses flow yet.
80
+ if (!fedInfo.federated.includes(partnerId)) {
81
+ fedInfo.federated.push(partnerId);
61
82
  }
62
83
 
63
- // Ensure arrays and lensConfig exist
64
- if (!fedInfo1.federation) fedInfo1.federation = [];
65
- if (!fedInfo1.notify) fedInfo1.notify = [];
66
- if (!fedInfo1.lensConfig) fedInfo1.lensConfig = {};
84
+ const hasInbound = partnerInbound.length > 0;
85
+ const hasOutbound = partnerOutbound.length > 0;
67
86
 
68
- // Add space2 to space1's federation list if not already present
69
- if (!fedInfo1.federation.includes(spaceId2)) {
70
- fedInfo1.federation.push(spaceId2);
87
+ if (hasInbound && !fedInfo.inbound.includes(partnerId)) {
88
+ fedInfo.inbound.push(partnerId);
89
+ } else if (!hasInbound) {
90
+ fedInfo.inbound = fedInfo.inbound.filter(id => id !== partnerId);
71
91
  }
72
-
73
- // Add space2 to space1's notify list if not already present
74
- if (!fedInfo1.notify.includes(spaceId2)) {
75
- fedInfo1.notify.push(spaceId2);
92
+ if (hasOutbound && !fedInfo.outbound.includes(partnerId)) {
93
+ fedInfo.outbound.push(partnerId);
94
+ } else if (!hasOutbound) {
95
+ fedInfo.outbound = fedInfo.outbound.filter(id => id !== partnerId);
76
96
  }
77
97
 
78
- // Store lens configuration for space2
79
- const newLensConfigsForSpace1 = { ...(fedInfo1.lensConfig || {}) }; // Shallow copy existing lensConfigs for space1
80
- newLensConfigsForSpace1[spaceId2] = { // Add/update config for the target spaceId2
81
- federate: [...federateLenses], // federateLenses & notifyLenses are from the main lensConfig parameter
82
- notify: [...notifyLenses],
98
+ fedInfo.lensConfig[partnerId] = {
99
+ inbound: [...partnerInbound],
100
+ outbound: [...partnerOutbound],
83
101
  timestamp: Date.now()
84
102
  };
85
- fedInfo1.lensConfig = newLensConfigsForSpace1; // Assign the new/modified object back to fedInfo1.lensConfig
103
+ fedInfo.timestamp = Date.now();
104
+ };
86
105
 
87
- // Update timestamp
88
- fedInfo1.timestamp = Date.now();
106
+ try {
107
+ // Space 1: directions as given.
108
+ let fedInfo1 = null;
109
+ try { fedInfo1 = await holosphere.getGlobal('federation', spaceId1, password1); } catch {}
110
+ if (fedInfo1 == null) {
111
+ fedInfo1 = {
112
+ id: spaceId1, name: spaceId1,
113
+ federated: [], inbound: [], outbound: [],
114
+ lensConfig: {}, partnerNames: {},
115
+ timestamp: Date.now()
116
+ };
117
+ }
118
+ applyDirection(fedInfo1, spaceId2, inbound, outbound);
89
119
 
90
- // Save updated federation info for space1
91
120
  try {
92
121
  await holosphere.putGlobal('federation', fedInfo1, password1);
93
122
  } catch (error) {
94
123
  throw new Error(`Failed to create federation: ${error.message}`);
95
124
  }
96
125
 
97
- // If bidirectional is true, handle space2 (B) as well
98
- {
126
+ // Space 2 (mirrored): directions are inverted from its perspective.
127
+ if (bidirectional) {
99
128
  let fedInfo2 = null;
100
- try {
101
- fedInfo2 = await holosphere.getGlobal('federation', spaceId2, password2);
102
- } catch (error) {
103
- }
104
-
129
+ try { fedInfo2 = await holosphere.getGlobal('federation', spaceId2, password2); } catch {}
105
130
  if (fedInfo2 == null) {
106
131
  fedInfo2 = {
107
- id: spaceId2,
108
- name: spaceId2,
109
- federation: [],
110
- notify: [],
111
- lensConfig: {}, // New field for lens-specific settings
132
+ id: spaceId2, name: spaceId2,
133
+ federated: [], inbound: [], outbound: [],
134
+ lensConfig: {}, partnerNames: {},
112
135
  timestamp: Date.now()
113
136
  };
114
137
  }
138
+ // From spaceId2's view, spaceId1's outbound lenses arrive as inbound,
139
+ // and spaceId1's inbound lenses go out as outbound.
140
+ applyDirection(fedInfo2, spaceId1, outbound, inbound);
115
141
 
116
- // Ensure arrays and lensConfig exist
117
- if (!fedInfo2.federation) fedInfo2.federation = [];
118
- if (!fedInfo2.notify) fedInfo2.notify = [];
119
- if (!fedInfo2.lensConfig) fedInfo2.lensConfig = {};
120
-
121
- // Add space1 to space2's federation list if bidirectional
122
- if (bidirectional && !fedInfo2.federation.includes(spaceId1)) {
123
- fedInfo2.federation.push(spaceId1);
124
- }
125
-
126
- // Add space1 to space2's notify list if not already present
127
- if (!fedInfo2.notify.includes(spaceId1)) {
128
- fedInfo2.notify.push(spaceId1);
129
- }
130
-
131
- // Store lens configuration for space1
132
- fedInfo2.lensConfig[spaceId1] = {
133
- federate: bidirectional ? [...federateLenses] : [], // Create a copy of the array
134
- notify: bidirectional ? [...notifyLenses] : [], // Create a copy of the array
135
- timestamp: Date.now()
136
- };
137
-
138
- // Update timestamp
139
- fedInfo2.timestamp = Date.now();
140
-
141
- // Save updated federation info for space2
142
142
  try {
143
143
  await holosphere.putGlobal('federation', fedInfo2, password2);
144
- } catch (error) {
145
- }
146
- }
147
-
148
- // Create federation metadata record
149
- const federationMeta = {
150
- id: `${spaceId1}_${spaceId2}`,
151
- space1: spaceId1,
152
- space2: spaceId2,
153
- created: Date.now(),
154
- status: 'active',
155
- bidirectional: bidirectional,
156
- lensConfig: {
157
- federate: [...federateLenses], // Create a copy of the array
158
- notify: [...notifyLenses] // Create a copy of the array
159
- }
160
- };
161
-
162
- try {
163
- await holosphere.putGlobal('federationMeta', federationMeta);
164
- } catch (error) {
144
+ } catch {}
165
145
  }
166
146
 
167
147
  return true;
@@ -198,8 +178,19 @@ export async function subscribeFederation(holosphere, spaceId, password = null,
198
178
  const subscriptions = [];
199
179
  let lastNotificationTime = {};
200
180
 
201
- if (fedInfo.federation && fedInfo.federation.length > 0) {
202
- for (const federatedSpace of fedInfo.federation) {
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
+
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
+
193
+ for (const federatedSpace of fedInfo.inbound) {
203
194
  // For each lens specified (or all if '*')
204
195
  for (const lens of lenses) {
205
196
  try {
@@ -207,27 +198,34 @@ export async function subscribeFederation(holosphere, spaceId, password = null,
207
198
  try {
208
199
  // Skip if data is missing or not from federated space
209
200
  if (!data || !data.id) return;
210
-
201
+
211
202
  // Apply throttling if configured
212
203
  const now = Date.now();
213
204
  const key = `${federatedSpace}_${lens}_${data.id}`;
214
-
205
+
215
206
  if (throttle > 0) {
216
- if (lastNotificationTime[key] &&
207
+ if (lastNotificationTime[key] &&
217
208
  (now - lastNotificationTime[key]) < throttle) {
218
209
  return; // Skip this notification (throttled)
219
210
  }
220
211
  lastNotificationTime[key] = now;
221
212
  }
222
-
223
- // Add federation metadata if not present
224
- if (!data.federation) {
225
- data.federation = {
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 = {
226
222
  origin: federatedSpace,
227
- timestamp: now
223
+ sourceLens: lens,
224
+ timestamp: now,
225
+ ...(partnerName ? { originName: partnerName } : {})
228
226
  };
229
227
  }
230
-
228
+
231
229
  // Execute callback with the data
232
230
  await callback(data, federatedSpace, lens);
233
231
  } catch (error) {
@@ -283,7 +281,7 @@ export async function getFederation(holosphere, spaceId, password = null) {
283
281
  * @param {string} spaceId - The ID of the source space.
284
282
  * @param {string} targetSpaceId - The ID of the target space in the federation link.
285
283
  * @param {string} [password] - Optional password for the source space.
286
- * @returns {Promise<object|null>} - An object with 'federate' and 'notify' arrays, or null if not found.
284
+ * @returns {Promise<object|null>} - An object with 'inbound' and 'outbound' arrays, or null if not found.
287
285
  */
288
286
  export async function getFederatedConfig(holosphere, spaceId, targetSpaceId, password = null) {
289
287
  if (!holosphere || !spaceId || !targetSpaceId) {
@@ -295,11 +293,11 @@ export async function getFederatedConfig(holosphere, spaceId, targetSpaceId, pas
295
293
 
296
294
  if (fedInfo && fedInfo.lensConfig && fedInfo.lensConfig[targetSpaceId]) {
297
295
  return {
298
- federate: fedInfo.lensConfig[targetSpaceId].federate || [],
299
- notify: fedInfo.lensConfig[targetSpaceId].notify || []
296
+ inbound: fedInfo.lensConfig[targetSpaceId].inbound || [],
297
+ outbound: fedInfo.lensConfig[targetSpaceId].outbound || []
300
298
  };
301
299
  }
302
- return null; // Or return an empty config: { federate: [], notify: [] }
300
+ return null;
303
301
  } catch (error) {
304
302
  console.error(`Error getting federated config for ${spaceId} -> ${targetSpaceId}: ${error.message}`);
305
303
  throw error;
@@ -332,53 +330,64 @@ export async function unfederate(holosphere, spaceId1, spaceId2, password1 = nul
332
330
  }
333
331
 
334
332
  if (!fedInfo1) {
335
- // If fedInfo1 doesn't exist, log and proceed to metadata cleanup.
336
333
  console.warn(`No federation info found for ${spaceId1}. Skipping its update.`);
337
334
  } else {
338
- // Ensure arrays exist
339
- if (!fedInfo1.federation) fedInfo1.federation = [];
340
- if (!fedInfo1.notify) fedInfo1.notify = [];
341
-
342
- // Update first space federation info - remove from both federation and notify arrays
343
- const originalFederationLength = fedInfo1.federation.length;
344
- const originalNotifyLength = fedInfo1.notify.length;
345
-
346
- fedInfo1.federation = fedInfo1.federation.filter(id => id !== spaceId2);
347
- fedInfo1.notify = fedInfo1.notify.filter(id => id !== spaceId2);
335
+ if (!fedInfo1.federated) fedInfo1.federated = [];
336
+ if (!fedInfo1.inbound) fedInfo1.inbound = [];
337
+ if (!fedInfo1.outbound) fedInfo1.outbound = [];
338
+ if (!fedInfo1.lensConfig) fedInfo1.lensConfig = {};
339
+ if (!fedInfo1.partnerNames) fedInfo1.partnerNames = {};
340
+
341
+ const beforeIn = fedInfo1.inbound.length;
342
+ const beforeOut = fedInfo1.outbound.length;
343
+
344
+ fedInfo1.federated = fedInfo1.federated.filter(id => id !== spaceId2);
345
+ fedInfo1.inbound = fedInfo1.inbound.filter(id => id !== spaceId2);
346
+ fedInfo1.outbound = fedInfo1.outbound.filter(id => id !== spaceId2);
347
+ delete fedInfo1.lensConfig[spaceId2];
348
+ delete fedInfo1.partnerNames[spaceId2];
348
349
  fedInfo1.timestamp = Date.now();
349
-
350
- console.log(`Unfederate: Removed ${spaceId2} from ${spaceId1}: federation ${originalFederationLength} -> ${fedInfo1.federation.length}, notify ${originalNotifyLength} -> ${fedInfo1.notify.length}`);
351
-
350
+
351
+ console.log(`Unfederate: removed ${spaceId2} from ${spaceId1}: inbound ${beforeIn} -> ${fedInfo1.inbound.length}, outbound ${beforeOut} -> ${fedInfo1.outbound.length}`);
352
+
352
353
  try {
353
354
  await holosphere.putGlobal('federation', fedInfo1, password1);
354
355
  } catch (error) {
355
356
  console.error(`Failed to update fedInfo1 for ${spaceId1} during unfederate: ${error.message}`);
356
- throw error; // RE-THROW to signal failure
357
+ throw error;
357
358
  }
358
359
  }
359
360
 
360
- // Update second space federation info (remove spaceId1 from spaceId2's notify list)
361
- // This part is usually for full bidirectional unfederation cleanup.
362
- // The original code only did this if password2 was provided.
363
- if (password2) { // Retaining original condition for this block
361
+ // Mirror the removal on space2 if password provided.
362
+ if (password2) {
364
363
  let fedInfo2 = null;
365
364
  try {
366
365
  fedInfo2 = await holosphere.getGlobal('federation', spaceId2, password2);
367
366
  } catch (error) {
368
367
  console.error(`Error getting fedInfo2 for ${spaceId2} during unfederate: ${error.message}`);
369
368
  }
370
-
371
- if (!fedInfo2 || !fedInfo2.notify) {
372
- console.warn(`No notify array found for ${spaceId2} or fedInfo2 is null. Skipping its update.`);
369
+
370
+ if (!fedInfo2) {
371
+ console.warn(`No federation info found for ${spaceId2}. Skipping its update.`);
373
372
  } else {
374
- fedInfo2.notify = fedInfo2.notify.filter(id => id !== spaceId1);
373
+ if (!fedInfo2.federated) fedInfo2.federated = [];
374
+ if (!fedInfo2.inbound) fedInfo2.inbound = [];
375
+ if (!fedInfo2.outbound) fedInfo2.outbound = [];
376
+ if (!fedInfo2.lensConfig) fedInfo2.lensConfig = {};
377
+ if (!fedInfo2.partnerNames) fedInfo2.partnerNames = {};
378
+
379
+ fedInfo2.federated = fedInfo2.federated.filter(id => id !== spaceId1);
380
+ fedInfo2.inbound = fedInfo2.inbound.filter(id => id !== spaceId1);
381
+ fedInfo2.outbound = fedInfo2.outbound.filter(id => id !== spaceId1);
382
+ delete fedInfo2.lensConfig[spaceId1];
383
+ delete fedInfo2.partnerNames[spaceId1];
375
384
  fedInfo2.timestamp = Date.now();
376
-
385
+
377
386
  try {
378
387
  await holosphere.putGlobal('federation', fedInfo2, password2);
379
388
  } catch (error) {
380
389
  console.error(`Failed to update fedInfo2 for ${spaceId2} during unfederate: ${error.message}`);
381
- throw error; // RE-THROW to signal failure
390
+ throw error;
382
391
  }
383
392
  }
384
393
  }
@@ -409,14 +418,15 @@ export async function unfederate(holosphere, spaceId1, spaceId2, password1 = nul
409
418
  }
410
419
 
411
420
  /**
412
- * Removes a notification relationship between two spaces
413
- * This removes spaceId2 from the notify list of spaceId1
414
- *
421
+ * Stops outbound propagation from spaceId1 to spaceId2.
422
+ * Removes spaceId2 from spaceId1's `outbound` list and clears the outbound
423
+ * lens config for that partner. Inbound subscriptions are untouched.
424
+ *
415
425
  * @param {object} holosphere - The HoloSphere instance
416
- * @param {string} spaceId1 - The space to modify (remove from its notify list)
417
- * @param {string} spaceId2 - The space to be removed from notifications
426
+ * @param {string} spaceId1 - The space to modify
427
+ * @param {string} spaceId2 - The partner to stop sending to
418
428
  * @param {string} [password1] - Optional password for the first space
419
- * @returns {Promise<boolean>} - True if notification was removed successfully
429
+ * @returns {Promise<boolean>} - True if outbound was removed
420
430
  */
421
431
  export async function removeNotify(holosphere, spaceId1, spaceId2, password1 = null) {
422
432
  if (!spaceId1 || !spaceId2) {
@@ -424,29 +434,22 @@ export async function removeNotify(holosphere, spaceId1, spaceId2, password1 = n
424
434
  }
425
435
 
426
436
  try {
427
- // Get federation info for space
428
- let fedInfo = await holosphere.getGlobal('federation', spaceId1, password1);
429
-
437
+ const fedInfo = await holosphere.getGlobal('federation', spaceId1, password1);
430
438
  if (!fedInfo) {
431
439
  throw new Error(`No federation info found for ${spaceId1}`);
432
440
  }
441
+ if (!fedInfo.outbound) fedInfo.outbound = [];
433
442
 
434
- // Ensure notify array exists
435
- if (!fedInfo.notify) fedInfo.notify = [];
436
-
437
- // Remove space2 from space1's notify list if present
438
- if (fedInfo.notify.includes(spaceId2)) {
439
- fedInfo.notify = fedInfo.notify.filter(id => id !== spaceId2);
440
-
441
- // Update timestamp
442
- fedInfo.timestamp = Date.now();
443
-
444
- // Save updated federation info
445
- await holosphere.putGlobal('federation', fedInfo, password1);
446
- return true;
447
- } else {
448
- return false;
443
+ if (!fedInfo.outbound.includes(spaceId2)) return false;
444
+
445
+ fedInfo.outbound = fedInfo.outbound.filter(id => id !== spaceId2);
446
+ if (fedInfo.lensConfig?.[spaceId2]) {
447
+ fedInfo.lensConfig[spaceId2].outbound = [];
449
448
  }
449
+ fedInfo.timestamp = Date.now();
450
+
451
+ await holosphere.putGlobal('federation', fedInfo, password1);
452
+ return true;
450
453
  } catch (error) {
451
454
  throw error;
452
455
  }
@@ -515,15 +518,42 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
515
518
  if (includeLocal) {
516
519
  spacesToQuery.push(holon); // Add local holon first
517
520
  }
518
- if (includeFederated && fedInfo && fedInfo.federation && fedInfo.federation.length > 0) {
519
- const federatedSpaces = maxFederatedSpaces === -1 ? fedInfo.federation : fedInfo.federation.slice(0, maxFederatedSpaces);
521
+ if (includeFederated && fedInfo && fedInfo.inbound && fedInfo.inbound.length > 0) {
522
+ const federatedSpaces = maxFederatedSpaces === -1 ? fedInfo.inbound : fedInfo.inbound.slice(0, maxFederatedSpaces);
520
523
  spacesToQuery = spacesToQuery.concat(federatedSpaces);
521
524
  }
522
525
 
526
+ // Resolve display names for federated partner spaces in parallel, so
527
+ // every tagged item can carry the holon's name. Compute once per call.
528
+ const remoteSpaces = spacesToQuery.filter(s => s !== holon);
529
+ const spaceNames = new Map();
530
+ await Promise.all(
531
+ remoteSpaces.map(async space => {
532
+ const name = await getHolonName(holosphere, space);
533
+ spaceNames.set(space, name);
534
+ })
535
+ );
536
+
537
+ // Tag items pulled from a federated partner with the space they came
538
+ // from (and its resolved display name, if any). Local items are left
539
+ // untouched so consumers can distinguish own vs. external by absence
540
+ // or presence of `_federation`.
541
+ const tagWithSource = (item, space) => {
542
+ if (!item || space === holon) return item;
543
+ const originName = spaceNames.get(space);
544
+ const fed = {
545
+ ...(item._federation || {}),
546
+ origin: space,
547
+ sourceLens: lens
548
+ };
549
+ if (originName) fed.originName = originName;
550
+ return { ...item, _federation: fed };
551
+ };
552
+
523
553
  // Fetch data from all relevant spaces
524
554
  for (const currentSpace of spacesToQuery) {
525
555
  if (queryIds && Array.isArray(queryIds)) {
526
- // --- Fetch specific IDs using holosphere.get ---
556
+ // --- Fetch specific IDs using holosphere.get ---
527
557
  console.log(`Fetching specific IDs from ${currentSpace}: ${queryIds.join(', ')}`);
528
558
  for (const itemId of queryIds) {
529
559
  if (fetchedItems.has(itemId)) continue; // Skip if already fetched
@@ -531,7 +561,7 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
531
561
  holosphere.get(currentSpace, lens, itemId)
532
562
  .then(item => {
533
563
  if (item) {
534
- fetchedItems.set(itemId, item);
564
+ fetchedItems.set(itemId, tagWithSource(item, currentSpace));
535
565
  }
536
566
  })
537
567
  .catch(err => console.warn(`Error fetching item ${itemId} from ${currentSpace}: ${err.message}`))
@@ -548,7 +578,7 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
548
578
  .then(items => {
549
579
  for (const item of items) {
550
580
  if (item && item[idField] && !fetchedItems.has(item[idField])) {
551
- fetchedItems.set(item[idField], item);
581
+ fetchedItems.set(item[idField], tagWithSource(item, currentSpace));
552
582
  }
553
583
  }
554
584
  })
@@ -596,91 +626,64 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
596
626
  console.log(`Original data found via soul path:`, JSON.stringify(originalData));
597
627
 
598
628
  if (originalData) {
599
- // Replace the reference with the original data
600
- result[i] = {
601
- ...originalData,
602
- _federation: {
603
- isReference: true,
604
- resolved: true,
605
- soul: item.soul,
606
- timestamp: Date.now()
629
+ // Replace the reference with the resolved data, attaching
630
+ // the canonical _hologram envelope (single source of truth).
631
+ const withMeta = attachHologramMeta(originalData, item.soul);
632
+ // Stamp the source holon's display name so consumers
633
+ // don't need a second round-trip to render it. Use
634
+ // the per-call cache when possible to avoid duplicate
635
+ // settings reads across many holograms from the same
636
+ // source.
637
+ if (withMeta._hologram?.sourceHolon) {
638
+ let sourceHolonName = spaceNames.get(withMeta._hologram.sourceHolon);
639
+ if (sourceHolonName === undefined) {
640
+ sourceHolonName = await getHolonName(holosphere, withMeta._hologram.sourceHolon);
641
+ spaceNames.set(withMeta._hologram.sourceHolon, sourceHolonName);
607
642
  }
608
- };
643
+ if (sourceHolonName) {
644
+ withMeta._hologram = {
645
+ ...withMeta._hologram,
646
+ sourceHolonName
647
+ };
648
+ }
649
+ }
650
+ result[i] = withMeta;
609
651
  } else {
610
- // Instead of leaving the original reference, create an error object
652
+ // Original data not found keep the id so callers can
653
+ // identify the broken reference, and surface the error.
611
654
  result[i] = {
612
655
  id: item.id,
613
- _federation: {
614
- isReference: true,
615
- resolved: false,
656
+ _hologram: {
657
+ isHologram: false,
616
658
  soul: item.soul,
617
659
  error: 'Referenced data not found',
618
- timestamp: Date.now()
660
+ resolvedAt: Date.now()
619
661
  }
620
662
  };
621
663
  }
622
664
  } else {
623
665
  console.warn(`Soul doesn't match expected format: ${item.soul}`);
624
- // Instead of leaving the original reference, create an error object
625
666
  result[i] = {
626
667
  id: item.id,
627
- _federation: {
628
- isReference: true,
629
- resolved: false,
668
+ _hologram: {
669
+ isHologram: false,
630
670
  soul: item.soul,
631
671
  error: 'Invalid soul format',
632
- timestamp: Date.now()
672
+ resolvedAt: Date.now()
633
673
  }
634
674
  };
635
675
  }
636
676
  } catch (refError) {
637
- // Instead of leaving the original reference, create an error object
638
677
  result[i] = {
639
678
  id: item.id,
640
- _federation: {
641
- isReference: true,
642
- resolved: false,
679
+ _hologram: {
680
+ isHologram: false,
643
681
  soul: item.soul,
644
682
  error: refError.message || 'Error resolving reference',
645
- timestamp: Date.now()
683
+ resolvedAt: Date.now()
646
684
  }
647
685
  };
648
686
  }
649
- }
650
- // For backward compatibility, check for old-style references
651
- else if (item._federation && item._federation.isReference) {
652
- console.log(`Found legacy reference: ${item._federation.origin}/${item._federation.lens}/${item[idField]}`);
653
-
654
- try {
655
- const reference = item._federation;
656
- console.log(`Getting original data from ${reference.origin} / ${reference.lens} / ${item[idField]}`);
657
-
658
- // Get original data
659
- const originalData = await holosphere.get(
660
- reference.origin,
661
- reference.lens,
662
- item[idField],
663
- null,
664
- { resolveReferences: false } // Prevent infinite recursion
665
- );
666
-
667
- console.log(`Original data found:`, JSON.stringify(originalData));
668
-
669
- if (originalData) {
670
- // Add federation information to the resolved data
671
- result[i] = {
672
- ...originalData,
673
- _federation: {
674
- ...reference,
675
- resolved: true,
676
- timestamp: Date.now()
677
- }
678
- };
679
- } else {
680
- console.warn(`Could not resolve legacy reference: original data not found`);
681
- }
682
- } catch (refError) {
683
- }
684
687
  }
685
688
  }
686
689
  }
@@ -790,18 +793,16 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
790
793
  // Get federation info for this holon using getFederation
791
794
  const fedInfo = await getFederation(holosphere, holon, password);
792
795
 
793
- // Only perform federation propagation if there's valid federation info
794
- if (fedInfo && fedInfo.federation && fedInfo.federation.length > 0 && fedInfo.notify && fedInfo.notify.length > 0) {
795
- // Filter federation spaces to those in notify list
796
- let spaces = fedInfo.notify;
797
-
798
- // Further filter by targetSpaces if provided
796
+ // Only propagate if we have outbound partners configured.
797
+ if (fedInfo && fedInfo.outbound && fedInfo.outbound.length > 0) {
798
+ let spaces = fedInfo.outbound;
799
+
799
800
  if (targetSpaces && Array.isArray(targetSpaces) && targetSpaces.length > 0) {
800
801
  spaces = spaces.filter(space => targetSpaces.includes(space));
801
802
  }
802
-
803
+
803
804
  if (spaces.length > 0) {
804
- // Filter spaces based on lens configuration
805
+ // Keep only partners whose outbound lens config includes this lens.
805
806
  spaces = spaces.filter(targetSpace => {
806
807
  const spaceConfig = fedInfo.lensConfig?.[targetSpace];
807
808
  if (!spaceConfig) {
@@ -810,19 +811,13 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
810
811
  return false;
811
812
  }
812
813
 
813
- // Ensure .federate is an array before calling .includes
814
- const federateLenses = Array.isArray(spaceConfig.federate) ? spaceConfig.federate : [];
814
+ const outboundLenses = Array.isArray(spaceConfig.outbound) ? spaceConfig.outbound : [];
815
+ const shouldPropagate = outboundLenses.includes('*') || outboundLenses.includes(lens);
815
816
 
816
- const shouldFederate = federateLenses.includes('*') || federateLenses.includes(lens);
817
-
818
- // Propagation now only depends on the 'federate' list configuration for the lens
819
- const shouldPropagate = shouldFederate;
820
-
821
817
  if (!shouldPropagate) {
822
- result.messages.push(`Propagation of lens '${lens}' to target space ${targetSpace} skipped: lens not in 'federate' configuration.`);
818
+ result.messages.push(`Propagation of lens '${lens}' to target space ${targetSpace} skipped: lens not in 'outbound' configuration.`);
823
819
  result.skipped++;
824
820
  }
825
-
826
821
  return shouldPropagate;
827
822
  });
828
823
 
@@ -830,6 +825,11 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
830
825
  // Check if data is already a hologram
831
826
  const isAlreadyHologram = holosphere.isHologram(data);
832
827
 
828
+ // Resolve our own holon's name once so every propagated
829
+ // payload carries it. Falls back to undefined (the field
830
+ // is omitted) so consumers can use the bare holon id.
831
+ const ownName = await getHolonName(holosphere, holon);
832
+
833
833
  // For each target space, propagate the data
834
834
  const propagatePromises = spaces.map(async (targetSpace) => {
835
835
  try {
@@ -838,7 +838,8 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
838
838
  origin: holon, // The space from which this data is being propagated
839
839
  sourceLens: lens, // The lens from which this data is being propagated
840
840
  propagatedAt: Date.now(),
841
- originalId: data.id
841
+ originalId: data.id,
842
+ ...(ownName ? { originName: ownName } : {})
842
843
  };
843
844
 
844
845
  if (useHolograms && !isAlreadyHologram) {
@@ -945,7 +946,11 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
945
946
 
946
947
  // Check if data is already a hologram (reuse from federation section)
947
948
  const isAlreadyHologram = holosphere.isHologram(data);
948
-
949
+
950
+ // Resolve our own holon's name once for parent propagation
951
+ // (same as the federation block above).
952
+ const ownNameParent = await getHolonName(holosphere, holon);
953
+
949
954
  // Propagate to each parent hexagon
950
955
  const parentPropagatePromises = parentHexagons.map(async (parentHexagon) => {
951
956
  try {
@@ -956,7 +961,8 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
956
961
  propagatedAt: Date.now(),
957
962
  originalId: data.id,
958
963
  propagationType: 'parent', // Indicate this is parent propagation
959
- parentLevel: holonResolution - h3.getResolution(parentHexagon) // How many levels up
964
+ parentLevel: holonResolution - h3.getResolution(parentHexagon), // How many levels up
965
+ ...(ownNameParent ? { originName: ownNameParent } : {})
960
966
  };
961
967
 
962
968
  if (useHolograms && !isAlreadyHologram) {
@@ -1128,82 +1134,76 @@ export async function resetFederation(holosphere, spaceId, password = null, opti
1128
1134
  };
1129
1135
  }
1130
1136
 
1131
- // Store counts for reporting
1132
- result.federatedCount = fedInfo.federation?.length || 0;
1133
- result.notifyCount = fedInfo.notify?.length || 0;
1137
+ const allPartners = fedInfo.federated || [];
1138
+ result.federatedCount = allPartners.length;
1139
+ result.inboundCount = fedInfo.inbound?.length || 0;
1140
+ result.outboundCount = fedInfo.outbound?.length || 0;
1134
1141
 
1135
- // Create empty federation record
1142
+ // Reset federation record to empty.
1136
1143
  const emptyFedInfo = {
1137
1144
  id: spaceId,
1138
1145
  name: spaceName || spaceId,
1139
- federation: [],
1140
- notify: [],
1146
+ federated: [],
1147
+ inbound: [],
1148
+ outbound: [],
1149
+ lensConfig: {},
1150
+ partnerNames: {},
1141
1151
  timestamp: Date.now()
1142
1152
  };
1143
-
1144
- // Update federation record with empty lists
1145
1153
  await holosphere.putGlobal('federation', emptyFedInfo, password);
1146
1154
 
1147
- // Notify federation partners if requested
1148
- if (notifyPartners && fedInfo.federation && fedInfo.federation.length > 0) {
1149
- const updatePromises = fedInfo.federation.map(async (partnerSpace) => {
1155
+ // Tell each partner to drop us from their lists.
1156
+ if (notifyPartners && allPartners.length > 0) {
1157
+ const updatePromises = allPartners.map(async (partnerSpace) => {
1150
1158
  try {
1151
- // Get partner's federation info
1152
1159
  const partnerFedInfo = await getFederation(holosphere, partnerSpace);
1153
-
1154
- if (partnerFedInfo) {
1155
- // Remove this space from partner's federation list
1156
- if (partnerFedInfo.federation) {
1157
- partnerFedInfo.federation = partnerFedInfo.federation.filter(
1158
- id => id !== spaceId.toString()
1159
- );
1160
- }
1161
-
1162
- // Remove this space from partner's notify list
1163
- if (partnerFedInfo.notify) {
1164
- partnerFedInfo.notify = partnerFedInfo.notify.filter(
1165
- id => id !== spaceId.toString()
1166
- );
1167
- }
1168
-
1169
- partnerFedInfo.timestamp = Date.now();
1170
-
1171
- // Save partner's updated federation info
1172
- await holosphere.putGlobal('federation', partnerFedInfo);
1173
- result.partnersNotified++;
1174
- return true;
1160
+ if (!partnerFedInfo) return false;
1161
+
1162
+ const sid = spaceId.toString();
1163
+ if (partnerFedInfo.federated) {
1164
+ partnerFedInfo.federated = partnerFedInfo.federated.filter(id => id !== sid);
1175
1165
  }
1176
- return false;
1166
+ if (partnerFedInfo.inbound) {
1167
+ partnerFedInfo.inbound = partnerFedInfo.inbound.filter(id => id !== sid);
1168
+ }
1169
+ if (partnerFedInfo.outbound) {
1170
+ partnerFedInfo.outbound = partnerFedInfo.outbound.filter(id => id !== sid);
1171
+ }
1172
+ if (partnerFedInfo.lensConfig) {
1173
+ delete partnerFedInfo.lensConfig[sid];
1174
+ }
1175
+ if (partnerFedInfo.partnerNames) {
1176
+ delete partnerFedInfo.partnerNames[sid];
1177
+ }
1178
+ partnerFedInfo.timestamp = Date.now();
1179
+
1180
+ await holosphere.putGlobal('federation', partnerFedInfo);
1181
+ result.partnersNotified++;
1182
+ return true;
1177
1183
  } catch (error) {
1178
- result.errors.push({
1179
- partner: partnerSpace,
1180
- error: error.message
1181
- });
1184
+ result.errors.push({ partner: partnerSpace, error: error.message });
1182
1185
  return false;
1183
1186
  }
1184
1187
  });
1185
-
1188
+
1186
1189
  await Promise.all(updatePromises);
1187
1190
  }
1188
-
1189
- // Update federation metadata records if they exist
1190
- if (fedInfo.federation && fedInfo.federation.length > 0) {
1191
- for (const partnerSpace of fedInfo.federation) {
1192
- try {
1193
- const metaId = `${spaceId}_${partnerSpace}`;
1194
- const altMetaId = `${partnerSpace}_${spaceId}`;
1195
-
1196
- const meta = await holosphere.getGlobal('federationMeta', metaId) ||
1197
- await holosphere.getGlobal('federationMeta', altMetaId);
1198
-
1199
- if (meta) {
1200
- meta.status = 'inactive';
1201
- meta.endedAt = Date.now();
1202
- await holosphere.putGlobal('federationMeta', meta);
1203
- }
1204
- } catch (error) {
1191
+
1192
+ // Mark any federationMeta records inactive.
1193
+ for (const partnerSpace of allPartners) {
1194
+ try {
1195
+ const metaId = `${spaceId}_${partnerSpace}`;
1196
+ const altMetaId = `${partnerSpace}_${spaceId}`;
1197
+
1198
+ const meta = await holosphere.getGlobal('federationMeta', metaId) ||
1199
+ await holosphere.getGlobal('federationMeta', altMetaId);
1200
+
1201
+ if (meta) {
1202
+ meta.status = 'inactive';
1203
+ meta.endedAt = Date.now();
1204
+ await holosphere.putGlobal('federationMeta', meta);
1205
1205
  }
1206
- }
1206
+ } catch {}
1207
1207
  }
1208
1208
 
1209
1209
  result.success = true;