@enbox/agent 0.5.8 → 0.5.10

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.
@@ -6,7 +6,7 @@ import type { GenericMessage, MessageEvent, MessagesSubscribeReply, MessagesSync
6
6
  import ms from 'ms';
7
7
 
8
8
  import { Level } from 'level';
9
- import { hashToHex, initDefaultHashes, Message } from '@enbox/dwn-sdk-js';
9
+ import { Encoder, hashToHex, initDefaultHashes, Message } from '@enbox/dwn-sdk-js';
10
10
 
11
11
  import type { PermissionsApi } from './types/permissions.js';
12
12
  import type { EnboxAgent, EnboxPlatformAgent } from './types/agent.js';
@@ -16,7 +16,7 @@ import { AgentPermissionsApi } from './permissions-api.js';
16
16
  import { DwnInterface } from './types/dwn.js';
17
17
  import { isRecordsWrite } from './utils.js';
18
18
  import { topologicalSort } from './sync-topological-sort.js';
19
- import { pullMessages, pushMessages } from './sync-messages.js';
19
+ import { fetchRemoteMessages, pullMessages, pushMessages } from './sync-messages.js';
20
20
 
21
21
  export type SyncEngineLevelParams = {
22
22
  agent?: EnboxPlatformAgent;
@@ -40,56 +40,6 @@ const MAX_DIFF_DEPTH = 16;
40
40
  const BATCHED_DIFF_DEPTH = 8;
41
41
 
42
42
  /**
43
- * Maximum number of concurrent remote HTTP requests during a tree diff.
44
- * The binary tree walk fans out in parallel — without a limit, depth N
45
- * produces 2^N concurrent requests, which can exhaust server rate limits.
46
- */
47
- const REMOTE_CONCURRENCY = 4;
48
-
49
- /**
50
- * Counting semaphore for bounding concurrent async operations.
51
- * Used by the tree walk to limit in-flight remote HTTP requests.
52
- */
53
- class Semaphore {
54
- private _permits: number;
55
- private readonly _waiting: (() => void)[] = [];
56
-
57
- constructor(permits: number) {
58
- this._permits = permits;
59
- }
60
-
61
- /** Wait until a permit is available, then consume one. */
62
- async acquire(): Promise<void> {
63
- if (this._permits > 0) {
64
- this._permits--;
65
- return;
66
- }
67
- return new Promise<void>((resolve) => {
68
- this._waiting.push(resolve);
69
- });
70
- }
71
-
72
- /** Release a permit, waking the next waiter if any. */
73
- release(): void {
74
- const next = this._waiting.shift();
75
- if (next) {
76
- next();
77
- } else {
78
- this._permits++;
79
- }
80
- }
81
-
82
- /** Acquire a permit, run the task, then release regardless of outcome. */
83
- async run<T>(fn: () => Promise<T>): Promise<T> {
84
- await this.acquire();
85
- try {
86
- return await fn();
87
- } finally {
88
- this.release();
89
- }
90
- }
91
- }
92
-
93
43
  /**
94
44
  * Key for the subscription cursor sublevel. Cursors are keyed by
95
45
  * `{did}^{dwnUrl}[^{protocol}]` and store an opaque EventLog cursor string.
@@ -572,28 +522,19 @@ export class SyncEngineLevel implements SyncEngine {
572
522
  const filters = protocol ? [{ protocol }] : [];
573
523
 
574
524
  // Look up permission grant for MessagesSubscribe if using a delegate.
525
+ // The unified scope matching in AgentPermissionsApi accepts a
526
+ // Messages.Read grant for MessagesSubscribe requests, so a single
527
+ // lookup is sufficient.
575
528
  let permissionGrantId: string | undefined;
576
529
  if (delegateDid) {
577
- try {
578
- const grant = await this._permissionsApi.getPermissionForRequest({
579
- connectedDid : did,
580
- messageType : DwnInterface.MessagesSubscribe,
581
- delegateDid,
582
- protocol,
583
- cached : true
584
- });
585
- permissionGrantId = grant.grant.id;
586
- } catch {
587
- // Fall back to trying MessagesRead which is a unified scope.
588
- const grant = await this._permissionsApi.getPermissionForRequest({
589
- connectedDid : did,
590
- messageType : DwnInterface.MessagesRead,
591
- delegateDid,
592
- protocol,
593
- cached : true
594
- });
595
- permissionGrantId = grant.grant.id;
596
- }
530
+ const grant = await this._permissionsApi.getPermissionForRequest({
531
+ connectedDid : did,
532
+ messageType : DwnInterface.MessagesSubscribe,
533
+ delegateDid,
534
+ protocol,
535
+ cached : true
536
+ });
537
+ permissionGrantId = grant.grant.id;
597
538
  }
598
539
 
599
540
  // Define the subscription handler that processes incoming events.
@@ -608,15 +549,33 @@ export class SyncEngineLevel implements SyncEngine {
608
549
  if (subMessage.type === 'event') {
609
550
  const event: MessageEvent = subMessage.event;
610
551
  try {
611
- // Process the message locally.
612
- const dataStream = this.extractDataStream(event);
552
+ // Extract inline data from the event (available for records <= 30 KB).
553
+ let dataStream = this.extractDataStream(event);
554
+
555
+ // For large RecordsWrite messages (no inline data), fetch the data
556
+ // from the remote DWN via MessagesRead before storing locally.
557
+ if (!dataStream && isRecordsWrite(event) && (event.message.descriptor as any).dataCid) {
558
+ const messageCid = await Message.getCid(event.message);
559
+ const fetched = await fetchRemoteMessages({
560
+ did, dwnUrl, delegateDid, protocol,
561
+ messageCids : [messageCid],
562
+ agent : this.agent,
563
+ permissionsApi : this._permissionsApi,
564
+ });
565
+ if (fetched.length > 0 && fetched[0].dataStream) {
566
+ dataStream = fetched[0].dataStream;
567
+ }
568
+ }
569
+
613
570
  await this.agent.dwn.processRawMessage(did, event.message, { dataStream });
571
+
572
+ // Only advance the cursor after successful processing.
573
+ // If processing fails, the event will be re-delivered on
574
+ // reconnection (cursor-based resume from the last good point).
575
+ await this.setCursor(cursorKey, subMessage.cursor);
614
576
  } catch (error: any) {
615
577
  console.error(`SyncEngineLevel: Error processing live-pull event for ${did}`, error);
616
578
  }
617
-
618
- // Persist cursor for resume on reconnect.
619
- await this.setCursor(cursorKey, subMessage.cursor);
620
579
  }
621
580
  };
622
581
 
@@ -703,7 +662,7 @@ export class SyncEngineLevel implements SyncEngine {
703
662
  if (delegateDid) {
704
663
  const grant = await this._permissionsApi.getPermissionForRequest({
705
664
  connectedDid : did,
706
- messageType : DwnInterface.MessagesRead,
665
+ messageType : DwnInterface.MessagesSubscribe,
707
666
  delegateDid,
708
667
  protocol,
709
668
  cached : true,
@@ -774,10 +733,11 @@ export class SyncEngineLevel implements SyncEngine {
774
733
  const entries = [...this._pendingPushCids.entries()];
775
734
  this._pendingPushCids.clear();
776
735
 
777
- for (const [, pending] of entries) {
736
+ // Push to all endpoints in parallel — each target is independent.
737
+ await Promise.all(entries.map(async ([, pending]) => {
778
738
  const { did, dwnUrl, delegateDid, protocol, cids } = pending;
779
739
  if (cids.length === 0) {
780
- continue;
740
+ return;
781
741
  }
782
742
 
783
743
  try {
@@ -789,8 +749,25 @@ export class SyncEngineLevel implements SyncEngine {
789
749
  });
790
750
  } catch (error: any) {
791
751
  console.error(`SyncEngineLevel: Push-on-write failed for ${did} -> ${dwnUrl}`, error);
752
+
753
+ // Re-queue the failed CIDs so they are retried on the next
754
+ // debounce cycle (or picked up by the SMT integrity check).
755
+ const targetKey = this.buildCursorKey(did, dwnUrl, protocol);
756
+ let requeued = this._pendingPushCids.get(targetKey);
757
+ if (!requeued) {
758
+ requeued = { did, dwnUrl, delegateDid, protocol, cids: [] };
759
+ this._pendingPushCids.set(targetKey, requeued);
760
+ }
761
+ requeued.cids.push(...cids);
762
+
763
+ // Schedule a retry after a short delay.
764
+ if (!this._pushDebounceTimer) {
765
+ this._pushDebounceTimer = setTimeout((): void => {
766
+ void this.flushPendingPushes();
767
+ }, PUSH_DEBOUNCE_MS * 4); // Back off: 1 second instead of 250ms.
768
+ }
792
769
  }
793
- }
770
+ }));
794
771
  }
795
772
 
796
773
  // ---------------------------------------------------------------------------
@@ -825,24 +802,35 @@ export class SyncEngineLevel implements SyncEngine {
825
802
  // ---------------------------------------------------------------------------
826
803
 
827
804
  /**
828
- * Extracts a ReadableStream from a MessageEvent if it contains a RecordsWrite with data.
805
+ * Extracts a ReadableStream from a MessageEvent if it contains a
806
+ * RecordsWrite with data — either as an inline `encodedData` field
807
+ * (for records <= 30 KB) or as a pre-existing data stream.
829
808
  */
830
809
  private extractDataStream(event: MessageEvent): ReadableStream<Uint8Array> | undefined {
831
- if (isRecordsWrite(event) && (event as any).data) {
810
+ if (!isRecordsWrite(event)) {
811
+ return undefined;
812
+ }
813
+
814
+ // Check for inline base64url-encoded data (small records from EventLog).
815
+ const encodedData = (event.message as any).encodedData as string | undefined;
816
+ if (encodedData) {
817
+ const bytes = Encoder.base64UrlToBytes(encodedData);
818
+ return new ReadableStream<Uint8Array>({
819
+ start(controller): void {
820
+ controller.enqueue(bytes);
821
+ controller.close();
822
+ }
823
+ });
824
+ }
825
+
826
+ // Check for a pre-existing data stream (e.g. from a direct message read).
827
+ if ((event as any).data) {
832
828
  return (event as any).data;
833
829
  }
830
+
834
831
  return undefined;
835
832
  }
836
833
 
837
- /**
838
- * Synchronously attempts to get a message CID. Returns undefined on failure.
839
- * This is used in the synchronous EventLog callback; the actual CID computation
840
- * is fast for already-constructed messages.
841
- */
842
- // tryGetCidSync was removed — it tried to return a CID synchronously
843
- // from an async SHA-256 computation, which always returned undefined.
844
- // The local push handler now awaits Message.getCid() directly.
845
-
846
834
  // ---------------------------------------------------------------------------
847
835
  // Default Hash Cache
848
836
  // ---------------------------------------------------------------------------
@@ -958,97 +946,6 @@ export class SyncEngineLevel implements SyncEngine {
958
946
  }
959
947
 
960
948
  // ---------------------------------------------------------------------------
961
- // Tree Diff — walk the SMT to find divergent leaf sets
962
- // ---------------------------------------------------------------------------
963
-
964
- /**
965
- * Walks the local and remote SMTs in parallel, recursing into subtrees whose
966
- * hashes differ, until reaching `MAX_DIFF_DEPTH` where leaves are enumerated.
967
- *
968
- * Returns the sets of messageCids that exist only locally or only remotely.
969
- */
970
- private async walkTreeDiff({ did, dwnUrl, delegateDid, protocol }: {
971
- did: string;
972
- dwnUrl: string;
973
- delegateDid?: string;
974
- protocol?: string;
975
- }): Promise<{ onlyLocal: string[]; onlyRemote: string[] }> {
976
- const onlyLocal: string[] = [];
977
- const onlyRemote: string[] = [];
978
-
979
- // Hoist permission grant lookup — resolved once and reused for all subtree/leaf requests.
980
- const permissionGrantId = await this.getSyncPermissionGrantId(did, delegateDid, protocol);
981
-
982
- // Gate remote HTTP requests through a semaphore so the binary tree walk
983
- // doesn't produce an exponential burst of concurrent requests. Local
984
- // DWN requests (in-process) are not gated.
985
- const remoteSemaphore = new Semaphore(REMOTE_CONCURRENCY);
986
-
987
- const walk = async (prefix: string): Promise<void> => {
988
- // Get subtree hashes for this prefix from local and remote.
989
- // Only the remote request is gated by the semaphore.
990
- const [localHash, remoteHash] = await Promise.all([
991
- this.getLocalSubtreeHash(did, prefix, delegateDid, protocol, permissionGrantId),
992
- remoteSemaphore.run(() => this.getRemoteSubtreeHash(did, dwnUrl, prefix, delegateDid, protocol, permissionGrantId)),
993
- ]);
994
-
995
- // If hashes match, this subtree is identical — skip.
996
- if (localHash === remoteHash) {
997
- return;
998
- }
999
-
1000
- // Short-circuit: if one side is the default (empty-subtree) hash, all entries
1001
- // on the other side are unique. Enumerate leaves directly instead of recursing
1002
- // further into the tree — this avoids an exponential walk when one DWN has
1003
- // entries that the other lacks entirely in this subtree.
1004
- const emptyHash = await this.getDefaultHashHex(prefix.length);
1005
- if (remoteHash === emptyHash && localHash !== emptyHash) {
1006
- const localLeaves = await this.getLocalLeaves(did, prefix, delegateDid, protocol, permissionGrantId);
1007
- onlyLocal.push(...localLeaves);
1008
- return;
1009
- }
1010
- if (localHash === emptyHash && remoteHash !== emptyHash) {
1011
- const remoteLeaves = await remoteSemaphore.run(
1012
- () => this.getRemoteLeaves(did, dwnUrl, prefix, delegateDid, protocol, permissionGrantId),
1013
- );
1014
- onlyRemote.push(...remoteLeaves);
1015
- return;
1016
- }
1017
-
1018
- // If we've reached the maximum diff depth, enumerate leaves.
1019
- if (prefix.length >= MAX_DIFF_DEPTH) {
1020
- const [localLeaves, remoteLeaves] = await Promise.all([
1021
- this.getLocalLeaves(did, prefix, delegateDid, protocol, permissionGrantId),
1022
- remoteSemaphore.run(() => this.getRemoteLeaves(did, dwnUrl, prefix, delegateDid, protocol, permissionGrantId)),
1023
- ]);
1024
-
1025
- const localSet = new Set(localLeaves);
1026
- const remoteSet = new Set(remoteLeaves);
1027
-
1028
- for (const cid of localLeaves) {
1029
- if (!remoteSet.has(cid)) {
1030
- onlyLocal.push(cid);
1031
- }
1032
- }
1033
- for (const cid of remoteLeaves) {
1034
- if (!localSet.has(cid)) {
1035
- onlyRemote.push(cid);
1036
- }
1037
- }
1038
- return;
1039
- }
1040
-
1041
- // Recurse into left (0) and right (1) children in parallel.
1042
- await Promise.all([
1043
- walk(prefix + '0'),
1044
- walk(prefix + '1'),
1045
- ]);
1046
- };
1047
-
1048
- await walk('');
1049
- return { onlyLocal, onlyRemote };
1050
- }
1051
-
1052
949
  // ---------------------------------------------------------------------------
1053
950
  // Batched Diff — single round-trip set reconciliation
1054
951
  // ---------------------------------------------------------------------------
@@ -1104,7 +1001,8 @@ export class SyncEngineLevel implements SyncEngine {
1104
1001
  }
1105
1002
 
1106
1003
  // Step 3: Enumerate local leaves for prefixes the remote reported as onlyLocal.
1107
- const permissionGrantIdForLeaves = await this.getSyncPermissionGrantId(did, delegateDid, protocol);
1004
+ // Reuse the same grant ID from step 2 (avoids redundant lookup).
1005
+ const permissionGrantIdForLeaves = permissionGrantId;
1108
1006
  const onlyLocalCids: string[] = [];
1109
1007
  for (const prefix of reply.onlyLocal ?? []) {
1110
1008
  const leaves = await this.getLocalLeaves(did, prefix, delegateDid, protocol, permissionGrantIdForLeaves);
@@ -1205,32 +1103,6 @@ export class SyncEngineLevel implements SyncEngine {
1205
1103
  return reply.hash ?? '';
1206
1104
  }
1207
1105
 
1208
- private async getRemoteSubtreeHash(
1209
- did: string, dwnUrl: string, prefix: string, delegateDid?: string, protocol?: string, permissionGrantId?: string
1210
- ): Promise<string> {
1211
- const syncMessage = await this.agent.dwn.processRequest({
1212
- store : false,
1213
- author : did,
1214
- target : did,
1215
- messageType : DwnInterface.MessagesSync,
1216
- granteeDid : delegateDid,
1217
- messageParams : {
1218
- action: 'subtree',
1219
- prefix,
1220
- protocol,
1221
- permissionGrantId
1222
- }
1223
- });
1224
-
1225
- const reply = await this.agent.rpc.sendDwnRequest({
1226
- dwnUrl,
1227
- targetDid : did,
1228
- message : syncMessage.message,
1229
- }) as MessagesSyncReply;
1230
-
1231
- return reply.hash ?? '';
1232
- }
1233
-
1234
1106
  /**
1235
1107
  * Get all leaf messageCids under a given prefix from the local DWN.
1236
1108
  *
@@ -1265,32 +1137,6 @@ export class SyncEngineLevel implements SyncEngine {
1265
1137
  return reply.entries ?? [];
1266
1138
  }
1267
1139
 
1268
- private async getRemoteLeaves(
1269
- did: string, dwnUrl: string, prefix: string, delegateDid?: string, protocol?: string, permissionGrantId?: string
1270
- ): Promise<string[]> {
1271
- const syncMessage = await this.agent.dwn.processRequest({
1272
- store : false,
1273
- author : did,
1274
- target : did,
1275
- messageType : DwnInterface.MessagesSync,
1276
- granteeDid : delegateDid,
1277
- messageParams : {
1278
- action: 'leaves',
1279
- prefix,
1280
- protocol,
1281
- permissionGrantId
1282
- }
1283
- });
1284
-
1285
- const reply = await this.agent.rpc.sendDwnRequest({
1286
- dwnUrl,
1287
- targetDid : did,
1288
- message : syncMessage.message,
1289
- }) as MessagesSyncReply;
1290
-
1291
- return reply.entries ?? [];
1292
- }
1293
-
1294
1140
  // ---------------------------------------------------------------------------
1295
1141
  // Pull / Push — delegates to standalone functions in sync-messages.ts
1296
1142
  // ---------------------------------------------------------------------------