@enbox/agent 0.7.7 → 0.7.8

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 (77) hide show
  1. package/dist/browser.mjs +9 -9
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dwn-api.js +3 -2
  4. package/dist/esm/dwn-api.js.map +1 -1
  5. package/dist/esm/enbox-connect-protocol.js +5 -5
  6. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  7. package/dist/esm/index.js +1 -1
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/permissions-api.js +7 -34
  10. package/dist/esm/permissions-api.js.map +1 -1
  11. package/dist/esm/sync-closure-resolver.js +229 -110
  12. package/dist/esm/sync-closure-resolver.js.map +1 -1
  13. package/dist/esm/sync-closure-types.js +24 -7
  14. package/dist/esm/sync-closure-types.js.map +1 -1
  15. package/dist/esm/sync-engine-level.js +1961 -764
  16. package/dist/esm/sync-engine-level.js.map +1 -1
  17. package/dist/esm/sync-link-id.js +4 -13
  18. package/dist/esm/sync-link-id.js.map +1 -1
  19. package/dist/esm/sync-link-reconciler.js +26 -8
  20. package/dist/esm/sync-link-reconciler.js.map +1 -1
  21. package/dist/esm/sync-messages.js +218 -154
  22. package/dist/esm/sync-messages.js.map +1 -1
  23. package/dist/esm/sync-permission-grants.js +208 -0
  24. package/dist/esm/sync-permission-grants.js.map +1 -0
  25. package/dist/esm/sync-replication-ledger.js +23 -40
  26. package/dist/esm/sync-replication-ledger.js.map +1 -1
  27. package/dist/esm/sync-scope-acceptance.js +126 -0
  28. package/dist/esm/sync-scope-acceptance.js.map +1 -0
  29. package/dist/esm/sync-topological-sort.js +57 -15
  30. package/dist/esm/sync-topological-sort.js.map +1 -1
  31. package/dist/esm/types/sync.js +130 -22
  32. package/dist/esm/types/sync.js.map +1 -1
  33. package/dist/types/dwn-api.d.ts.map +1 -1
  34. package/dist/types/index.d.ts +1 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/permissions-api.d.ts +1 -2
  37. package/dist/types/permissions-api.d.ts.map +1 -1
  38. package/dist/types/sync-closure-resolver.d.ts.map +1 -1
  39. package/dist/types/sync-closure-types.d.ts +14 -3
  40. package/dist/types/sync-closure-types.d.ts.map +1 -1
  41. package/dist/types/sync-engine-level.d.ts +127 -25
  42. package/dist/types/sync-engine-level.d.ts.map +1 -1
  43. package/dist/types/sync-link-id.d.ts +3 -9
  44. package/dist/types/sync-link-id.d.ts.map +1 -1
  45. package/dist/types/sync-link-reconciler.d.ts +12 -2
  46. package/dist/types/sync-link-reconciler.d.ts.map +1 -1
  47. package/dist/types/sync-messages.d.ts +16 -13
  48. package/dist/types/sync-messages.d.ts.map +1 -1
  49. package/dist/types/sync-permission-grants.d.ts +52 -0
  50. package/dist/types/sync-permission-grants.d.ts.map +1 -0
  51. package/dist/types/sync-replication-ledger.d.ts +5 -13
  52. package/dist/types/sync-replication-ledger.d.ts.map +1 -1
  53. package/dist/types/sync-scope-acceptance.d.ts +28 -0
  54. package/dist/types/sync-scope-acceptance.d.ts.map +1 -0
  55. package/dist/types/sync-topological-sort.d.ts +2 -1
  56. package/dist/types/sync-topological-sort.d.ts.map +1 -1
  57. package/dist/types/types/permissions.d.ts +2 -0
  58. package/dist/types/types/permissions.d.ts.map +1 -1
  59. package/dist/types/types/sync.d.ts +137 -75
  60. package/dist/types/types/sync.d.ts.map +1 -1
  61. package/package.json +3 -3
  62. package/src/dwn-api.ts +3 -2
  63. package/src/enbox-connect-protocol.ts +5 -5
  64. package/src/index.ts +10 -1
  65. package/src/permissions-api.ts +11 -42
  66. package/src/sync-closure-resolver.ts +306 -126
  67. package/src/sync-closure-types.ts +38 -9
  68. package/src/sync-engine-level.ts +2560 -797
  69. package/src/sync-link-id.ts +9 -14
  70. package/src/sync-link-reconciler.ts +43 -10
  71. package/src/sync-messages.ts +263 -159
  72. package/src/sync-permission-grants.ts +297 -0
  73. package/src/sync-replication-ledger.ts +55 -50
  74. package/src/sync-scope-acceptance.ts +186 -0
  75. package/src/sync-topological-sort.ts +89 -21
  76. package/src/types/permissions.ts +2 -0
  77. package/src/types/sync.ts +235 -62
@@ -1,13 +1,13 @@
1
1
  import type { GenericMessage, MessagesReadReply, MessagesSyncDiffEntry, UnionMessageReply } from '@enbox/dwn-sdk-js';
2
2
 
3
3
  import type { EnboxPlatformAgent } from './types/agent.js';
4
- import type { PermissionsApi } from './types/permissions.js';
5
4
  import type { PermanentPushFailure, PushResult } from './types/sync.js';
6
5
 
7
6
  import { DwnInterfaceName, DwnMethodName, Encoder, Message } from '@enbox/dwn-sdk-js';
8
7
 
9
8
  import { DwnInterface } from './types/dwn.js';
10
9
  import { isRecordsWrite } from './utils.js';
10
+ import { toMessagesPermissionGrantIds } from './sync-permission-grants.js';
11
11
  import { topologicalSort } from './sync-topological-sort.js';
12
12
 
13
13
  /** Maximum data size (in bytes) to buffer in memory for retry. Larger payloads are re-fetched. */
@@ -21,6 +21,26 @@ export type SyncMessageEntry = {
21
21
  bufferedData?: Uint8Array;
22
22
  };
23
23
 
24
+ /** Optional pre-apply gate for inbound sync entries. */
25
+ export type PullMessageAcceptance = (
26
+ entry: SyncMessageEntry,
27
+ entries: SyncMessageEntry[],
28
+ ) => Promise<boolean>;
29
+
30
+ /** Raised when an in-flight pull is cancelled before local apply can continue. */
31
+ export class SyncPullAbortedError extends Error {
32
+ constructor() {
33
+ super('Sync pull aborted because the sync target is no longer current.');
34
+ this.name = 'SyncPullAbortedError';
35
+ }
36
+ }
37
+
38
+ function assertShouldContinue(shouldContinue: (() => boolean) | undefined): void {
39
+ if (shouldContinue?.() === false) {
40
+ throw new SyncPullAbortedError();
41
+ }
42
+ }
43
+
24
44
  /**
25
45
  * 202: message was successfully written to the remote DWN
26
46
  * 204: an initial write message was written without any data
@@ -103,116 +123,185 @@ export async function getMessageCid(message: GenericMessage): Promise<string> {
103
123
  * Large payloads are re-fetched on retry since buffering them would consume
104
124
  * too much memory.
105
125
  */
106
- export async function pullMessages({ did, dwnUrl, delegateDid, protocol, messageCids, prefetched, agent, permissionsApi }: {
126
+ export async function pullMessages({ did, dwnUrl, delegateDid, permissionGrantIds, messageCids, prefetched, acceptEntry, shouldContinue, agent }: {
107
127
  did: string;
108
128
  dwnUrl: string;
109
129
  delegateDid?: string;
110
- protocol?: string;
130
+ permissionGrantIds?: string[];
111
131
  messageCids: string[];
112
132
  /** Pre-fetched message entries from the batched diff response (already have message + data). */
113
133
  prefetched?: MessagesSyncDiffEntry[];
134
+ acceptEntry?: PullMessageAcceptance;
135
+ shouldContinue?: () => boolean;
114
136
  agent: EnboxPlatformAgent;
115
- permissionsApi: PermissionsApi;
116
137
  }): Promise<string[]> {
117
- // Convert prefetched diff entries into SyncMessageEntry format.
118
- const prefetchedEntries: SyncMessageEntry[] = [];
119
- if (prefetched) {
120
- for (const entry of prefetched) {
121
- if (!entry.message) { continue; }
122
- const syncEntry: SyncMessageEntry = { message: entry.message };
123
- if (entry.encodedData) {
124
- // Convert base64url-encoded data to a ReadableStream.
125
- const bytes = Encoder.base64UrlToBytes(entry.encodedData);
126
- syncEntry.bufferedData = bytes;
127
- syncEntry.dataStream = new ReadableStream<Uint8Array>({
128
- start(controller): void {
129
- controller.enqueue(bytes);
130
- controller.close();
131
- }
132
- });
133
- }
134
- prefetchedEntries.push(syncEntry);
135
- }
136
- }
138
+ assertShouldContinue(shouldContinue);
137
139
 
138
140
  // Step 1: Fetch remaining messages (not prefetched) from the remote.
139
141
  const fetched = messageCids.length > 0
140
- ? await fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi })
142
+ ? await fetchRemoteMessages({ did, dwnUrl, delegateDid, permissionGrantIds, messageCids, agent })
141
143
  : [];
144
+ assertShouldContinue(shouldContinue);
142
145
 
143
146
  // Merge prefetched entries with remotely fetched ones.
144
- const allFetched = [...prefetchedEntries, ...fetched];
147
+ const allFetched = [...syncEntriesFromPrefetchedDiff(prefetched), ...fetched];
148
+
149
+ const { accepted, rejectedCids } = await applyPullAcceptanceGate(allFetched, acceptEntry, shouldContinue);
150
+ assertShouldContinue(shouldContinue);
145
151
 
146
152
  // Step 2: Build dependency graph and topological sort.
147
- const sorted = topologicalSort(allFetched);
153
+ const sorted = topologicalSort(accepted);
148
154
 
149
155
  // Step 3: Buffer small data streams so they can be replayed on retry.
150
- await bufferSmallStreams(sorted);
156
+ await bufferSmallStreams(sorted, shouldContinue);
157
+ assertShouldContinue(shouldContinue);
151
158
 
152
159
  // Step 4: Process messages in dependency order with multi-pass retry.
153
160
  const MAX_RETRY_PASSES = 3;
154
161
  let pending = sorted;
155
162
 
156
163
  for (let pass = 0; pass <= MAX_RETRY_PASSES && pending.length > 0; pass++) {
157
- const failed: SyncMessageEntry[] = [];
164
+ assertShouldContinue(shouldContinue);
165
+ const failed = await processPullPass({ did, entries: pending, shouldContinue, agent });
166
+ assertShouldContinue(shouldContinue);
167
+ pending = failed.length === 0
168
+ ? []
169
+ : await preparePullRetry({ did, dwnUrl, delegateDid, permissionGrantIds, failed, shouldContinue, agent });
170
+ }
158
171
 
159
- for (const entry of pending) {
160
- // Create a fresh ReadableStream from the buffer if available (stream is single-use).
161
- const dataStream = entry.bufferedData
162
- ? new ReadableStream<Uint8Array>({ start(c): void { c.enqueue(entry.bufferedData!); c.close(); } })
163
- : entry.dataStream;
172
+ // Return CIDs that permanently failed after all retry passes.
173
+ return [...rejectedCids, ...await getMessageCids(pending)];
174
+ }
164
175
 
165
- const pullReply = await agent.dwn.processRawMessage(did, entry.message, { dataStream });
176
+ async function applyPullAcceptanceGate(
177
+ entries: SyncMessageEntry[],
178
+ acceptEntry: PullMessageAcceptance | undefined,
179
+ shouldContinue?: () => boolean,
180
+ ): Promise<{ accepted: SyncMessageEntry[]; rejectedCids: string[] }> {
181
+ if (!acceptEntry) {
182
+ return { accepted: entries, rejectedCids: [] };
183
+ }
166
184
 
167
- if (!syncMessageReplyIsSuccessful(pullReply)) {
168
- failed.push(entry);
169
- }
185
+ const accepted: SyncMessageEntry[] = [];
186
+ const rejectedCids: string[] = [];
187
+ for (const entry of entries) {
188
+ assertShouldContinue(shouldContinue);
189
+ if (await acceptEntry(entry, entries)) {
190
+ accepted.push(entry);
191
+ } else {
192
+ rejectedCids.push(await getMessageCid(entry.message));
170
193
  }
194
+ assertShouldContinue(shouldContinue);
195
+ }
171
196
 
172
- if (failed.length > 0) {
173
- // Separate entries that have a buffer (can retry locally) from those
174
- // that need a fresh fetch (large payloads whose stream was consumed).
175
- const needsRefetch: string[] = [];
176
- const canRetry: SyncMessageEntry[] = [];
177
-
178
- for (const entry of failed) {
179
- if (entry.bufferedData || !entry.dataStream) {
180
- // Has a buffer or has no data — can retry without re-fetching.
181
- canRetry.push(entry);
182
- } else {
183
- // Large payload whose stream was consumed — must re-fetch.
184
- const cid = await getMessageCid(entry.message);
185
- needsRefetch.push(cid);
186
- }
187
- }
197
+ return { accepted, rejectedCids };
198
+ }
188
199
 
189
- // Re-fetch only the large-payload messages that we couldn't buffer.
190
- if (needsRefetch.length > 0) {
191
- const reFetched = await fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids: needsRefetch, agent, permissionsApi });
192
- canRetry.push(...reFetched);
193
- }
200
+ function syncEntriesFromPrefetchedDiff(prefetched?: MessagesSyncDiffEntry[]): SyncMessageEntry[] {
201
+ if (!prefetched) { return []; }
202
+
203
+ const entries: SyncMessageEntry[] = [];
204
+ for (const entry of prefetched) {
205
+ if (!entry.message) { continue; }
194
206
 
195
- pending = topologicalSort(canRetry);
207
+ const syncEntry: SyncMessageEntry = { message: entry.message };
208
+ if (entry.encodedData) {
209
+ const bytes = Encoder.base64UrlToBytes(entry.encodedData);
210
+ syncEntry.bufferedData = bytes;
211
+ syncEntry.dataStream = dataStreamFromBytes(bytes);
212
+ }
213
+ entries.push(syncEntry);
214
+ }
215
+ return entries;
216
+ }
217
+
218
+ function dataStreamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
219
+ return new ReadableStream<Uint8Array>({
220
+ start(controller): void {
221
+ controller.enqueue(bytes);
222
+ controller.close();
223
+ }
224
+ });
225
+ }
226
+
227
+ function replayableDataStream(entry: SyncMessageEntry): ReadableStream<Uint8Array> | undefined {
228
+ return entry.bufferedData ? dataStreamFromBytes(entry.bufferedData) : entry.dataStream;
229
+ }
230
+
231
+ async function processPullPass({ did, entries, shouldContinue, agent }: {
232
+ did: string;
233
+ entries: SyncMessageEntry[];
234
+ shouldContinue?: () => boolean;
235
+ agent: EnboxPlatformAgent;
236
+ }): Promise<SyncMessageEntry[]> {
237
+ const failed: SyncMessageEntry[] = [];
238
+
239
+ for (const entry of entries) {
240
+ assertShouldContinue(shouldContinue);
241
+ const pullReply = await agent.dwn.processRawMessage(did, entry.message, {
242
+ dataStream: replayableDataStream(entry),
243
+ });
244
+ assertShouldContinue(shouldContinue);
245
+
246
+ if (!syncMessageReplyIsSuccessful(pullReply)) {
247
+ failed.push(entry);
248
+ }
249
+ }
250
+
251
+ return failed;
252
+ }
253
+
254
+ async function preparePullRetry({ did, dwnUrl, delegateDid, permissionGrantIds, failed, shouldContinue, agent }: {
255
+ did: string;
256
+ dwnUrl: string;
257
+ delegateDid?: string;
258
+ permissionGrantIds?: string[];
259
+ failed: SyncMessageEntry[];
260
+ shouldContinue?: () => boolean;
261
+ agent: EnboxPlatformAgent;
262
+ }): Promise<SyncMessageEntry[]> {
263
+ const canRetry: SyncMessageEntry[] = [];
264
+ const needsRefetch: string[] = [];
265
+
266
+ for (const entry of failed) {
267
+ if (entry.bufferedData || !entry.dataStream) {
268
+ canRetry.push(entry);
196
269
  } else {
197
- pending = [];
270
+ needsRefetch.push(await getMessageCid(entry.message));
198
271
  }
199
272
  }
200
273
 
201
- // Return CIDs that permanently failed after all retry passes.
202
- const permanentlyFailed: string[] = [];
203
- for (const entry of pending) {
204
- const cid = await getMessageCid(entry.message);
205
- permanentlyFailed.push(cid);
274
+ if (needsRefetch.length > 0) {
275
+ assertShouldContinue(shouldContinue);
276
+ canRetry.push(...await fetchRemoteMessages({
277
+ did,
278
+ dwnUrl,
279
+ delegateDid,
280
+ permissionGrantIds,
281
+ messageCids: needsRefetch,
282
+ agent,
283
+ }));
284
+ assertShouldContinue(shouldContinue);
206
285
  }
207
- return permanentlyFailed;
286
+
287
+ return topologicalSort(canRetry);
288
+ }
289
+
290
+ async function getMessageCids(entries: SyncMessageEntry[]): Promise<string[]> {
291
+ const cids: string[] = [];
292
+ for (const entry of entries) {
293
+ cids.push(await getMessageCid(entry.message));
294
+ }
295
+ return cids;
208
296
  }
209
297
 
210
298
  /**
211
299
  * Buffers small data streams into `Uint8Array` so they can be replayed on retry.
212
300
  * Streams larger than `MAX_BUFFER_SIZE` are left as-is (will be re-fetched on retry).
213
301
  */
214
- async function bufferSmallStreams(entries: SyncMessageEntry[]): Promise<void> {
302
+ async function bufferSmallStreams(entries: SyncMessageEntry[], shouldContinue?: () => boolean): Promise<void> {
215
303
  for (const entry of entries) {
304
+ assertShouldContinue(shouldContinue);
216
305
  if (!entry.dataStream) {
217
306
  continue;
218
307
  }
@@ -228,6 +317,7 @@ async function bufferSmallStreams(entries: SyncMessageEntry[]): Promise<void> {
228
317
  for (;;) {
229
318
  const { done, value } = await reader.read();
230
319
  if (done) { break; }
320
+ assertShouldContinue(shouldContinue);
231
321
  totalSize += value.byteLength;
232
322
  if (totalSize > MAX_BUFFER_SIZE) {
233
323
  exceededThreshold = true;
@@ -256,41 +346,24 @@ async function bufferSmallStreams(entries: SyncMessageEntry[]): Promise<void> {
256
346
 
257
347
  entry.bufferedData = buffer;
258
348
  // Create a fresh ReadableStream from the buffer for the first processing attempt.
259
- entry.dataStream = new ReadableStream<Uint8Array>({
260
- start(controller): void {
261
- controller.enqueue(buffer);
262
- controller.close();
263
- }
264
- });
349
+ entry.dataStream = dataStreamFromBytes(buffer);
350
+ assertShouldContinue(shouldContinue);
265
351
  }
266
352
  }
267
353
 
268
354
  /**
269
355
  * Fetches messages from a remote DWN by their CIDs using MessagesRead.
270
356
  */
271
- export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi }: {
357
+ export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, permissionGrantIds, messageCids, agent }: {
272
358
  did: string;
273
359
  dwnUrl: string;
274
360
  delegateDid?: string;
275
- protocol?: string;
361
+ permissionGrantIds?: string[];
276
362
  messageCids: string[];
277
363
  agent: EnboxPlatformAgent;
278
- permissionsApi: PermissionsApi;
279
364
  }): Promise<SyncMessageEntry[]> {
280
365
  const results: SyncMessageEntry[] = [];
281
366
 
282
- let permissionGrantId: string | undefined;
283
- if (delegateDid) {
284
- const messagesReadGrant = await permissionsApi.getPermissionForRequest({
285
- connectedDid : did,
286
- messageType : DwnInterface.MessagesRead,
287
- delegateDid,
288
- protocol,
289
- cached : true
290
- });
291
- permissionGrantId = messagesReadGrant.grant.id;
292
- }
293
-
294
367
  // Fetch messages in parallel with bounded concurrency. Keep this low
295
368
  // to avoid bursting through the remote server's rate limits during sync.
296
369
  const CONCURRENCY = 4;
@@ -308,7 +381,7 @@ export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol,
308
381
  target : did,
309
382
  messageType : DwnInterface.MessagesRead,
310
383
  granteeDid : delegateDid,
311
- messageParams : { messageCid, permissionGrantId }
384
+ messageParams : { messageCid, permissionGrantIds: toMessagesPermissionGrantIds(permissionGrantIds) }
312
385
  });
313
386
 
314
387
  let reply: MessagesReadReply;
@@ -356,116 +429,147 @@ export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol,
356
429
  * on the first failure. Callers use this to advance the push checkpoint
357
430
  * incrementally — only up to the highest contiguous success.
358
431
  */
359
- export async function pushMessages({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi }: {
432
+ export async function pushMessages({ did, dwnUrl, delegateDid, permissionGrantIds, messageCids, agent }: {
360
433
  did: string;
361
434
  dwnUrl: string;
362
435
  delegateDid?: string;
363
- protocol?: string;
436
+ permissionGrantIds?: string[];
364
437
  messageCids: string[];
365
438
  agent: EnboxPlatformAgent;
366
- permissionsApi: PermissionsApi;
367
439
  }): Promise<PushResult> {
368
440
  const succeeded: string[] = [];
369
- const failed: string[] = [];
370
441
  const permanentlyFailed: PermanentPushFailure[] = [];
442
+ const { fetched, failed } = await fetchLocalMessagesForPush({
443
+ did,
444
+ delegateDid,
445
+ permissionGrantIds,
446
+ messageCids,
447
+ agent,
448
+ });
449
+
450
+ // Step 2: Sort in dependency order using topological sort.
451
+ const sorted = topologicalSort(fetched);
452
+
453
+ // Step 3: Buffer data streams so they survive fetch retries.
454
+ // ReadableStream is single-use — if sendDwnRequest's underlying fetch
455
+ // retries the HTTP request, the original stream is already consumed.
456
+ await bufferSmallStreams(sorted);
371
457
 
372
- // Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
458
+ // Step 4: Push messages in dependency order.
459
+ for (const entry of sorted) {
460
+ const outcome = await pushSingleMessage({ did, dwnUrl, entry, agent });
461
+ if (outcome.status === 'succeeded') {
462
+ succeeded.push(outcome.cid);
463
+ } else if (outcome.status === 'permanent') {
464
+ permanentlyFailed.push(outcome.failure);
465
+ } else {
466
+ failed.push(outcome.cid);
467
+ }
468
+ }
469
+
470
+ return { succeeded, failed, permanentlyFailed };
471
+ }
472
+
473
+ async function fetchLocalMessagesForPush({ did, delegateDid, permissionGrantIds, messageCids, agent }: {
474
+ did: string;
475
+ delegateDid?: string;
476
+ permissionGrantIds?: string[];
477
+ messageCids: string[];
478
+ agent: EnboxPlatformAgent;
479
+ }): Promise<{ fetched: SyncMessageEntry[]; failed: string[] }> {
373
480
  const fetched: SyncMessageEntry[] = [];
481
+ const failed: string[] = [];
482
+
374
483
  for (const messageCid of messageCids) {
375
- const dwnMessage = await getLocalMessage({ author: did, messageCid, delegateDid, protocol, agent, permissionsApi });
484
+ const dwnMessage = await getLocalMessage({ author: did, messageCid, delegateDid, permissionGrantIds, agent });
376
485
  if (dwnMessage) {
377
486
  fetched.push(dwnMessage);
378
487
  } else {
379
- // Message could not be fetched locally — mark as failed.
380
488
  failed.push(messageCid);
381
489
  }
382
490
  }
383
491
 
384
- // Step 2: Sort in dependency order using topological sort.
385
- const sorted = topologicalSort(fetched);
492
+ return { fetched, failed };
493
+ }
386
494
 
387
- // Step 3: Buffer data streams so they survive fetch retries.
388
- // ReadableStream is single-use if sendDwnRequest's underlying fetch
389
- // retries the HTTP request, the original stream is already consumed.
390
- await bufferSmallStreams(sorted);
495
+ type PushSingleMessageOutcome =
496
+ | { status: 'succeeded'; cid: string }
497
+ | { status: 'failed'; cid: string }
498
+ | { status: 'permanent'; cid: string; failure: PermanentPushFailure };
391
499
 
392
- // Step 4: Push messages in dependency order.
393
- for (const entry of sorted) {
394
- const cid = await getMessageCid(entry.message);
500
+ async function pushSingleMessage({ did, dwnUrl, entry, agent }: {
501
+ did: string;
502
+ dwnUrl: string;
503
+ entry: SyncMessageEntry;
504
+ agent: EnboxPlatformAgent;
505
+ }): Promise<PushSingleMessageOutcome> {
506
+ const cid = await getMessageCid(entry.message);
395
507
 
396
- // Use a Blob for buffered data — unlike ReadableStream, Blob is
397
- // replayable so fetchWithRetry can retry the HTTP request on failure.
398
- const data = entry.bufferedData
399
- ? new Blob([entry.bufferedData] as BlobPart[], { type: 'application/octet-stream' })
400
- : entry.dataStream;
508
+ try {
509
+ const reply = await agent.rpc.sendDwnRequest({
510
+ dwnUrl,
511
+ targetDid : did,
512
+ data : pushData(entry),
513
+ message : entry.message
514
+ });
401
515
 
402
- try {
403
- const reply = await agent.rpc.sendDwnRequest({
404
- dwnUrl,
405
- targetDid : did,
406
- data,
407
- message : entry.message
408
- });
516
+ if (syncMessageReplyIsSuccessful(reply, entry.message)) {
517
+ return { status: 'succeeded', cid };
518
+ }
409
519
 
410
- if (syncMessageReplyIsSuccessful(reply, entry.message)) {
411
- succeeded.push(cid);
412
- } else if (isPermanentPushFailure(reply)) {
413
- // Permanent failures (400/401/403) will never succeed — do NOT retry.
414
- // These include protocol violations (RecordLimitExceeded), auth errors,
415
- // and schema validation failures.
416
- if (reply.status.code === 400 && reply.status.detail?.includes('record limit')) {
417
- // Expected for singleton convergence in multi-device scenarios:
418
- // one device created a singleton record, this device has a
419
- // different one, and the remote rejects the duplicate.
420
- console.debug(`SyncEngineLevel: singleton already exists on remote, skipping push for ${cid}`);
421
- } else {
422
- console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
423
- }
424
- permanentlyFailed.push({ cid, statusCode: reply.status.code, detail: reply.status.detail ?? '' });
425
- } else {
426
- // Transient failures (5xx, etc.) — worth retrying.
427
- console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
428
- failed.push(cid);
429
- }
430
- } catch (error: any) {
431
- // Network errors — transient, worth retrying.
432
- console.error(`SyncEngineLevel: push error for ${cid}: ${error.message ?? error}`);
433
- failed.push(cid);
520
+ if (isPermanentPushFailure(reply)) {
521
+ logPermanentPushFailure(cid, reply);
522
+ return {
523
+ status : 'permanent',
524
+ cid,
525
+ failure : { cid, statusCode: reply.status.code, detail: reply.status.detail ?? '' },
526
+ };
434
527
  }
528
+
529
+ console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
530
+ return { status: 'failed', cid };
531
+ } catch (error: any) {
532
+ console.error(`SyncEngineLevel: push error for ${cid}: ${error.message ?? error}`);
533
+ return { status: 'failed', cid };
435
534
  }
535
+ }
436
536
 
437
- return { succeeded, failed, permanentlyFailed };
537
+ function pushData(entry: SyncMessageEntry): Blob | ReadableStream<Uint8Array> | undefined {
538
+ // Use a Blob for buffered data: unlike ReadableStream, Blob is replayable,
539
+ // so fetchWithRetry can retry the HTTP request after a transport failure.
540
+ return entry.bufferedData
541
+ ? new Blob([entry.bufferedData] as BlobPart[], { type: 'application/octet-stream' })
542
+ : entry.dataStream;
543
+ }
544
+
545
+ function logPermanentPushFailure(cid: string, reply: UnionMessageReply): void {
546
+ if (reply.status.code === 400 && reply.status.detail?.includes('record limit')) {
547
+ // Expected for singleton convergence in multi-device scenarios: one device
548
+ // created a singleton record, this device has another, and the remote
549
+ // rejects the duplicate.
550
+ console.debug(`SyncEngineLevel: singleton already exists on remote, skipping push for ${cid}`);
551
+ return;
552
+ }
553
+
554
+ console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
438
555
  }
439
556
 
440
557
  /**
441
558
  * Reads a message from the local DWN by its CID using MessagesRead.
442
559
  */
443
- export async function getLocalMessage({ author, delegateDid, protocol, messageCid, agent, permissionsApi }: {
560
+ export async function getLocalMessage({ author, delegateDid, permissionGrantIds, messageCid, agent }: {
444
561
  author: string;
445
562
  delegateDid?: string;
446
- protocol?: string;
563
+ permissionGrantIds?: string[];
447
564
  messageCid: string;
448
565
  agent: EnboxPlatformAgent;
449
- permissionsApi: PermissionsApi;
450
566
  }): Promise<SyncMessageEntry | undefined> {
451
- let permissionGrantId: string | undefined;
452
- if (delegateDid) {
453
- const messagesReadGrant = await permissionsApi.getPermissionForRequest({
454
- connectedDid : author,
455
- messageType : DwnInterface.MessagesRead,
456
- delegateDid,
457
- protocol,
458
- cached : true
459
- });
460
- permissionGrantId = messagesReadGrant.grant.id;
461
- }
462
-
463
567
  const { reply } = await agent.dwn.processRequest({
464
568
  author,
465
569
  target : author,
466
570
  messageType : DwnInterface.MessagesRead,
467
571
  granteeDid : delegateDid,
468
- messageParams : { messageCid, permissionGrantId }
572
+ messageParams : { messageCid, permissionGrantIds: toMessagesPermissionGrantIds(permissionGrantIds) }
469
573
  });
470
574
 
471
575
  if (reply.status.code !== 200 || !reply.entry) {