@enbox/agent 0.1.4 → 0.1.6

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.
Files changed (66) hide show
  1. package/dist/browser.mjs +11 -11
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/anonymous-dwn-api.js +184 -0
  4. package/dist/esm/anonymous-dwn-api.js.map +1 -0
  5. package/dist/esm/dwn-api.js +86 -777
  6. package/dist/esm/dwn-api.js.map +1 -1
  7. package/dist/esm/dwn-encryption.js +342 -0
  8. package/dist/esm/dwn-encryption.js.map +1 -0
  9. package/dist/esm/dwn-key-delivery.js +256 -0
  10. package/dist/esm/dwn-key-delivery.js.map +1 -0
  11. package/dist/esm/dwn-record-upgrade.js +119 -0
  12. package/dist/esm/dwn-record-upgrade.js.map +1 -0
  13. package/dist/esm/dwn-type-guards.js +23 -0
  14. package/dist/esm/dwn-type-guards.js.map +1 -0
  15. package/dist/esm/index.js +6 -0
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/protocol-utils.js +158 -0
  18. package/dist/esm/protocol-utils.js.map +1 -0
  19. package/dist/esm/store-data-protocols.js +1 -1
  20. package/dist/esm/store-data-protocols.js.map +1 -1
  21. package/dist/esm/sync-engine-level.js +22 -353
  22. package/dist/esm/sync-engine-level.js.map +1 -1
  23. package/dist/esm/sync-messages.js +234 -0
  24. package/dist/esm/sync-messages.js.map +1 -0
  25. package/dist/esm/sync-topological-sort.js +143 -0
  26. package/dist/esm/sync-topological-sort.js.map +1 -0
  27. package/dist/esm/test-harness.js +20 -0
  28. package/dist/esm/test-harness.js.map +1 -1
  29. package/dist/types/anonymous-dwn-api.d.ts +140 -0
  30. package/dist/types/anonymous-dwn-api.d.ts.map +1 -0
  31. package/dist/types/dwn-api.d.ts +36 -179
  32. package/dist/types/dwn-api.d.ts.map +1 -1
  33. package/dist/types/dwn-encryption.d.ts +144 -0
  34. package/dist/types/dwn-encryption.d.ts.map +1 -0
  35. package/dist/types/dwn-key-delivery.d.ts +112 -0
  36. package/dist/types/dwn-key-delivery.d.ts.map +1 -0
  37. package/dist/types/dwn-record-upgrade.d.ts +33 -0
  38. package/dist/types/dwn-record-upgrade.d.ts.map +1 -0
  39. package/dist/types/dwn-type-guards.d.ts +9 -0
  40. package/dist/types/dwn-type-guards.d.ts.map +1 -0
  41. package/dist/types/index.d.ts +6 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/dist/types/protocol-utils.d.ts +70 -0
  44. package/dist/types/protocol-utils.d.ts.map +1 -0
  45. package/dist/types/sync-engine-level.d.ts +5 -42
  46. package/dist/types/sync-engine-level.d.ts.map +1 -1
  47. package/dist/types/sync-messages.d.ts +76 -0
  48. package/dist/types/sync-messages.d.ts.map +1 -0
  49. package/dist/types/sync-topological-sort.d.ts +15 -0
  50. package/dist/types/sync-topological-sort.d.ts.map +1 -0
  51. package/dist/types/test-harness.d.ts +10 -0
  52. package/dist/types/test-harness.d.ts.map +1 -1
  53. package/package.json +5 -5
  54. package/src/anonymous-dwn-api.ts +263 -0
  55. package/src/dwn-api.ts +160 -1015
  56. package/src/dwn-encryption.ts +481 -0
  57. package/src/dwn-key-delivery.ts +370 -0
  58. package/src/dwn-record-upgrade.ts +166 -0
  59. package/src/dwn-type-guards.ts +43 -0
  60. package/src/index.ts +6 -0
  61. package/src/protocol-utils.ts +185 -0
  62. package/src/store-data-protocols.ts +1 -1
  63. package/src/sync-engine-level.ts +24 -413
  64. package/src/sync-messages.ts +277 -0
  65. package/src/sync-topological-sort.ts +167 -0
  66. package/src/test-harness.ts +19 -0
@@ -0,0 +1,185 @@
1
+ import type { ProtocolDefinition, ProtocolRuleSet } from '@enbox/dwn-sdk-js';
2
+
3
+ /**
4
+ * Navigates a protocol definition's structure to find the rule set at a given protocol path.
5
+ * @param protocolDefinition - The protocol definition to search
6
+ * @param protocolPath - The dot-separated protocol path (e.g. 'thread/message')
7
+ * @returns The rule set at the given path, or undefined if the path doesn't exist
8
+ */
9
+ export function getRuleSetAtPath(
10
+ protocolDefinition: ProtocolDefinition,
11
+ protocolPath: string,
12
+ ): ProtocolRuleSet | undefined {
13
+ const segments = protocolPath.split('/');
14
+ let ruleSet: ProtocolRuleSet | undefined =
15
+ protocolDefinition.structure as unknown as ProtocolRuleSet;
16
+ for (const segment of segments) {
17
+ ruleSet = ruleSet[segment] as ProtocolRuleSet | undefined;
18
+ if (!ruleSet) { return undefined; }
19
+ }
20
+ return ruleSet;
21
+ }
22
+
23
+ /**
24
+ * Extracts the root context ID from a contextId or parentContextId.
25
+ * e.g. 'abc/def/ghi' -> 'abc', 'abc' -> 'abc'
26
+ * @param contextId - The context ID to extract the root from
27
+ * @returns The root context ID
28
+ */
29
+ export function getRootContextId(contextId: string): string {
30
+ return contextId.split('/')[0] || contextId;
31
+ }
32
+
33
+ /**
34
+ * Checks if a protocol path represents a multi-party context.
35
+ * Returns true if the root path's subtree contains $role descendants
36
+ * or relational who/of $actions rules that grant read access.
37
+ *
38
+ * @param protocolDefinition - The full protocol definition
39
+ * @param rootProtocolPath - The root protocol path to check
40
+ * @returns true if the protocol path represents a multi-party context
41
+ */
42
+ export function isMultiPartyContext(
43
+ protocolDefinition: ProtocolDefinition,
44
+ rootProtocolPath: string,
45
+ ): boolean {
46
+ const ruleSet = getRuleSetAtPath(protocolDefinition, rootProtocolPath);
47
+ if (!ruleSet) { return false; }
48
+
49
+ // (a) Check for $role descendants in the subtree
50
+ function hasRoleRecursive(rs: ProtocolRuleSet): boolean {
51
+ for (const key in rs) {
52
+ if (!key.startsWith('$')) {
53
+ const child = rs[key] as ProtocolRuleSet;
54
+ if (child.$role === true) { return true; }
55
+ if (hasRoleRecursive(child)) { return true; }
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ if (hasRoleRecursive(ruleSet)) {
62
+ return true;
63
+ }
64
+
65
+ // (b) Check for relational who/of read rules anywhere in the protocol
66
+ // that reference a path within this subtree. A rule like
67
+ // { who: 'recipient', of: 'email', can: ['read'] } on any record
68
+ // type means the email recipient needs a context key.
69
+ return hasRelationalReadAccess(
70
+ undefined, rootProtocolPath, protocolDefinition,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Checks whether any relational who/of rule in the protocol grants
76
+ * read access for a given actor type and ancestor path.
77
+ *
78
+ * Walks the *entire* protocol structure looking for any $actions rule that:
79
+ * - Has `who` equal to `actorType` ('recipient' or 'author'), or any actor
80
+ * type if `actorType` is `undefined`
81
+ * - Has `of` equal to `ofPath`
82
+ * - Has `can` including 'read'
83
+ *
84
+ * @param actorType - 'author' | 'recipient', or undefined for any
85
+ * @param ofPath - The protocol path to check (e.g. 'thread', 'email')
86
+ * @param protocolDefinition - The full protocol definition
87
+ * @returns true if a matching relational read rule exists
88
+ */
89
+ export function hasRelationalReadAccess(
90
+ actorType: 'author' | 'recipient' | undefined,
91
+ ofPath: string,
92
+ protocolDefinition: ProtocolDefinition,
93
+ ): boolean {
94
+ const structure = protocolDefinition.structure as unknown as ProtocolRuleSet;
95
+
96
+ function walkRuleSet(rs: ProtocolRuleSet): boolean {
97
+ // Check $actions on this node
98
+ if (rs.$actions) {
99
+ for (const rule of rs.$actions) {
100
+ if (
101
+ rule.who &&
102
+ rule.who !== 'anyone' &&
103
+ (actorType === undefined || rule.who === actorType) &&
104
+ rule.of === ofPath &&
105
+ rule.can?.includes('read')
106
+ ) {
107
+ return true;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Recurse into child record types
113
+ for (const key in rs) {
114
+ if (!key.startsWith('$')) {
115
+ if (walkRuleSet(rs[key] as ProtocolRuleSet)) {
116
+ return true;
117
+ }
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+
123
+ return walkRuleSet(structure);
124
+ }
125
+
126
+ /**
127
+ * Analyses a record write to determine which DIDs need context key delivery.
128
+ *
129
+ * Returns a set of participant DIDs that should receive `contextKey` records.
130
+ * The DWN owner (tenantDid) is always excluded — they have ProtocolPath access.
131
+ *
132
+ * Cases handled:
133
+ * 1. `$role` record with a recipient -> recipient is a participant
134
+ * 2. Record has a recipient and a relational read rule grants access
135
+ * via `{ who: 'recipient', of: '<path>', can: ['read'] }`
136
+ * 3. Record is authored by an external party -> if `{ who: 'author', of:
137
+ * '<path>', can: ['read'] }` rules grant read access, the author needs
138
+ * a context key.
139
+ *
140
+ * @param params.protocolDefinition - The installed protocol definition
141
+ * @param params.protocolPath - The written record's protocol path
142
+ * @param params.recipient - Recipient DID from the record, if any
143
+ * @param params.tenantDid - The DWN owner's DID (excluded from results)
144
+ * @param params.authorDid - Author DID if externally authored, undefined otherwise
145
+ * @returns Set of DIDs that need context key delivery
146
+ */
147
+ export function detectNewParticipants({ protocolDefinition, protocolPath, recipient, tenantDid, authorDid }: {
148
+ protocolDefinition: ProtocolDefinition;
149
+ protocolPath: string;
150
+ recipient?: string;
151
+ tenantDid: string;
152
+ authorDid?: string;
153
+ }): Set<string> {
154
+ const participants = new Set<string>();
155
+
156
+ // Navigate to the rule set at the given protocol path
157
+ const ruleSet = getRuleSetAtPath(protocolDefinition, protocolPath);
158
+ if (!ruleSet) { return participants; }
159
+
160
+ // Case 1: $role record -> recipient is a participant
161
+ if (ruleSet.$role === true && recipient) {
162
+ participants.add(recipient);
163
+ }
164
+
165
+ // Case 2: Record has a recipient -> check if relational read rules exist
166
+ if (recipient && recipient !== tenantDid) {
167
+ if (hasRelationalReadAccess('recipient', protocolPath, protocolDefinition)) {
168
+ participants.add(recipient);
169
+ }
170
+ }
171
+
172
+ // Case 3: External author -> check if author-based relational read rules exist.
173
+ // If `{ who: 'author', of: '<path>', can: ['read'] }` is defined anywhere
174
+ // in the protocol, the external author needs a context key to decrypt.
175
+ if (authorDid && authorDid !== tenantDid) {
176
+ if (hasRelationalReadAccess('author', protocolPath, protocolDefinition)) {
177
+ participants.add(authorDid);
178
+ }
179
+ }
180
+
181
+ // Remove the DWN owner — they always have ProtocolPath access
182
+ participants.delete(tenantDid);
183
+
184
+ return participants;
185
+ }
@@ -24,7 +24,7 @@ export const IdentityProtocolDefinition: ProtocolDefinition = {
24
24
  };
25
25
 
26
26
  export const KeyDeliveryProtocolDefinition: ProtocolDefinition = {
27
- protocol : 'https://enbox.org/protocols/key-delivery',
27
+ protocol : 'https://identity.foundation/protocols/key-delivery',
28
28
  published : false,
29
29
  types : {
30
30
  contextKey: {
@@ -1,20 +1,9 @@
1
1
  import type { AbstractLevel } from 'abstract-level';
2
- import type {
3
- GenericMessage,
4
- MessagesReadReply,
5
- MessagesSyncReply,
6
- UnionMessageReply,
7
- } from '@enbox/dwn-sdk-js';
2
+ import type { GenericMessage, MessagesSyncReply } from '@enbox/dwn-sdk-js';
8
3
 
9
4
  import ms from 'ms';
10
5
 
11
6
  import { Level } from 'level';
12
- import {
13
- DwnInterfaceName,
14
- DwnMethodName,
15
- Message,
16
- PermissionsProtocol,
17
- } from '@enbox/dwn-sdk-js';
18
7
  import { hashToHex, initDefaultHashes } from '@enbox/dwn-sdk-js';
19
8
 
20
9
  import type { PermissionsApi } from './types/permissions.js';
@@ -23,7 +12,9 @@ import type { Web5Agent, Web5PlatformAgent } from './types/agent.js';
23
12
 
24
13
  import { AgentPermissionsApi } from './permissions-api.js';
25
14
  import { DwnInterface } from './types/dwn.js';
26
- import { getDwnServiceEndpointUrls, isRecordsWrite } from './utils.js';
15
+ import { getDwnServiceEndpointUrls } from './utils.js';
16
+ import { topologicalSort } from './sync-topological-sort.js';
17
+ import { pullMessages, pushMessages } from './sync-messages.js';
27
18
 
28
19
  export type SyncEngineLevelParams = {
29
20
  agent?: Web5PlatformAgent;
@@ -520,14 +511,13 @@ export class SyncEngineLevel implements SyncEngine {
520
511
  // Pull — fetch messages from remote, process locally in dependency order
521
512
  // ---------------------------------------------------------------------------
522
513
 
514
+ // ---------------------------------------------------------------------------
515
+ // Pull / Push — delegates to standalone functions in sync-messages.ts
516
+ // ---------------------------------------------------------------------------
517
+
523
518
  /**
524
- * Fetches missing messages from the remote DWN and processes them on the local DWN
519
+ * Fetches missing messages from the remote DWN and processes them locally
525
520
  * in dependency order (topological sort).
526
- *
527
- * Messages that fail processing are re-fetched from the remote before each retry
528
- * pass rather than buffered in memory. ReadableStream is single-use, so a failed
529
- * message's data stream is consumed on the first attempt. Re-fetching provides a
530
- * fresh stream without holding all record data in memory simultaneously.
531
521
  */
532
522
  private async pullMessages({ did, dwnUrl, delegateDid, protocol, messageCids }: {
533
523
  did: string;
@@ -536,132 +526,16 @@ export class SyncEngineLevel implements SyncEngine {
536
526
  protocol?: string;
537
527
  messageCids: string[];
538
528
  }): Promise<void> {
539
- // Step 1: Fetch all missing messages from the remote in parallel.
540
- const fetched = await this.fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids });
541
-
542
- // Step 2: Build dependency graph and topological sort.
543
- const sorted = SyncEngineLevel.topologicalSort(fetched);
544
-
545
- // Step 3: Process messages in dependency order with multi-pass retry.
546
- // Retry up to MAX_RETRY_PASSES times for messages that fail due to
547
- // dependency ordering issues (e.g., a RecordsWrite whose ProtocolsConfigure
548
- // hasn't committed yet). Failed messages are re-fetched from the remote
549
- // to obtain a fresh data stream, since ReadableStream is single-use.
550
- const MAX_RETRY_PASSES = 3;
551
- let pending = sorted;
552
-
553
- for (let pass = 0; pass <= MAX_RETRY_PASSES && pending.length > 0; pass++) {
554
- const failedCids: string[] = [];
555
-
556
- for (const entry of pending) {
557
- const pullReply = await this.agent.dwn.node.processMessage(did, entry.message, { dataStream: entry.dataStream });
558
- if (!SyncEngineLevel.syncMessageReplyIsSuccessful(pullReply)) {
559
- const cid = await SyncEngineLevel.getMessageCid(entry.message);
560
- failedCids.push(cid);
561
- }
562
- }
563
-
564
- // Re-fetch failed messages from the remote to get fresh data streams.
565
- if (failedCids.length > 0) {
566
- const reFetched = await this.fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids: failedCids });
567
- pending = SyncEngineLevel.topologicalSort(reFetched);
568
- } else {
569
- pending = [];
570
- }
571
- }
572
- }
573
-
574
- /**
575
- * Fetches messages from a remote DWN by their CIDs using MessagesRead.
576
- */
577
- private async fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids }: {
578
- did: string;
579
- dwnUrl: string;
580
- delegateDid?: string;
581
- protocol?: string;
582
- messageCids: string[];
583
- }): Promise<{ message: GenericMessage; dataStream?: ReadableStream<Uint8Array> }[]> {
584
- const results: { message: GenericMessage; dataStream?: ReadableStream<Uint8Array> }[] = [];
585
-
586
- let permissionGrantId: string | undefined;
587
- if (delegateDid) {
588
- try {
589
- const messagesReadGrant = await this._permissionsApi.getPermissionForRequest({
590
- connectedDid : did,
591
- messageType : DwnInterface.MessagesRead,
592
- delegateDid,
593
- protocol,
594
- cached : true
595
- });
596
- permissionGrantId = messagesReadGrant.grant.id;
597
- } catch (error: any) {
598
- console.error('SyncEngineLevel: pull - Error fetching MessagesRead permission grant for delegate DID', error);
599
- return results;
600
- }
601
- }
602
-
603
- // Fetch messages in parallel with bounded concurrency.
604
- const CONCURRENCY = 10;
605
- let cursor = 0;
606
-
607
- while (cursor < messageCids.length) {
608
- const batch = messageCids.slice(cursor, cursor + CONCURRENCY);
609
- cursor += CONCURRENCY;
610
-
611
- type FetchResult = { message: GenericMessage; dataStream?: ReadableStream<Uint8Array> } | undefined;
612
- const batchResults = await Promise.all(batch.map(async (messageCid): Promise<FetchResult> => {
613
- const messagesRead = await this.agent.processDwnRequest({
614
- store : false,
615
- author : did,
616
- target : did,
617
- messageType : DwnInterface.MessagesRead,
618
- granteeDid : delegateDid,
619
- messageParams : { messageCid, permissionGrantId }
620
- });
621
-
622
- let reply: MessagesReadReply;
623
- try {
624
- reply = await this.agent.rpc.sendDwnRequest({
625
- dwnUrl,
626
- targetDid : did,
627
- message : messagesRead.message,
628
- }) as MessagesReadReply;
629
- } catch {
630
- return undefined;
631
- }
632
-
633
- if (reply.status.code !== 200 || !reply.entry?.message) {
634
- return undefined;
635
- }
636
-
637
- const replyEntry = reply.entry;
638
- let dataStream: ReadableStream<Uint8Array> | undefined;
639
- if (isRecordsWrite(replyEntry) && replyEntry.data) {
640
- dataStream = replyEntry.data;
641
- }
642
-
643
- return { message: replyEntry.message, dataStream };
644
- }));
645
-
646
- for (const result of batchResults) {
647
- if (result) {
648
- results.push(result);
649
- }
650
- }
651
- }
652
-
653
- return results;
529
+ return pullMessages({
530
+ did, dwnUrl, delegateDid, protocol, messageCids,
531
+ agent : this.agent,
532
+ permissionsApi : this._permissionsApi,
533
+ });
654
534
  }
655
535
 
656
- // ---------------------------------------------------------------------------
657
- // Push — read local messages, send to remote
658
- // ---------------------------------------------------------------------------
659
-
660
536
  /**
661
- * Reads missing messages from the local DWN and pushes them to the remote DWN.
662
- * Messages are fetched first, then sorted in dependency order (topological sort)
663
- * so that initial writes come before updates, and ProtocolsConfigures come before
664
- * records that reference those protocols.
537
+ * Reads missing messages from the local DWN and pushes them to the remote DWN
538
+ * in dependency order (topological sort).
665
539
  */
666
540
  private async pushMessages({ did, dwnUrl, delegateDid, protocol, messageCids }: {
667
541
  did: string;
@@ -670,288 +544,25 @@ export class SyncEngineLevel implements SyncEngine {
670
544
  protocol?: string;
671
545
  messageCids: string[];
672
546
  }): Promise<void> {
673
- // Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
674
- const fetched: { message: GenericMessage; dataStream?: ReadableStream<Uint8Array> }[] = [];
675
- for (const messageCid of messageCids) {
676
- const dwnMessage = await this.getLocalMessage({ author: did, messageCid, delegateDid, protocol });
677
- if (dwnMessage) {
678
- fetched.push(dwnMessage);
679
- }
680
- }
681
-
682
- // Step 2: Sort in dependency order using topological sort.
683
- const sorted = SyncEngineLevel.topologicalSort(fetched);
684
-
685
- // Step 3: Push messages in dependency order, consuming each stream as we go.
686
- for (const entry of sorted) {
687
- try {
688
- const reply = await this.agent.rpc.sendDwnRequest({
689
- dwnUrl,
690
- targetDid : did,
691
- data : entry.dataStream,
692
- message : entry.message
693
- });
694
-
695
- if (!SyncEngineLevel.syncMessageReplyIsSuccessful(reply)) {
696
- const cid = await SyncEngineLevel.getMessageCid(entry.message);
697
- console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
698
- }
699
- } catch {
700
- // Remote unreachable — stop pushing to this endpoint.
701
- throw new Error(`SyncEngineLevel: Remote DWN at ${dwnUrl} is unreachable.`);
702
- }
703
- }
704
- }
705
-
706
- /**
707
- * Helper to get the CID of a message for logging purposes.
708
- */
709
- private static async getMessageCid(message: GenericMessage): Promise<string> {
710
- try {
711
- return await Message.getCid(message);
712
- } catch {
713
- return 'unknown';
714
- }
715
- }
716
-
717
- /**
718
- * Reads a message from the local DWN by its CID using MessagesRead.
719
- */
720
- private async getLocalMessage({ author, delegateDid, protocol, messageCid }: {
721
- author: string;
722
- delegateDid?: string;
723
- protocol?: string;
724
- messageCid: string;
725
- }): Promise<{ message: GenericMessage; dataStream?: ReadableStream<Uint8Array> } | undefined> {
726
- let permissionGrantId: string | undefined;
727
- if (delegateDid) {
728
- try {
729
- const messagesReadGrant = await this._permissionsApi.getPermissionForRequest({
730
- connectedDid : author,
731
- messageType : DwnInterface.MessagesRead,
732
- delegateDid,
733
- protocol,
734
- cached : true
735
- });
736
- permissionGrantId = messagesReadGrant.grant.id;
737
- } catch (error: any) {
738
- console.error('SyncEngineLevel: push - Error fetching MessagesRead permission grant for delegate DID', error);
739
- return;
740
- }
741
- }
742
-
743
- const { reply } = await this.agent.dwn.processRequest({
744
- author,
745
- target : author,
746
- messageType : DwnInterface.MessagesRead,
747
- granteeDid : delegateDid,
748
- messageParams : { messageCid, permissionGrantId }
547
+ return pushMessages({
548
+ did, dwnUrl, delegateDid, protocol, messageCids,
549
+ agent : this.agent,
550
+ permissionsApi : this._permissionsApi,
749
551
  });
750
-
751
- if (reply.status.code !== 200 || !reply.entry) {
752
- return undefined;
753
- }
754
- const messageEntry = reply.entry!;
755
-
756
- const result: { message: GenericMessage; dataStream?: ReadableStream<Uint8Array> } = {
757
- message: messageEntry.message
758
- };
759
-
760
- if (isRecordsWrite(messageEntry) && messageEntry.data) {
761
- result.dataStream = messageEntry.data;
762
- }
763
-
764
- return result;
765
552
  }
766
553
 
767
554
  // ---------------------------------------------------------------------------
768
- // Dependency-aware topological sort for pulled messages
555
+ // Dependency-aware topological sort delegates to sync-topological-sort.ts
769
556
  // ---------------------------------------------------------------------------
770
557
 
771
558
  /**
772
- * Builds a dependency graph from the fetched messages and returns them in
773
- * topological order so that dependencies are processed before dependents.
774
- *
775
- * Dependencies:
776
- * - ProtocolsConfigure must come before any RecordsWrite using that protocol
777
- * - Parent record must come before child record (via parentId)
778
- * - Initial write must come before update writes (same recordId, not initial)
779
- * - Permission grant must come before records using that permissionGrantId
559
+ * Delegate to the standalone `topologicalSort` function.
560
+ * Tests call `SyncEngineLevel.topologicalSort(...)` so this static method must remain.
780
561
  */
781
562
  static topologicalSort<T extends { message: GenericMessage }>(
782
563
  messages: T[]
783
564
  ): T[] {
784
- if (messages.length <= 1) {
785
- return messages;
786
- }
787
-
788
- // Index messages by various keys for dependency resolution.
789
- const byIndex = new Map<number, T>();
790
- const protocolConfigureIndex = new Map<string, number>(); // protocol URL -> index
791
- const initialWriteIndex = new Map<string, number>(); // recordId -> index of initial write
792
- const grantIndex = new Map<string, number>(); // grant recordId -> index
793
-
794
- for (let i = 0; i < messages.length; i++) {
795
- const entry = messages[i];
796
- byIndex.set(i, entry);
797
- const desc = entry.message.descriptor;
798
-
799
- if (desc.interface === DwnInterfaceName.Protocols && desc.method === DwnMethodName.Configure) {
800
- const protocolUrl = (desc as any).definition?.protocol;
801
- if (protocolUrl) {
802
- protocolConfigureIndex.set(protocolUrl, i);
803
- }
804
- }
805
-
806
- if (desc.interface === DwnInterfaceName.Records && desc.method === DwnMethodName.Write) {
807
- const recordId = (entry.message as any).recordId;
808
- const isInitial = SyncEngineLevel.isInitialWrite(entry.message);
809
- if (isInitial && recordId) {
810
- initialWriteIndex.set(recordId, i);
811
- }
812
-
813
- // Index permission grants by recordId so dependents can reference them.
814
- if (
815
- (desc as any).protocol === PermissionsProtocol.uri &&
816
- (desc as any).protocolPath === PermissionsProtocol.grantPath &&
817
- recordId
818
- ) {
819
- grantIndex.set(recordId, i);
820
- }
821
- }
822
- }
823
-
824
- // Build adjacency list (edges: dependency -> dependent).
825
- const edges = new Map<number, Set<number>>();
826
- const inDegree = new Array(messages.length).fill(0) as number[];
827
-
828
- const addEdge = (from: number, to: number): void => {
829
- if (from === to) {
830
- return;
831
- }
832
- if (!edges.has(from)) {
833
- edges.set(from, new Set());
834
- }
835
- const edgeSet = edges.get(from)!;
836
- if (!edgeSet.has(to)) {
837
- edgeSet.add(to);
838
- inDegree[to]++;
839
- }
840
- };
841
-
842
- for (let i = 0; i < messages.length; i++) {
843
- const desc = messages[i].message.descriptor;
844
- const msg = messages[i].message as any;
845
-
846
- // Protocol dependency: RecordsWrite depends on ProtocolsConfigure for its protocol.
847
- if (desc.interface === DwnInterfaceName.Records) {
848
- const protocol = (desc as any).protocol;
849
- if (protocol && protocolConfigureIndex.has(protocol)) {
850
- addEdge(protocolConfigureIndex.get(protocol)!, i);
851
- }
852
- }
853
-
854
- // Parent dependency: child record depends on parent record.
855
- if (desc.interface === DwnInterfaceName.Records && (desc as any).parentId) {
856
- const parentId = (desc as any).parentId;
857
- if (initialWriteIndex.has(parentId)) {
858
- addEdge(initialWriteIndex.get(parentId)!, i);
859
- }
860
- }
861
-
862
- // Initial write dependency: update depends on initial write.
863
- if (desc.interface === DwnInterfaceName.Records && desc.method === DwnMethodName.Write) {
864
- const recordId = msg.recordId;
865
- if (recordId && !SyncEngineLevel.isInitialWrite(messages[i].message) && initialWriteIndex.has(recordId)) {
866
- addEdge(initialWriteIndex.get(recordId)!, i);
867
- }
868
- }
869
-
870
- // Delete depends on initial write.
871
- if (desc.interface === DwnInterfaceName.Records && desc.method === DwnMethodName.Delete) {
872
- const recordId = msg.descriptor?.recordId;
873
- if (recordId && initialWriteIndex.has(recordId)) {
874
- addEdge(initialWriteIndex.get(recordId)!, i);
875
- }
876
- }
877
-
878
- // Permission grant dependency: message depends on the grant it references.
879
- const permissionGrantId = (desc as any).permissionGrantId;
880
- if (permissionGrantId && grantIndex.has(permissionGrantId)) {
881
- addEdge(grantIndex.get(permissionGrantId)!, i);
882
- }
883
- }
884
-
885
- // Kahn's algorithm for topological sort.
886
- const queue: number[] = [];
887
- for (let i = 0; i < messages.length; i++) {
888
- if (inDegree[i] === 0) {
889
- queue.push(i);
890
- }
891
- }
892
-
893
- const sorted: T[] = [];
894
- while (queue.length > 0) {
895
- const node = queue.shift()!;
896
- sorted.push(byIndex.get(node)!);
897
-
898
- const neighbors = edges.get(node);
899
- if (neighbors) {
900
- for (const neighbor of neighbors) {
901
- inDegree[neighbor]--;
902
- if (inDegree[neighbor] === 0) {
903
- queue.push(neighbor);
904
- }
905
- }
906
- }
907
- }
908
-
909
- // If there are nodes not in sorted (cycle), append them at the end.
910
- if (sorted.length < messages.length) {
911
- const sortedSet = new Set(sorted);
912
- for (let i = 0; i < messages.length; i++) {
913
- const entry = byIndex.get(i)!;
914
- if (!sortedSet.has(entry)) {
915
- sorted.push(entry);
916
- }
917
- }
918
- }
919
-
920
- return sorted;
921
- }
922
-
923
- /**
924
- * Checks whether a message is an initial RecordsWrite (not an update).
925
- * An initial write has recordId === message CID context or has no `dateModified` != `dateCreated`.
926
- */
927
- private static isInitialWrite(message: GenericMessage): boolean {
928
- const desc = message.descriptor as any;
929
- if (desc.interface !== DwnInterfaceName.Records || desc.method !== DwnMethodName.Write) {
930
- return false;
931
- }
932
- // A RecordsWrite is initial if dateCreated === messageTimestamp (first write for this recordId).
933
- return desc.dateCreated === desc.messageTimestamp;
934
- }
935
-
936
- // ---------------------------------------------------------------------------
937
- // Helpers
938
- // ---------------------------------------------------------------------------
939
-
940
- /**
941
- * 202: message was successfully written to the remote DWN
942
- * 204: an initial write message was written without any data
943
- * 409: message was already present on the remote DWN
944
- * RecordsDelete + 404: the initial write was not found or already deleted
945
- */
946
- private static syncMessageReplyIsSuccessful(reply: UnionMessageReply): boolean {
947
- return reply.status.code === 202 ||
948
- reply.status.code === 204 ||
949
- reply.status.code === 409 ||
950
- (
951
- reply.entry?.message.descriptor.interface === DwnInterfaceName.Records &&
952
- reply.entry?.message.descriptor.method === DwnMethodName.Delete &&
953
- reply.status.code === 404
954
- );
565
+ return topologicalSort(messages);
955
566
  }
956
567
 
957
568
  /**