holosphere 1.1.21 → 1.3.0-alpha3

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/global.js CHANGED
@@ -65,11 +65,15 @@ export async function putGlobal(holoInstance, tableName, data, password = null)
65
65
 
66
66
  return new Promise((resolve, reject) => {
67
67
  try {
68
- // Create a copy of data without the _meta field if it exists
68
+ // Create a copy of data, stripping read-side envelopes that
69
+ // must never be persisted (they're attached at resolution time).
69
70
  let dataToStore = { ...data };
70
71
  if (dataToStore._meta !== undefined) {
71
72
  delete dataToStore._meta;
72
73
  }
74
+ if (dataToStore._hologram !== undefined) {
75
+ delete dataToStore._hologram;
76
+ }
73
77
  const payload = JSON.stringify(dataToStore);
74
78
 
75
79
  // Check if the data being stored is a hologram
@@ -327,8 +331,12 @@ export async function getAllGlobal(holoInstance, tableName, password = null) {
327
331
  user.get('private').get(tableName) :
328
332
  holoInstance.gun.get(holoInstance.appname).get(tableName);
329
333
 
330
- // PASS 1: Get shallow node to determine expected item count
331
- dataPath.once((data) => {
334
+ // PASS 1: Get shallow node to determine expected item count.
335
+ // Retry once if empty — Gun's .once() reads from local cache, which
336
+ // may be cold immediately after startup before peers have synced.
337
+ const shallowOnce = () => new Promise((res) => dataPath.once((d) => res(d)));
338
+
339
+ const processShallow = (data) => {
332
340
  if (!data) {
333
341
  resolve([]);
334
342
  return;
@@ -350,60 +358,63 @@ export async function getAllGlobal(holoInstance, tableName, password = null) {
350
358
  return;
351
359
  }
352
360
 
353
- // PASS 2: Use map().once() to iterate and get full item data
354
- let receivedCount = 0;
355
-
356
- dataPath.map().once(async (itemData, key) => {
357
- if (!itemData || key === '_') {
358
- receivedCount++;
359
- if (receivedCount >= expectedCount) {
360
- await Promise.all(pendingProcessing);
361
- resolve(output);
362
- }
363
- return;
364
- }
365
-
366
- const processingPromise = (async () => {
367
- try {
368
- const parsed = await holoInstance.parse(itemData);
369
- if (!parsed) return;
361
+ // PASS 2: iterate explicitly over the filtered keys.
362
+ // Avoid dataPath.map().once() — it fires for null tombstone
363
+ // siblings and can resolve before real items are processed.
364
+ const processItem = async (itemData, key) => {
365
+ if (!itemData) return;
366
+ try {
367
+ const parsed = await holoInstance.parse(itemData);
368
+ if (!parsed) return;
370
369
 
371
- if (holoInstance.isHologram(parsed)) {
372
- const resolved = await holoInstance.resolveHologram(parsed, {
373
- followHolograms: true
374
- });
370
+ if (holoInstance.isHologram(parsed)) {
371
+ const resolved = await holoInstance.resolveHologram(parsed, {
372
+ followHolograms: true
373
+ });
375
374
 
376
- if (resolved === null) {
377
- try {
378
- await holoInstance.deleteGlobal(tableName, key, password);
379
- } catch (deleteError) {
380
- console.error(`Failed to delete invalid global hologram at ${tableName}/${key}:`, deleteError);
381
- }
382
- return;
375
+ if (resolved === null) {
376
+ try {
377
+ await holoInstance.deleteGlobal(tableName, key, password);
378
+ } catch (deleteError) {
379
+ console.error(`Failed to delete invalid global hologram at ${tableName}/${key}:`, deleteError);
383
380
  }
381
+ return;
382
+ }
384
383
 
385
- if (resolved !== parsed) {
386
- output.push(resolved);
387
- } else {
388
- output.push(parsed);
389
- }
384
+ if (resolved !== parsed) {
385
+ output.push(resolved);
390
386
  } else {
391
387
  output.push(parsed);
392
388
  }
393
- } catch (error) {
394
- console.error('Error parsing data:', error);
389
+ } else {
390
+ output.push(parsed);
395
391
  }
396
- })();
397
-
398
- pendingProcessing.push(processingPromise);
399
- receivedCount++;
392
+ } catch (error) {
393
+ console.error('Error parsing data:', error);
394
+ }
395
+ };
400
396
 
401
- if (receivedCount >= expectedCount) {
402
- await Promise.all(pendingProcessing);
403
- resolve(output);
397
+ Promise.all(keys.map((key) => {
398
+ const inline = data[key];
399
+ if (typeof inline !== 'object' || inline === null) {
400
+ return processItem(inline, key);
404
401
  }
405
- });
406
- });
402
+ return new Promise((resolveItem) => {
403
+ dataPath.get(key).once((itemData) => {
404
+ processItem(itemData, key).then(resolveItem, resolveItem);
405
+ });
406
+ });
407
+ })).then(() => resolve(output));
408
+ };
409
+
410
+ (async () => {
411
+ let data = await shallowOnce();
412
+ if (!data) {
413
+ await new Promise(r => setTimeout(r, 1500));
414
+ data = await shallowOnce();
415
+ }
416
+ processShallow(data);
417
+ })();
407
418
  });
408
419
  } catch (error) {
409
420
  console.error('Error in getAllGlobal:', error);
@@ -730,11 +741,70 @@ export async function deleteAllGlobal(holoInstance, tableName, password = null)
730
741
  }
731
742
  }
732
743
 
744
+ /**
745
+ * Subscribe to real-time changes in a global table.
746
+ * @param {HoloSphere} holoInstance - The HoloSphere instance.
747
+ * @param {string} tableName - The table name to subscribe to.
748
+ * @param {string|null} key - Specific key to subscribe to, or null for all keys.
749
+ * @param {function} callback - Callback for data changes.
750
+ * @param {object} [options] - Subscription options.
751
+ * @param {boolean} [options.realtimeOnly] - Only fire for new changes.
752
+ * @returns {Promise<{ unsubscribe: () => void }>}
753
+ */
754
+ export async function subscribeGlobal(holoInstance, tableName, key, callback, options = {}) {
755
+ const dataPath = holoInstance.gun.get(holoInstance.appname).get(tableName);
756
+ let active = true;
757
+
758
+ if (key) {
759
+ // Subscribe to a specific key
760
+ dataPath.get(key).on(async (data) => {
761
+ if (!active || !data) return;
762
+ try {
763
+ const parsed = await holoInstance.parse(data);
764
+ if (parsed) callback(parsed, key);
765
+ } catch (e) {
766
+ console.warn('[subscribeGlobal] Error parsing data:', e);
767
+ }
768
+ });
769
+ } else {
770
+ // Subscribe to all keys in the table
771
+ dataPath.map().on(async (data, k) => {
772
+ if (!active || !data || k === '_') return;
773
+ try {
774
+ const parsed = await holoInstance.parse(data);
775
+ if (parsed) callback(parsed, k);
776
+ } catch (e) {
777
+ console.warn('[subscribeGlobal] Error parsing data:', e);
778
+ }
779
+ });
780
+ }
781
+
782
+ return {
783
+ unsubscribe: () => {
784
+ active = false;
785
+ if (key) {
786
+ dataPath.get(key).off();
787
+ } else {
788
+ dataPath.off();
789
+ }
790
+ },
791
+ stop: () => {
792
+ active = false;
793
+ if (key) {
794
+ dataPath.get(key).off();
795
+ } else {
796
+ dataPath.off();
797
+ }
798
+ }
799
+ };
800
+ }
801
+
733
802
  // Export all global operations as default
734
803
  export default {
735
804
  putGlobal,
736
805
  getGlobal,
737
806
  getAllGlobal,
738
807
  deleteGlobal,
739
- deleteAllGlobal
740
- };
808
+ deleteAllGlobal,
809
+ subscribeGlobal
810
+ };
@@ -0,0 +1,321 @@
1
+ /**
2
+ * GunDB-based federation handshake protocol for HoloSphere v1.3
3
+ * Replaces Nostr NIP-44 encrypted DMs with GunDB DM channels.
4
+ * Same protocol payloads (JSON with type: federation_request/response/update),
5
+ * different transport layer.
6
+ */
7
+
8
+ /**
9
+ * Generate a unique message ID
10
+ */
11
+ function generateMessageId() {
12
+ return Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
13
+ }
14
+
15
+ /**
16
+ * Get the DM path for a recipient in GunDB
17
+ */
18
+ function getDMPath(holosphere, recipientPubKey) {
19
+ return holosphere.gun.get(holosphere.appname).get('_dm').get(recipientPubKey);
20
+ }
21
+
22
+ /**
23
+ * Write a DM to a recipient's channel
24
+ */
25
+ async function sendDM(holosphere, recipientPubKey, message) {
26
+ const msgId = generateMessageId();
27
+ const payload = JSON.stringify({
28
+ ...message,
29
+ id: msgId,
30
+ timestamp: Date.now()
31
+ });
32
+
33
+ return new Promise((resolve) => {
34
+ getDMPath(holosphere, recipientPubKey).get(msgId).put(payload, (ack) => {
35
+ if (ack.err) {
36
+ console.warn('[handshake] Failed to send DM:', ack.err);
37
+ resolve({ success: false, error: ack.err });
38
+ } else {
39
+ resolve({ success: true, id: msgId });
40
+ }
41
+ });
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Subscribe to federation DMs for a public key.
47
+ * Listens for incoming federation requests, responses, and updates.
48
+ *
49
+ * @param {object} holosphere - The HoloSphere instance
50
+ * @param {string|Uint8Array} privateKey - The user's private key (for identity)
51
+ * @param {string} publicKey - The user's public key
52
+ * @param {object} handlers - Event handlers
53
+ * @param {function} handlers.onRequest - Called when a federation request is received
54
+ * @param {function} handlers.onResponse - Called when a federation response is received
55
+ * @param {function} handlers.onUpdate - Called when a federation update is received
56
+ * @param {function} handlers.onUpdateResponse - Called when an update response is received
57
+ * @returns {function} Unsubscribe function
58
+ */
59
+ export function subscribeToFederationDMs(holosphere, privateKey, publicKey, handlers) {
60
+ const dmPath = getDMPath(holosphere, publicKey);
61
+ let active = true;
62
+ const processedMessages = new Set();
63
+
64
+ dmPath.map().on((data, key) => {
65
+ if (!active || !data || key === '_') return;
66
+
67
+ // Avoid processing the same message twice
68
+ if (processedMessages.has(key)) return;
69
+ processedMessages.add(key);
70
+
71
+ try {
72
+ const message = typeof data === 'string' ? JSON.parse(data) : data;
73
+ const senderPubKey = message.senderPubKey || message.sender || '';
74
+
75
+ switch (message.type) {
76
+ case 'federation_request':
77
+ if (handlers.onRequest) {
78
+ handlers.onRequest(message, senderPubKey);
79
+ }
80
+ break;
81
+ case 'federation_response':
82
+ if (handlers.onResponse) {
83
+ handlers.onResponse(message, senderPubKey);
84
+ }
85
+ break;
86
+ case 'federation_update':
87
+ if (handlers.onUpdate) {
88
+ handlers.onUpdate(message, senderPubKey);
89
+ }
90
+ break;
91
+ case 'federation_update_response':
92
+ if (handlers.onUpdateResponse) {
93
+ handlers.onUpdateResponse(message, senderPubKey);
94
+ }
95
+ break;
96
+ default:
97
+ console.log('[handshake] Unknown DM type:', message.type);
98
+ }
99
+ } catch (e) {
100
+ // Skip unparseable messages
101
+ }
102
+ });
103
+
104
+ // Return unsubscribe function
105
+ return () => {
106
+ active = false;
107
+ dmPath.off();
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Initiate a federation handshake with a partner.
113
+ *
114
+ * @param {object} holosphere - The HoloSphere instance
115
+ * @param {string|Uint8Array} privateKey - The initiator's private key
116
+ * @param {object} params - Handshake parameters
117
+ * @param {string} params.partnerPubKey - The partner's public key
118
+ * @param {string} params.holonId - The initiator's holon ID
119
+ * @param {string} params.holonName - The initiator's holon name
120
+ * @param {object} [params.lensConfig] - Lens configuration to share
121
+ * @param {string} [params.message] - Optional message
122
+ * @returns {Promise<{ success: boolean, requestId?: string }>}
123
+ */
124
+ export async function initiateFederationHandshake(holosphere, privateKey, params) {
125
+ const {
126
+ partnerPubKey,
127
+ holonId,
128
+ holonName,
129
+ lensConfig = {},
130
+ message = ''
131
+ } = params;
132
+
133
+ const senderPubKey = holosphere.client?.publicKey || '';
134
+
135
+ const request = {
136
+ type: 'federation_request',
137
+ senderPubKey,
138
+ senderHolonId: holonId,
139
+ senderHolonName: holonName,
140
+ lensConfig,
141
+ message,
142
+ status: 'pending'
143
+ };
144
+
145
+ const result = await sendDM(holosphere, partnerPubKey, request);
146
+ if (result.success) {
147
+ console.log('[handshake] Federation request sent to:', partnerPubKey?.slice(0, 8));
148
+ }
149
+ return { success: result.success, requestId: result.id };
150
+ }
151
+
152
+ /**
153
+ * Accept a federation request.
154
+ *
155
+ * @param {object} holosphere - The HoloSphere instance
156
+ * @param {string|Uint8Array} privateKey - The responder's private key
157
+ * @param {object} params - Accept parameters
158
+ * @param {string} params.requesterPubKey - The requester's public key
159
+ * @param {string} params.holonId - The responder's holon ID
160
+ * @param {string} params.holonName - The responder's holon name
161
+ * @param {object} [params.lensConfig] - Lens configuration
162
+ * @param {string} [params.requestId] - Original request ID
163
+ * @returns {Promise<{ success: boolean }>}
164
+ */
165
+ export async function acceptFederationRequest(holosphere, privateKey, params) {
166
+ const {
167
+ requesterPubKey,
168
+ holonId,
169
+ holonName,
170
+ lensConfig = {},
171
+ requestId
172
+ } = params;
173
+
174
+ const senderPubKey = holosphere.client?.publicKey || '';
175
+
176
+ const response = {
177
+ type: 'federation_response',
178
+ senderPubKey,
179
+ responderHolonId: holonId,
180
+ responderHolonName: holonName,
181
+ lensConfig,
182
+ status: 'accepted',
183
+ requestId
184
+ };
185
+
186
+ // Add the requester as an allowed author
187
+ if (requesterPubKey) {
188
+ holosphere.addAllowedAuthor(requesterPubKey);
189
+ }
190
+
191
+ return sendDM(holosphere, requesterPubKey, response);
192
+ }
193
+
194
+ /**
195
+ * Reject a federation request.
196
+ */
197
+ export async function rejectFederationRequest(holosphere, privateKey, params) {
198
+ const { requesterPubKey, holonId, reason = '', requestId } = params;
199
+ const senderPubKey = holosphere.client?.publicKey || '';
200
+
201
+ const response = {
202
+ type: 'federation_response',
203
+ senderPubKey,
204
+ responderHolonId: holonId,
205
+ status: 'rejected',
206
+ reason,
207
+ requestId
208
+ };
209
+
210
+ return sendDM(holosphere, requesterPubKey, response);
211
+ }
212
+
213
+ /**
214
+ * Process a received federation response.
215
+ * Creates the federation relationship on the initiator's side.
216
+ *
217
+ * @param {object} holosphere - The HoloSphere instance
218
+ * @param {object} response - The response object
219
+ * @param {string} senderPubKey - The responder's public key
220
+ * @param {object} options - Processing options
221
+ * @param {string} options.holonId - The initiator's holon ID
222
+ * @param {string[]} [options.inboundLenses] - Lenses to accept inbound data for
223
+ * @returns {Promise<{ success: boolean }>}
224
+ */
225
+ export async function processFederationResponse(holosphere, response, senderPubKey, options = {}) {
226
+ const { holonId, inboundLenses = [] } = options;
227
+
228
+ if (response.status !== 'accepted') {
229
+ return { success: false, reason: 'not_accepted' };
230
+ }
231
+
232
+ try {
233
+ // Add the responder as an allowed author
234
+ if (senderPubKey) {
235
+ holosphere.addAllowedAuthor(senderPubKey);
236
+ }
237
+
238
+ // Store the responder's holon ID for federation lookup
239
+ const responderHolonId = response.responderHolonId || senderPubKey;
240
+
241
+ console.log('[handshake] Processing accepted federation from:', responderHolonId?.slice(0, 8));
242
+ return { success: true, responderHolonId };
243
+ } catch (error) {
244
+ console.error('[handshake] Error processing federation response:', error);
245
+ return { success: false, error: error.message };
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Request a federation update (e.g., change shared lenses).
251
+ *
252
+ * @param {object} holosphere - The HoloSphere instance
253
+ * @param {string|Uint8Array} privateKey - The requester's private key
254
+ * @param {object} params - Update parameters
255
+ * @param {string} params.partnerPubKey - The partner's public key
256
+ * @param {string} params.holonId - The requester's holon ID
257
+ * @param {string} params.holonName - The requester's holon name
258
+ * @param {object} params.newLensConfig - The new lens configuration
259
+ * @param {string} [params.message] - Optional message
260
+ * @returns {Promise<{ success: boolean }>}
261
+ */
262
+ export async function requestFederationUpdate(holosphere, privateKey, params) {
263
+ const {
264
+ partnerPubKey,
265
+ holonId,
266
+ holonName,
267
+ newLensConfig = {},
268
+ message = ''
269
+ } = params;
270
+
271
+ const senderPubKey = holosphere.client?.publicKey || '';
272
+
273
+ const update = {
274
+ type: 'federation_update',
275
+ senderPubKey,
276
+ senderHolonId: holonId,
277
+ senderHolonName: holonName,
278
+ newLensConfig,
279
+ message
280
+ };
281
+
282
+ return sendDM(holosphere, partnerPubKey, update);
283
+ }
284
+
285
+ /**
286
+ * Accept a federation update request.
287
+ */
288
+ export async function acceptFederationUpdate(holosphere, privateKey, params) {
289
+ const { requesterPubKey, holonId, newLensConfig = {}, updateId } = params;
290
+ const senderPubKey = holosphere.client?.publicKey || '';
291
+
292
+ const response = {
293
+ type: 'federation_update_response',
294
+ senderPubKey,
295
+ responderHolonId: holonId,
296
+ status: 'accepted',
297
+ newLensConfig,
298
+ updateId
299
+ };
300
+
301
+ return sendDM(holosphere, requesterPubKey, response);
302
+ }
303
+
304
+ /**
305
+ * Reject a federation update request.
306
+ */
307
+ export async function rejectFederationUpdate(holosphere, privateKey, params) {
308
+ const { requesterPubKey, holonId, reason = '', updateId } = params;
309
+ const senderPubKey = holosphere.client?.publicKey || '';
310
+
311
+ const response = {
312
+ type: 'federation_update_response',
313
+ senderPubKey,
314
+ responderHolonId: holonId,
315
+ status: 'rejected',
316
+ reason,
317
+ updateId
318
+ };
319
+
320
+ return sendDM(holosphere, requesterPubKey, response);
321
+ }
package/hologram.js CHANGED
@@ -78,7 +78,37 @@ export function isHologram(data) {
78
78
  }
79
79
 
80
80
  /**
81
- * Resolves a hologram to its actual data
81
+ * Attaches the canonical `_hologram` envelope to data resolved from a soul reference.
82
+ *
83
+ * This is the SINGLE source of truth for resolved-hologram metadata in HoloSphere.
84
+ * Every code path that returns "data resolved from a hologram reference" must go
85
+ * through this helper so consumers can rely on one shape.
86
+ *
87
+ * @param {object} originalData - The data fetched from the source holon/lens/key.
88
+ * @param {string} hologramSoul - The soul path the hologram pointed at.
89
+ * @returns {object} - originalData merged with `_hologram: { isHologram, soul, sourceHolon, sourceLens, sourceKey, resolvedAt }`.
90
+ */
91
+ export function attachHologramMeta(originalData, hologramSoul) {
92
+ const parts = parseSoulPath(hologramSoul);
93
+ return {
94
+ ...originalData,
95
+ _hologram: {
96
+ isHologram: true,
97
+ soul: hologramSoul,
98
+ sourceHolon: parts?.holon ?? null,
99
+ sourceLens: parts?.lens ?? null,
100
+ sourceKey: parts?.key ?? null,
101
+ resolvedAt: Date.now(),
102
+ },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Resolves a hologram to its actual data.
108
+ *
109
+ * On success, the returned object spreads `originalData` and attaches the
110
+ * canonical `_hologram` envelope (see {@link attachHologramMeta}).
111
+ *
82
112
  * @param {HoloSphere} holoInstance - The HoloSphere instance.
83
113
  * @param {object} hologram - The hologram to resolve
84
114
  * @param {object} [options] - Optional parameters
@@ -86,7 +116,7 @@ export function isHologram(data) {
86
116
  * @param {Set<string>} [options.visited] - Internal use: Tracks visited souls to prevent loops
87
117
  * @param {number} [options.maxDepth=10] - Maximum resolution depth to prevent infinite loops
88
118
  * @param {number} [options.currentDepth=0] - Current resolution depth
89
- * @returns {Promise<object|null>} - The resolved data, null if resolution failed due to target not found, or the original hologram for circular/invalid cases.
119
+ * @returns {Promise<object|null>} - The resolved data with `_hologram` attached, null if resolution failed due to target not found, or the original hologram for circular/invalid cases.
90
120
  */
91
121
  export async function resolveHologram(holoInstance, hologram, options = {}) {
92
122
  if (!isHologram(hologram)) {
@@ -143,16 +173,9 @@ export async function resolveHologram(holoInstance, hologram, options = {}) {
143
173
  );
144
174
 
145
175
  if (originalData && !originalData._invalidHologram) {
146
- // Structure for the returned object - isHologram (top-level) is removed
147
- return {
148
- ...originalData,
149
- _meta: {
150
- ...(originalData._meta || {}), // Preserve original _meta
151
- resolvedFromHologram: true, // This is now the primary indicator
152
- hologramSoul: hologram.soul, // Clarified meta field
153
- resolutionDepth: currentDepth
154
- }
155
- };
176
+ // Attach the canonical `_hologram` envelope. This is the only
177
+ // resolved-hologram indicator HoloSphere emits.
178
+ return attachHologramMeta(originalData, hologram.soul);
156
179
  } else {
157
180
  console.warn(`!!! Original data NOT FOUND for soul: ${hologram.soul}. Removing broken hologram.`);
158
181
 
@@ -179,5 +202,6 @@ export default {
179
202
  createHologram,
180
203
  parseSoulPath,
181
204
  isHologram,
182
- resolveHologram
183
- };
205
+ resolveHologram,
206
+ attachHologramMeta
207
+ };