@enbox/agent 0.5.15 → 0.6.0
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 +11 -11
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/dwn-api.js +433 -33
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-encryption.js +131 -12
- package/dist/esm/dwn-encryption.js.map +1 -1
- package/dist/esm/dwn-key-delivery.js +64 -47
- package/dist/esm/dwn-key-delivery.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +400 -3
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/permissions-api.js +11 -1
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +8 -1
- package/dist/esm/sync-closure-resolver.js.map +1 -1
- package/dist/esm/sync-engine-level.js +407 -6
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-messages.js +10 -3
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/types/dwn-api.d.ts +159 -0
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/dwn-encryption.d.ts +39 -2
- package/dist/types/dwn-encryption.d.ts.map +1 -1
- package/dist/types/dwn-key-delivery.d.ts +1 -9
- package/dist/types/dwn-key-delivery.d.ts.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts +166 -1
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
- 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-engine-level.d.ts +45 -1
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +2 -2
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/types/permissions.d.ts +9 -0
- package/dist/types/types/permissions.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +70 -2
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/dwn-api.ts +494 -38
- package/src/dwn-encryption.ts +160 -11
- package/src/dwn-key-delivery.ts +73 -61
- package/src/enbox-connect-protocol.ts +575 -6
- package/src/permissions-api.ts +13 -1
- package/src/sync-closure-resolver.ts +7 -1
- package/src/sync-engine-level.ts +368 -4
- package/src/sync-messages.ts +14 -5
- package/src/types/permissions.ts +9 -0
- package/src/types/sync.ts +86 -2
package/src/permissions-api.ts
CHANGED
|
@@ -244,9 +244,16 @@ export class AgentPermissionsApi implements PermissionsApi {
|
|
|
244
244
|
requestId : createGrantParams.requestId,
|
|
245
245
|
description : createGrantParams.description,
|
|
246
246
|
delegated,
|
|
247
|
-
scope : createGrantParams.scope
|
|
247
|
+
scope : createGrantParams.scope,
|
|
248
248
|
};
|
|
249
249
|
|
|
250
|
+
// Attach delegate key-delivery metadata to the grant data payload.
|
|
251
|
+
// This is stored in the grant's encoded data (not tags) to avoid
|
|
252
|
+
// SQL column size limits on tag values.
|
|
253
|
+
if (createGrantParams.delegateKeyDelivery) {
|
|
254
|
+
permissionGrantData.delegateKeyDelivery = createGrantParams.delegateKeyDelivery;
|
|
255
|
+
}
|
|
256
|
+
|
|
250
257
|
const permissionsGrantBytes = Convert.object(permissionGrantData).toUint8Array();
|
|
251
258
|
|
|
252
259
|
const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = {
|
|
@@ -346,12 +353,17 @@ export class AgentPermissionsApi implements PermissionsApi {
|
|
|
346
353
|
tags
|
|
347
354
|
};
|
|
348
355
|
|
|
356
|
+
if (params.permissionGrantId) {
|
|
357
|
+
messageParams.permissionGrantId = params.permissionGrantId;
|
|
358
|
+
}
|
|
359
|
+
|
|
349
360
|
const { reply, message } = await this.agent.processDwnRequest({
|
|
350
361
|
store,
|
|
351
362
|
author,
|
|
352
363
|
target : author,
|
|
353
364
|
messageType : DwnInterface.RecordsWrite,
|
|
354
365
|
messageParams,
|
|
366
|
+
granteeDid : params.granteeDid,
|
|
355
367
|
dataStream : new Blob([ permissionRevocationBytes as BlobPart ])
|
|
356
368
|
});
|
|
357
369
|
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from './sync-closure-types.js';
|
|
7
7
|
import type { GenericMessage, MessageStore } from '@enbox/dwn-sdk-js';
|
|
8
8
|
|
|
9
|
-
import { Message } from '@enbox/dwn-sdk-js';
|
|
9
|
+
import { Message, PermissionsProtocol } from '@enbox/dwn-sdk-js';
|
|
10
10
|
|
|
11
11
|
import { isMultiPartyContext } from './protocol-utils.js';
|
|
12
12
|
import { ClosureFailureCode, createClosureContext } from './sync-closure-types.js';
|
|
@@ -25,6 +25,12 @@ function extractProtocolDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
|
25
25
|
const protocol = desc.protocol as string | undefined;
|
|
26
26
|
if (!protocol) { return []; }
|
|
27
27
|
|
|
28
|
+
// The permissions protocol is a built-in core protocol handled natively
|
|
29
|
+
// by every DWN — it never has a ProtocolsConfigure message. Exempt it
|
|
30
|
+
// from protocol metadata closure to avoid spurious failures when pushing
|
|
31
|
+
// permission grant records created during delegated connect flows.
|
|
32
|
+
if (protocol === PermissionsProtocol.uri) { return []; }
|
|
33
|
+
|
|
28
34
|
return [{
|
|
29
35
|
dependencyClass : 1,
|
|
30
36
|
label : 'protocolsConfigure',
|
package/src/sync-engine-level.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { Encoder, hashToHex, initDefaultHashes, Message } from '@enbox/dwn-sdk-j
|
|
|
10
10
|
|
|
11
11
|
import type { ClosureEvaluationContext } from './sync-closure-types.js';
|
|
12
12
|
import type { PermissionsApi } from './types/permissions.js';
|
|
13
|
+
import type { DeadLetterCategory, DeadLetterEntry, PushResult, ReplicationLinkState, StartSyncParams, SyncConnectivityState, SyncEngine, SyncEvent, SyncEventListener, SyncHealthSummary, SyncIdentityOptions, SyncMode, SyncScope } from './types/sync.js';
|
|
13
14
|
import type { EnboxAgent, EnboxPlatformAgent } from './types/agent.js';
|
|
14
|
-
import type { PushResult, ReplicationLinkState, StartSyncParams, SyncConnectivityState, SyncEngine, SyncEvent, SyncEventListener, SyncIdentityOptions, SyncMode, SyncScope } from './types/sync.js';
|
|
15
15
|
|
|
16
16
|
import { evaluateClosure } from './sync-closure-resolver.js';
|
|
17
17
|
import { MAX_PENDING_TOKENS } from './types/sync.js';
|
|
@@ -268,6 +268,14 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
268
268
|
/** Backoff multiplier for consecutive failures (caps at 4x the configured interval). */
|
|
269
269
|
private static readonly MAX_BACKOFF_MULTIPLIER = 4;
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Bound browser event handlers so they can be added and removed.
|
|
273
|
+
* Set in `startBrowserConnectivityListeners`, cleared in `stopBrowserConnectivityListeners`.
|
|
274
|
+
*/
|
|
275
|
+
private _onOnline?: () => void;
|
|
276
|
+
private _onOffline?: () => void;
|
|
277
|
+
private _onVisibilityChange?: () => void;
|
|
278
|
+
|
|
271
279
|
constructor({ agent, dataPath, db }: SyncEngineLevelParams) {
|
|
272
280
|
this._agent = agent;
|
|
273
281
|
this._permissionsApi = new AgentPermissionsApi({ agent: agent as EnboxAgent });
|
|
@@ -282,6 +290,11 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
282
290
|
return this._ledger;
|
|
283
291
|
}
|
|
284
292
|
|
|
293
|
+
/** LevelDB sublevel for permanently failed messages (dead letters). */
|
|
294
|
+
private get _deadLetters(): AbstractLevel<string | Buffer | Uint8Array, string, string> {
|
|
295
|
+
return this._db.sublevel('deadLetters') as unknown as AbstractLevel<string | Buffer | Uint8Array, string, string>;
|
|
296
|
+
}
|
|
297
|
+
|
|
285
298
|
/**
|
|
286
299
|
* Retrieves the `EnboxPlatformAgent` execution context.
|
|
287
300
|
*
|
|
@@ -584,6 +597,10 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
584
597
|
* 4. Schedules an infrequent SMT integrity check at `interval`.
|
|
585
598
|
*/
|
|
586
599
|
private async startLiveSync(intervalMilliseconds: number): Promise<void> {
|
|
600
|
+
// Step 0: Register browser connectivity listeners for instant recovery
|
|
601
|
+
// on network switch, sleep/wake, or tab foregrounding. No-op in Node.
|
|
602
|
+
this.startBrowserConnectivityListeners();
|
|
603
|
+
|
|
587
604
|
// Step 1: Initial SMT catch-up.
|
|
588
605
|
try {
|
|
589
606
|
await this.sync();
|
|
@@ -980,6 +997,12 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
980
997
|
const prevRepairConnectivity = link.connectivity;
|
|
981
998
|
link.connectivity = 'online';
|
|
982
999
|
await this.ledger.setStatus(link, 'live');
|
|
1000
|
+
|
|
1001
|
+
// Auto-clear dead letters for this link — repair has verified
|
|
1002
|
+
// convergence via SMT reconciliation so any previously recorded
|
|
1003
|
+
// failures (closure, push-exhausted, pull-processing) for this
|
|
1004
|
+
// specific link are no longer current.
|
|
1005
|
+
void this.clearDeadLettersForLink(did, dwnUrl, protocol);
|
|
983
1006
|
this.emitEvent({ type: 'repair:completed', tenantDid: did, remoteEndpoint: dwnUrl, protocol });
|
|
984
1007
|
if (prevRepairConnectivity !== 'online') {
|
|
985
1008
|
this.emitEvent({ type: 'link:connectivity-change', tenantDid: did, remoteEndpoint: dwnUrl, protocol, from: prevRepairConnectivity, to: 'online' });
|
|
@@ -1095,7 +1118,112 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1095
1118
|
/**
|
|
1096
1119
|
* Tears down all live subscriptions and push listeners.
|
|
1097
1120
|
*/
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Browser connectivity: online/offline + visibilitychange
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Registers browser `online`, `offline`, and `visibilitychange` event
|
|
1127
|
+
* listeners to detect connectivity changes that WebSocket `close` events
|
|
1128
|
+
* miss (NAT timeout, network switch, sleep/wake). Safe to call in Node —
|
|
1129
|
+
* the guards skip registration when browser APIs are unavailable.
|
|
1130
|
+
*/
|
|
1131
|
+
private startBrowserConnectivityListeners(): void {
|
|
1132
|
+
this.stopBrowserConnectivityListeners();
|
|
1133
|
+
|
|
1134
|
+
// Guard: only run in browser environments with the required APIs.
|
|
1135
|
+
if (typeof globalThis.addEventListener !== 'function') { return; }
|
|
1136
|
+
|
|
1137
|
+
const generation = this._engineGeneration;
|
|
1138
|
+
|
|
1139
|
+
this._onOnline = (): void => {
|
|
1140
|
+
if (this._engineGeneration !== generation) { return; }
|
|
1141
|
+
console.info('SyncEngineLevel: browser online — triggering immediate integrity check');
|
|
1142
|
+
// Don't set _connectivityState here — individual links will transition
|
|
1143
|
+
// to online as their WebSocket connections actually recover during the
|
|
1144
|
+
// sync below. The public getter uses per-link aggregation.
|
|
1145
|
+
|
|
1146
|
+
// Kick off an immediate SMT reconciliation to catch up after being offline.
|
|
1147
|
+
if (!this._syncLock) {
|
|
1148
|
+
this.sync().catch((err) => {
|
|
1149
|
+
console.error('SyncEngineLevel: post-online sync failed', err);
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
this._onOffline = (): void => {
|
|
1155
|
+
if (this._engineGeneration !== generation) { return; }
|
|
1156
|
+
console.info('SyncEngineLevel: browser offline');
|
|
1157
|
+
this._connectivityState = 'offline';
|
|
1158
|
+
|
|
1159
|
+
// Transition every active link to offline so the public
|
|
1160
|
+
// connectivityState getter (which aggregates per-link state)
|
|
1161
|
+
// reflects the browser's network status immediately.
|
|
1162
|
+
for (const link of this._activeLinks.values()) {
|
|
1163
|
+
const prev = link.connectivity;
|
|
1164
|
+
if (prev !== 'offline') {
|
|
1165
|
+
link.connectivity = 'offline';
|
|
1166
|
+
this.emitEvent({
|
|
1167
|
+
type : 'link:connectivity-change',
|
|
1168
|
+
tenantDid : link.tenantDid,
|
|
1169
|
+
remoteEndpoint : link.remoteEndpoint,
|
|
1170
|
+
protocol : link.protocol,
|
|
1171
|
+
from : prev,
|
|
1172
|
+
to : 'offline',
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
this._onVisibilityChange = (): void => {
|
|
1179
|
+
if (this._engineGeneration !== generation) { return; }
|
|
1180
|
+
|
|
1181
|
+
// Only act when the page becomes visible again — the user is back.
|
|
1182
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'visible') { return; }
|
|
1183
|
+
|
|
1184
|
+
console.info('SyncEngineLevel: page became visible — triggering integrity check');
|
|
1185
|
+
|
|
1186
|
+
// The device may have slept and WebSockets may be dead. An immediate
|
|
1187
|
+
// sync via SMT reconciliation detects and repairs any divergence.
|
|
1188
|
+
if (!this._syncLock) {
|
|
1189
|
+
this.sync().catch((err) => {
|
|
1190
|
+
console.error('SyncEngineLevel: post-visibility sync failed', err);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
globalThis.addEventListener('online', this._onOnline);
|
|
1196
|
+
globalThis.addEventListener('offline', this._onOffline);
|
|
1197
|
+
|
|
1198
|
+
if (typeof document !== 'undefined') {
|
|
1199
|
+
document.addEventListener('visibilitychange', this._onVisibilityChange);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Removes browser connectivity listeners if they were registered. */
|
|
1204
|
+
private stopBrowserConnectivityListeners(): void {
|
|
1205
|
+
if (this._onOnline) {
|
|
1206
|
+
globalThis.removeEventListener('online', this._onOnline);
|
|
1207
|
+
this._onOnline = undefined;
|
|
1208
|
+
}
|
|
1209
|
+
if (this._onOffline) {
|
|
1210
|
+
globalThis.removeEventListener('offline', this._onOffline);
|
|
1211
|
+
this._onOffline = undefined;
|
|
1212
|
+
}
|
|
1213
|
+
if (this._onVisibilityChange && typeof document !== 'undefined') {
|
|
1214
|
+
document.removeEventListener('visibilitychange', this._onVisibilityChange);
|
|
1215
|
+
this._onVisibilityChange = undefined;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ---------------------------------------------------------------------------
|
|
1220
|
+
// Teardown
|
|
1221
|
+
// ---------------------------------------------------------------------------
|
|
1222
|
+
|
|
1098
1223
|
private async teardownLiveSync(): Promise<void> {
|
|
1224
|
+
// Remove browser connectivity listeners before tearing down.
|
|
1225
|
+
this.stopBrowserConnectivityListeners();
|
|
1226
|
+
|
|
1099
1227
|
// Increment generation to invalidate all in-flight async operations
|
|
1100
1228
|
// (repairs, retry timers, degraded-poll ticks). Any async work that
|
|
1101
1229
|
// captured the previous generation will bail on its next checkpoint.
|
|
@@ -1357,10 +1485,25 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1357
1485
|
);
|
|
1358
1486
|
|
|
1359
1487
|
if (!closureResult.complete) {
|
|
1488
|
+
const failureCode = closureResult.failure!.code;
|
|
1489
|
+
const failureDetail = closureResult.failure!.detail;
|
|
1360
1490
|
console.warn(
|
|
1361
1491
|
`SyncEngineLevel: Closure incomplete for ${did} -> ${dwnUrl}: ` +
|
|
1362
|
-
`${
|
|
1492
|
+
`${failureCode} — ${failureDetail}`
|
|
1363
1493
|
);
|
|
1494
|
+
|
|
1495
|
+
// Record the message that triggered the closure failure.
|
|
1496
|
+
const closureCid = await Message.getCid(event.message);
|
|
1497
|
+
void this.recordDeadLetter({
|
|
1498
|
+
messageCid : closureCid,
|
|
1499
|
+
tenantDid : did,
|
|
1500
|
+
remoteEndpoint : dwnUrl,
|
|
1501
|
+
protocol,
|
|
1502
|
+
category : 'closure',
|
|
1503
|
+
errorCode : failureCode,
|
|
1504
|
+
errorDetail : failureDetail,
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1364
1507
|
await this.transitionToRepairing(cursorKey, link);
|
|
1365
1508
|
return;
|
|
1366
1509
|
}
|
|
@@ -1380,6 +1523,10 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1380
1523
|
this._recentlyPulledCids.set(`${pulledCid}|${dwnUrl}`, Date.now() + SyncEngineLevel.ECHO_SUPPRESS_TTL_MS);
|
|
1381
1524
|
this.evictExpiredEchoEntries();
|
|
1382
1525
|
|
|
1526
|
+
// Auto-clear any dead letter for this CID — it was processed
|
|
1527
|
+
// successfully, so a previous failure has been self-healed.
|
|
1528
|
+
this.clearFailedMessage(pulledCid, dwnUrl).catch(() => { /* teardown race */ });
|
|
1529
|
+
|
|
1383
1530
|
// Mark this ordinal as committed and drain the checkpoint.
|
|
1384
1531
|
// Guard: if the link transitioned to repairing while this handler was
|
|
1385
1532
|
// in-flight (e.g., an earlier ordinal's handler failed concurrently),
|
|
@@ -1413,6 +1560,24 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1413
1560
|
}
|
|
1414
1561
|
} catch (error: any) {
|
|
1415
1562
|
console.error(`SyncEngineLevel: Error processing live-pull event for ${did}`, error);
|
|
1563
|
+
|
|
1564
|
+
// Record the failing message in the dead letter store before
|
|
1565
|
+
// transitioning to repair. The CID identifies which specific
|
|
1566
|
+
// message caused the transition.
|
|
1567
|
+
try {
|
|
1568
|
+
const failedCid = await Message.getCid(event.message);
|
|
1569
|
+
void this.recordDeadLetter({
|
|
1570
|
+
messageCid : failedCid,
|
|
1571
|
+
tenantDid : did,
|
|
1572
|
+
remoteEndpoint : dwnUrl,
|
|
1573
|
+
protocol,
|
|
1574
|
+
category : 'pull-processing',
|
|
1575
|
+
errorDetail : error.message ?? String(error),
|
|
1576
|
+
});
|
|
1577
|
+
} catch {
|
|
1578
|
+
// Best effort — don't let dead letter recording block repair.
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1416
1581
|
// A failed processRawMessage means local state is incomplete.
|
|
1417
1582
|
// Transition to repairing immediately — do NOT advance the checkpoint
|
|
1418
1583
|
// past this failure or let later ordinals commit past it. SMT
|
|
@@ -1647,6 +1812,25 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1647
1812
|
permissionsApi : this._permissionsApi,
|
|
1648
1813
|
});
|
|
1649
1814
|
|
|
1815
|
+
// Auto-clear dead letters for CIDs that succeeded — a previously
|
|
1816
|
+
// failed message may have been repaired by reconciliation.
|
|
1817
|
+
for (const cid of result.succeeded) {
|
|
1818
|
+
this.clearFailedMessage(cid, dwnUrl).catch(() => { /* teardown race */ });
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Record permanently failed messages in the dead letter store.
|
|
1822
|
+
for (const entry of result.permanentlyFailed) {
|
|
1823
|
+
await this.recordDeadLetter({
|
|
1824
|
+
messageCid : entry.cid,
|
|
1825
|
+
tenantDid : did,
|
|
1826
|
+
remoteEndpoint : dwnUrl,
|
|
1827
|
+
protocol,
|
|
1828
|
+
category : 'push-permanent',
|
|
1829
|
+
errorCode : String(entry.statusCode ?? ''),
|
|
1830
|
+
errorDetail : entry.detail ?? 'permanent push failure',
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1650
1834
|
if (result.failed.length > 0) {
|
|
1651
1835
|
const failedSet = new Set(result.failed);
|
|
1652
1836
|
const failedEntries = pushEntries.filter((entry) => failedSet.has(entry.cid));
|
|
@@ -1703,7 +1887,18 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1703
1887
|
const pushRuntime = this.getOrCreatePushRuntime(targetKey, pending);
|
|
1704
1888
|
|
|
1705
1889
|
if (pending.retryCount >= maxRetries) {
|
|
1706
|
-
// Retry budget exhausted —
|
|
1890
|
+
// Retry budget exhausted — record each CID as a dead letter and mark
|
|
1891
|
+
// the link dirty for reconciliation.
|
|
1892
|
+
for (const entry of pending.entries) {
|
|
1893
|
+
void this.recordDeadLetter({
|
|
1894
|
+
messageCid : entry.cid,
|
|
1895
|
+
tenantDid : pending.did,
|
|
1896
|
+
remoteEndpoint : pending.dwnUrl,
|
|
1897
|
+
protocol : pending.protocol,
|
|
1898
|
+
category : 'push-exhausted',
|
|
1899
|
+
errorDetail : `push retries exhausted after ${maxRetries} attempts`,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1707
1902
|
if (pushRuntime.timer) {
|
|
1708
1903
|
clearTimeout(pushRuntime.timer);
|
|
1709
1904
|
}
|
|
@@ -1815,6 +2010,9 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1815
2010
|
|
|
1816
2011
|
if (reconcileOutcome.converged) {
|
|
1817
2012
|
await this.ledger.clearNeedsReconcile(link);
|
|
2013
|
+
// SMT roots match — this link is converged. Clear dead letters
|
|
2014
|
+
// scoped to this specific link (tenantDid, remoteEndpoint, protocol).
|
|
2015
|
+
void this.clearDeadLettersForLink(did, dwnUrl, protocol);
|
|
1818
2016
|
this.emitEvent({ type: 'reconcile:completed', tenantDid: did, remoteEndpoint: dwnUrl, protocol });
|
|
1819
2017
|
} else {
|
|
1820
2018
|
// Roots still differ — retry after a delay. This can happen when
|
|
@@ -2282,11 +2480,23 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
2282
2480
|
messageCids: string[];
|
|
2283
2481
|
prefetched?: MessagesSyncDiffEntry[];
|
|
2284
2482
|
}): Promise<void> {
|
|
2285
|
-
|
|
2483
|
+
const failedCids = await pullMessages({
|
|
2286
2484
|
did, dwnUrl, delegateDid, protocol, messageCids, prefetched,
|
|
2287
2485
|
agent : this.agent,
|
|
2288
2486
|
permissionsApi : this._permissionsApi,
|
|
2289
2487
|
});
|
|
2488
|
+
|
|
2489
|
+
// Record permanently failed pull entries in the dead letter store.
|
|
2490
|
+
for (const cid of failedCids) {
|
|
2491
|
+
await this.recordDeadLetter({
|
|
2492
|
+
messageCid : cid,
|
|
2493
|
+
tenantDid : did,
|
|
2494
|
+
remoteEndpoint : dwnUrl,
|
|
2495
|
+
protocol,
|
|
2496
|
+
category : 'pull-processing',
|
|
2497
|
+
errorDetail : 'pull processing failed after retry passes exhausted',
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2290
2500
|
}
|
|
2291
2501
|
|
|
2292
2502
|
// ---------------------------------------------------------------------------
|
|
@@ -2367,6 +2577,160 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
2367
2577
|
return topologicalSort(messages);
|
|
2368
2578
|
}
|
|
2369
2579
|
|
|
2580
|
+
// ---------------------------------------------------------------------------
|
|
2581
|
+
// Dead letter tracking
|
|
2582
|
+
// ---------------------------------------------------------------------------
|
|
2583
|
+
|
|
2584
|
+
/**
|
|
2585
|
+
* Clear dead letter entries scoped to a specific sync link. Matches on
|
|
2586
|
+
* (tenantDid, remoteEndpoint, protocol) so that repairing protocol A
|
|
2587
|
+
* does not erase still-valid failures for protocol B on the same remote.
|
|
2588
|
+
* When `protocol` is undefined (full-tenant link), clears entries that
|
|
2589
|
+
* also have no protocol.
|
|
2590
|
+
*/
|
|
2591
|
+
private async clearDeadLettersForLink(tenantDid: string, remoteEndpoint: string, protocol?: string): Promise<void> {
|
|
2592
|
+
const batch: { type: 'del'; key: string }[] = [];
|
|
2593
|
+
try {
|
|
2594
|
+
for await (const [key, value] of this._deadLetters.iterator()) {
|
|
2595
|
+
const entry = JSON.parse(value) as DeadLetterEntry;
|
|
2596
|
+
if (entry.tenantDid === tenantDid &&
|
|
2597
|
+
entry.remoteEndpoint === remoteEndpoint &&
|
|
2598
|
+
entry.protocol === protocol) {
|
|
2599
|
+
batch.push({ type: 'del', key });
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
if (batch.length > 0) {
|
|
2603
|
+
await this._deadLetters.batch(batch);
|
|
2604
|
+
}
|
|
2605
|
+
} catch (error) {
|
|
2606
|
+
const e = error as { code?: string };
|
|
2607
|
+
if (e.code !== 'LEVEL_DATABASE_NOT_OPEN') { throw error; }
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* Build a compound dead letter key. Different remotes can fail the same CID
|
|
2613
|
+
* for different reasons, so the key includes the remote endpoint.
|
|
2614
|
+
*/
|
|
2615
|
+
private static deadLetterKey(messageCid: string, remoteEndpoint?: string): string {
|
|
2616
|
+
return remoteEndpoint ? `${messageCid}|${remoteEndpoint}` : messageCid;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
public async recordDeadLetter(params: {
|
|
2620
|
+
messageCid : string;
|
|
2621
|
+
tenantDid : string;
|
|
2622
|
+
remoteEndpoint? : string;
|
|
2623
|
+
protocol? : string;
|
|
2624
|
+
category : DeadLetterCategory;
|
|
2625
|
+
errorCode? : string;
|
|
2626
|
+
errorDetail : string;
|
|
2627
|
+
}): Promise<void> {
|
|
2628
|
+
const entry: DeadLetterEntry = {
|
|
2629
|
+
...params,
|
|
2630
|
+
failedAt: new Date().toISOString(),
|
|
2631
|
+
};
|
|
2632
|
+
const key = SyncEngineLevel.deadLetterKey(params.messageCid, params.remoteEndpoint);
|
|
2633
|
+
try {
|
|
2634
|
+
await this._deadLetters.put(key, JSON.stringify(entry));
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
// Suppress only the expected teardown race — any other error surfaces.
|
|
2637
|
+
const e = error as { code?: string };
|
|
2638
|
+
if (e.code !== 'LEVEL_DATABASE_NOT_OPEN') {
|
|
2639
|
+
throw error;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
public async getFailedMessages(tenantDid?: string): Promise<DeadLetterEntry[]> {
|
|
2645
|
+
const entries: DeadLetterEntry[] = [];
|
|
2646
|
+
for await (const [, value] of this._deadLetters.iterator()) {
|
|
2647
|
+
const entry = JSON.parse(value) as DeadLetterEntry;
|
|
2648
|
+
if (!tenantDid || entry.tenantDid === tenantDid) {
|
|
2649
|
+
entries.push(entry);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
// Deterministic ordering: newest first so apps see the most recent failures.
|
|
2653
|
+
entries.sort((a, b) => b.failedAt.localeCompare(a.failedAt));
|
|
2654
|
+
return entries;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
public async clearFailedMessage(messageCid: string, remoteEndpoint?: string): Promise<boolean> {
|
|
2658
|
+
if (remoteEndpoint) {
|
|
2659
|
+
// Clear a specific CID + remote pair.
|
|
2660
|
+
const key = SyncEngineLevel.deadLetterKey(messageCid, remoteEndpoint);
|
|
2661
|
+
try {
|
|
2662
|
+
await this._deadLetters.get(key);
|
|
2663
|
+
await this._deadLetters.del(key);
|
|
2664
|
+
return true;
|
|
2665
|
+
} catch (error) {
|
|
2666
|
+
const e = error as { code?: string };
|
|
2667
|
+
if (e.code === 'LEVEL_NOT_FOUND') { return false; }
|
|
2668
|
+
throw error;
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// No remote specified — clear ALL entries for this CID (any remote).
|
|
2673
|
+
let found = false;
|
|
2674
|
+
const batch: { type: 'del'; key: string }[] = [];
|
|
2675
|
+
for await (const [key, value] of this._deadLetters.iterator()) {
|
|
2676
|
+
const entry = JSON.parse(value) as DeadLetterEntry;
|
|
2677
|
+
if (entry.messageCid === messageCid) {
|
|
2678
|
+
batch.push({ type: 'del', key });
|
|
2679
|
+
found = true;
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
if (batch.length > 0) {
|
|
2683
|
+
await this._deadLetters.batch(batch);
|
|
2684
|
+
}
|
|
2685
|
+
return found;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
public async clearAllFailedMessages(tenantDid?: string): Promise<void> {
|
|
2689
|
+
if (!tenantDid) {
|
|
2690
|
+
await this._deadLetters.clear();
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
const batch: { type: 'del'; key: string }[] = [];
|
|
2695
|
+
for await (const [key, value] of this._deadLetters.iterator()) {
|
|
2696
|
+
const entry = JSON.parse(value) as DeadLetterEntry;
|
|
2697
|
+
if (entry.tenantDid === tenantDid) {
|
|
2698
|
+
batch.push({ type: 'del', key });
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
if (batch.length > 0) {
|
|
2702
|
+
await this._deadLetters.batch(batch);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
public async getSyncHealth(): Promise<SyncHealthSummary> {
|
|
2707
|
+
let failedMessageCount = 0;
|
|
2708
|
+
for await (const _ of this._deadLetters.iterator()) {
|
|
2709
|
+
failedMessageCount++;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// Count degraded links from the durable ledger, not just in-memory
|
|
2713
|
+
// _activeLinks. Links persist across restarts; a repairing/degraded_poll
|
|
2714
|
+
// link from a previous session must still be reported.
|
|
2715
|
+
let degradedLinkCount = 0;
|
|
2716
|
+
const allLinks = await this.ledger.getAllLinks();
|
|
2717
|
+
for (const link of allLinks) {
|
|
2718
|
+
if (link.status === 'repairing' || link.status === 'degraded_poll') {
|
|
2719
|
+
degradedLinkCount++;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
return {
|
|
2724
|
+
connectivity: this.connectivityState,
|
|
2725
|
+
failedMessageCount,
|
|
2726
|
+
degradedLinkCount,
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// ---------------------------------------------------------------------------
|
|
2731
|
+
// Sync targets
|
|
2732
|
+
// ---------------------------------------------------------------------------
|
|
2733
|
+
|
|
2370
2734
|
/**
|
|
2371
2735
|
* Returns the list of sync targets: (did, dwnUrl, delegateDid?, protocol?) tuples.
|
|
2372
2736
|
*/
|
package/src/sync-messages.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { GenericMessage, MessagesReadReply, MessagesSyncDiffEntry, UnionMessageReply } from '@enbox/dwn-sdk-js';
|
|
2
|
+
|
|
1
3
|
import type { EnboxPlatformAgent } from './types/agent.js';
|
|
2
4
|
import type { PermissionsApi } from './types/permissions.js';
|
|
3
|
-
import type { PushResult } from './types/sync.js';
|
|
4
|
-
import type { GenericMessage, MessagesReadReply, MessagesSyncDiffEntry, UnionMessageReply } from '@enbox/dwn-sdk-js';
|
|
5
|
+
import type { PermanentPushFailure, PushResult } from './types/sync.js';
|
|
5
6
|
|
|
6
7
|
import { DwnInterfaceName, DwnMethodName, Encoder, Message } from '@enbox/dwn-sdk-js';
|
|
7
8
|
|
|
@@ -81,7 +82,7 @@ export async function pullMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
81
82
|
prefetched?: MessagesSyncDiffEntry[];
|
|
82
83
|
agent: EnboxPlatformAgent;
|
|
83
84
|
permissionsApi: PermissionsApi;
|
|
84
|
-
}): Promise<
|
|
85
|
+
}): Promise<string[]> {
|
|
85
86
|
// Convert prefetched diff entries into SyncMessageEntry format.
|
|
86
87
|
const prefetchedEntries: SyncMessageEntry[] = [];
|
|
87
88
|
if (prefetched) {
|
|
@@ -165,6 +166,14 @@ export async function pullMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
165
166
|
pending = [];
|
|
166
167
|
}
|
|
167
168
|
}
|
|
169
|
+
|
|
170
|
+
// Return CIDs that permanently failed after all retry passes.
|
|
171
|
+
const permanentlyFailed: string[] = [];
|
|
172
|
+
for (const entry of pending) {
|
|
173
|
+
const cid = await getMessageCid(entry.message);
|
|
174
|
+
permanentlyFailed.push(cid);
|
|
175
|
+
}
|
|
176
|
+
return permanentlyFailed;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
/**
|
|
@@ -327,7 +336,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
327
336
|
}): Promise<PushResult> {
|
|
328
337
|
const succeeded: string[] = [];
|
|
329
338
|
const failed: string[] = [];
|
|
330
|
-
const permanentlyFailed:
|
|
339
|
+
const permanentlyFailed: PermanentPushFailure[] = [];
|
|
331
340
|
|
|
332
341
|
// Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
|
|
333
342
|
const fetched: SyncMessageEntry[] = [];
|
|
@@ -369,7 +378,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
369
378
|
} else {
|
|
370
379
|
console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
|
|
371
380
|
}
|
|
372
|
-
permanentlyFailed.push(cid);
|
|
381
|
+
permanentlyFailed.push({ cid, statusCode: reply.status.code, detail: reply.status.detail ?? '' });
|
|
373
382
|
} else {
|
|
374
383
|
// Transient failures (5xx, etc.) — worth retrying.
|
|
375
384
|
console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
|
package/src/types/permissions.ts
CHANGED
|
@@ -48,6 +48,11 @@ export type CreateGrantParams = {
|
|
|
48
48
|
grantedTo: string;
|
|
49
49
|
scope: DwnPermissionScope;
|
|
50
50
|
delegated?: boolean;
|
|
51
|
+
/** Delegate key-delivery metadata for cross-device context key delivery. */
|
|
52
|
+
delegateKeyDelivery?: {
|
|
53
|
+
rootKeyId: string;
|
|
54
|
+
publicKeyJwk: Record<string, any>;
|
|
55
|
+
};
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
export type CreateRequestParams = {
|
|
@@ -63,6 +68,10 @@ export type CreateRevocationParams = {
|
|
|
63
68
|
author: string;
|
|
64
69
|
grant: DwnPermissionGrant;
|
|
65
70
|
description?: string;
|
|
71
|
+
/** For delegated revocation: the delegate DID that signs the revocation. */
|
|
72
|
+
granteeDid?: string;
|
|
73
|
+
/** For delegated revocation: the grant ID that authorizes the revocation write. */
|
|
74
|
+
permissionGrantId?: string;
|
|
66
75
|
};
|
|
67
76
|
|
|
68
77
|
export type GetPermissionParams = {
|