@enbox/agent 0.7.6 → 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.
- package/dist/browser.mjs +9 -11
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/dwn-api.js +3 -2
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +5 -5
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/hd-identity-vault.js +187 -177
- package/dist/esm/hd-identity-vault.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/permissions-api.js +7 -34
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +229 -110
- package/dist/esm/sync-closure-resolver.js.map +1 -1
- package/dist/esm/sync-closure-types.js +24 -7
- package/dist/esm/sync-closure-types.js.map +1 -1
- package/dist/esm/sync-engine-level.js +1961 -764
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-link-id.js +4 -13
- package/dist/esm/sync-link-id.js.map +1 -1
- package/dist/esm/sync-link-reconciler.js +26 -8
- package/dist/esm/sync-link-reconciler.js.map +1 -1
- package/dist/esm/sync-messages.js +218 -154
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/esm/sync-permission-grants.js +208 -0
- package/dist/esm/sync-permission-grants.js.map +1 -0
- package/dist/esm/sync-replication-ledger.js +23 -40
- package/dist/esm/sync-replication-ledger.js.map +1 -1
- package/dist/esm/sync-scope-acceptance.js +126 -0
- package/dist/esm/sync-scope-acceptance.js.map +1 -0
- package/dist/esm/sync-topological-sort.js +57 -15
- package/dist/esm/sync-topological-sort.js.map +1 -1
- package/dist/esm/types/sync.js +130 -22
- package/dist/esm/types/sync.js.map +1 -1
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/hd-identity-vault.d.ts +25 -0
- package/dist/types/hd-identity-vault.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/permissions-api.d.ts +1 -2
- package/dist/types/permissions-api.d.ts.map +1 -1
- package/dist/types/sync-closure-resolver.d.ts.map +1 -1
- package/dist/types/sync-closure-types.d.ts +14 -3
- package/dist/types/sync-closure-types.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +127 -25
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-link-id.d.ts +3 -9
- package/dist/types/sync-link-id.d.ts.map +1 -1
- package/dist/types/sync-link-reconciler.d.ts +12 -2
- package/dist/types/sync-link-reconciler.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +16 -13
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/sync-permission-grants.d.ts +52 -0
- package/dist/types/sync-permission-grants.d.ts.map +1 -0
- package/dist/types/sync-replication-ledger.d.ts +5 -13
- package/dist/types/sync-replication-ledger.d.ts.map +1 -1
- package/dist/types/sync-scope-acceptance.d.ts +28 -0
- package/dist/types/sync-scope-acceptance.d.ts.map +1 -0
- package/dist/types/sync-topological-sort.d.ts +2 -1
- package/dist/types/sync-topological-sort.d.ts.map +1 -1
- package/dist/types/types/identity-vault.d.ts +9 -0
- package/dist/types/types/identity-vault.d.ts.map +1 -1
- package/dist/types/types/permissions.d.ts +2 -0
- package/dist/types/types/permissions.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +137 -75
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/dwn-api.ts +3 -2
- package/src/enbox-connect-protocol.ts +5 -5
- package/src/hd-identity-vault.ts +244 -212
- package/src/index.ts +10 -1
- package/src/permissions-api.ts +11 -42
- package/src/sync-closure-resolver.ts +306 -126
- package/src/sync-closure-types.ts +38 -9
- package/src/sync-engine-level.ts +2560 -797
- package/src/sync-link-id.ts +9 -14
- package/src/sync-link-reconciler.ts +43 -10
- package/src/sync-messages.ts +263 -159
- package/src/sync-permission-grants.ts +297 -0
- package/src/sync-replication-ledger.ts +55 -50
- package/src/sync-scope-acceptance.ts +186 -0
- package/src/sync-topological-sort.ts +89 -21
- package/src/types/identity-vault.ts +8 -1
- package/src/types/permissions.ts +2 -0
- package/src/types/sync.ts +235 -62
package/src/sync-messages.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 = [...
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
+
needsRefetch.push(await getMessageCid(entry.message));
|
|
198
271
|
}
|
|
199
272
|
}
|
|
200
273
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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 =
|
|
260
|
-
|
|
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,
|
|
357
|
+
export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, permissionGrantIds, messageCids, agent }: {
|
|
272
358
|
did: string;
|
|
273
359
|
dwnUrl: string;
|
|
274
360
|
delegateDid?: string;
|
|
275
|
-
|
|
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,
|
|
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,
|
|
432
|
+
export async function pushMessages({ did, dwnUrl, delegateDid, permissionGrantIds, messageCids, agent }: {
|
|
360
433
|
did: string;
|
|
361
434
|
dwnUrl: string;
|
|
362
435
|
delegateDid?: string;
|
|
363
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
385
|
-
|
|
492
|
+
return { fetched, failed };
|
|
493
|
+
}
|
|
386
494
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
495
|
+
type PushSingleMessageOutcome =
|
|
496
|
+
| { status: 'succeeded'; cid: string }
|
|
497
|
+
| { status: 'failed'; cid: string }
|
|
498
|
+
| { status: 'permanent'; cid: string; failure: PermanentPushFailure };
|
|
391
499
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
: entry
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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,
|
|
560
|
+
export async function getLocalMessage({ author, delegateDid, permissionGrantIds, messageCid, agent }: {
|
|
444
561
|
author: string;
|
|
445
562
|
delegateDid?: string;
|
|
446
|
-
|
|
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,
|
|
572
|
+
messageParams : { messageCid, permissionGrantIds: toMessagesPermissionGrantIds(permissionGrantIds) }
|
|
469
573
|
});
|
|
470
574
|
|
|
471
575
|
if (reply.status.code !== 200 || !reply.entry) {
|