@enbox/agent 0.6.5 → 0.6.7
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/README.md +18 -5
- package/dist/browser.mjs +11 -11
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/agent-did-resolver-cache.js +5 -5
- package/dist/esm/agent-did-resolver-cache.js.map +1 -1
- package/dist/esm/crypto-api.js.map +1 -1
- package/dist/esm/did-api.js +1 -1
- package/dist/esm/did-api.js.map +1 -1
- package/dist/esm/dwn-api.js +93 -53
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-discovery-payload.js +7 -4
- package/dist/esm/dwn-discovery-payload.js.map +1 -1
- package/dist/esm/dwn-key-delivery.js +8 -3
- package/dist/esm/dwn-key-delivery.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +34 -14
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/enbox-user-agent.js +11 -3
- package/dist/esm/enbox-user-agent.js.map +1 -1
- package/dist/esm/hd-identity-vault.js +33 -18
- package/dist/esm/hd-identity-vault.js.map +1 -1
- package/dist/esm/identity-api.js +5 -4
- package/dist/esm/identity-api.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/local-dwn.js.map +1 -1
- package/dist/esm/local-key-manager.js.map +1 -1
- package/dist/esm/permissions-api.js +9 -5
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/prototyping/crypto/jose/jwe-flattened.js +9 -9
- package/dist/esm/prototyping/crypto/jose/jwe-flattened.js.map +1 -1
- package/dist/esm/secret-store.js +106 -0
- package/dist/esm/secret-store.js.map +1 -0
- package/dist/esm/store-data.js +32 -11
- package/dist/esm/store-data.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +1 -1
- package/dist/esm/sync-closure-resolver.js.map +1 -1
- package/dist/esm/sync-engine-level.js +418 -141
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-replication-ledger.js +25 -0
- package/dist/esm/sync-replication-ledger.js.map +1 -1
- package/dist/esm/test-harness.js +32 -5
- package/dist/esm/test-harness.js.map +1 -1
- package/dist/esm/types/sync.js +9 -3
- package/dist/esm/types/sync.js.map +1 -1
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/agent-did-resolver-cache.d.ts +1 -1
- package/dist/types/agent-did-resolver-cache.d.ts.map +1 -1
- package/dist/types/anonymous-dwn-api.d.ts +2 -2
- package/dist/types/anonymous-dwn-api.d.ts.map +1 -1
- package/dist/types/crypto-api.d.ts +1 -1
- package/dist/types/crypto-api.d.ts.map +1 -1
- package/dist/types/did-api.d.ts +2 -2
- package/dist/types/did-api.d.ts.map +1 -1
- package/dist/types/dwn-api.d.ts +51 -11
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/dwn-key-delivery.d.ts +4 -1
- package/dist/types/dwn-key-delivery.d.ts.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts +3 -2
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
- package/dist/types/enbox-user-agent.d.ts +5 -1
- package/dist/types/enbox-user-agent.d.ts.map +1 -1
- package/dist/types/hd-identity-vault.d.ts +9 -2
- package/dist/types/hd-identity-vault.d.ts.map +1 -1
- package/dist/types/identity-api.d.ts +1 -1
- package/dist/types/identity-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/local-dwn.d.ts +3 -3
- package/dist/types/local-dwn.d.ts.map +1 -1
- package/dist/types/local-key-manager.d.ts +2 -2
- package/dist/types/local-key-manager.d.ts.map +1 -1
- package/dist/types/permissions-api.d.ts +1 -1
- package/dist/types/permissions-api.d.ts.map +1 -1
- package/dist/types/secret-store.d.ts +81 -0
- package/dist/types/secret-store.d.ts.map +1 -0
- package/dist/types/store-data.d.ts +15 -3
- package/dist/types/store-data.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +52 -16
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-replication-ledger.d.ts +10 -1
- package/dist/types/sync-replication-ledger.d.ts.map +1 -1
- package/dist/types/test-harness.d.ts +3 -0
- package/dist/types/test-harness.d.ts.map +1 -1
- package/dist/types/types/agent.d.ts +3 -0
- package/dist/types/types/agent.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +27 -4
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agent-did-resolver-cache.ts +5 -5
- package/src/anonymous-dwn-api.ts +2 -2
- package/src/crypto-api.ts +1 -1
- package/src/did-api.ts +3 -3
- package/src/dwn-api.ts +107 -69
- package/src/dwn-discovery-payload.ts +5 -4
- package/src/dwn-key-delivery.ts +8 -2
- package/src/enbox-connect-protocol.ts +38 -21
- package/src/enbox-user-agent.ts +15 -3
- package/src/hd-identity-vault.ts +47 -21
- package/src/identity-api.ts +6 -5
- package/src/index.ts +1 -0
- package/src/local-dwn.ts +3 -3
- package/src/local-key-manager.ts +2 -2
- package/src/permissions-api.ts +12 -8
- package/src/prototyping/crypto/jose/jwe-flattened.ts +8 -8
- package/src/secret-store.ts +173 -0
- package/src/store-data.ts +40 -14
- package/src/sync-closure-resolver.ts +2 -2
- package/src/sync-engine-level.ts +423 -162
- package/src/sync-replication-ledger.ts +26 -1
- package/src/test-harness.ts +40 -5
- package/src/types/agent.ts +3 -0
- package/src/types/sync.ts +35 -7
- package/src/utils.ts +1 -1
|
@@ -94,6 +94,18 @@ function isEventInScope(message, scope) {
|
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
96
|
export class SyncEngineLevel {
|
|
97
|
+
/** Validate `SyncIdentityOptions` for `registerIdentity` and `updateIdentityOptions`. */
|
|
98
|
+
static validateSyncIdentityOptions(options) {
|
|
99
|
+
if (!options || !('protocols' in options)) {
|
|
100
|
+
throw new Error('SyncEngineLevel: options.protocols is required — pass \'all\' for a full replica or a non-empty protocol list.');
|
|
101
|
+
}
|
|
102
|
+
if (options.protocols !== 'all' && !Array.isArray(options.protocols)) {
|
|
103
|
+
throw new Error('SyncEngineLevel: protocols must be \'all\' or a non-empty string array.');
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(options.protocols) && options.protocols.length === 0) {
|
|
106
|
+
throw new Error('SyncEngineLevel: protocols must be \'all\' or a non-empty array of protocol URIs. An empty array is ambiguous.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
97
109
|
constructor({ agent, dataPath, db }) {
|
|
98
110
|
this._syncLock = false;
|
|
99
111
|
/**
|
|
@@ -112,7 +124,7 @@ export class SyncEngineLevel {
|
|
|
112
124
|
// ---------------------------------------------------------------------------
|
|
113
125
|
// Live sync state
|
|
114
126
|
// ---------------------------------------------------------------------------
|
|
115
|
-
/** Current sync mode, set by `startSync`. */
|
|
127
|
+
/** Current sync mode, set by `startSync`. Reset to `undefined` by `stopSync`/`clear`. */
|
|
116
128
|
this._syncMode = 'poll';
|
|
117
129
|
/**
|
|
118
130
|
* Monotonic session generation counter. Incremented on every teardown.
|
|
@@ -144,6 +156,14 @@ export class SyncEngineLevel {
|
|
|
144
156
|
* tenant. Keyed by tenantDid to prevent cross-tenant cache pollution.
|
|
145
157
|
*/
|
|
146
158
|
this._closureContexts = new Map();
|
|
159
|
+
/**
|
|
160
|
+
* Monotonic generation counter for sync target cache invalidation.
|
|
161
|
+
* Bumped on every invalidation (register/unregister/update/clear/close/stopSync).
|
|
162
|
+
* An in-flight `getSyncTargets()` captures the generation before awaiting
|
|
163
|
+
* and only writes to the cache if it hasn't changed, preventing a
|
|
164
|
+
* concurrent mutation from being masked by stale data.
|
|
165
|
+
*/
|
|
166
|
+
this._syncTargetsCacheGeneration = 0;
|
|
147
167
|
/** Count of consecutive SMT sync failures (for backoff in poll mode). */
|
|
148
168
|
this._consecutiveFailures = 0;
|
|
149
169
|
/** Per-link degraded-poll interval timers. */
|
|
@@ -198,6 +218,15 @@ export class SyncEngineLevel {
|
|
|
198
218
|
set agent(agent) {
|
|
199
219
|
this._agent = agent;
|
|
200
220
|
this._permissionsApi = new AgentPermissionsApi({ agent: agent });
|
|
221
|
+
// Cached sync targets were resolved through the previous agent's
|
|
222
|
+
// DID resolver / endpoint lookup — invalidate so the next sync
|
|
223
|
+
// tick re-resolves through the new agent.
|
|
224
|
+
this._syncTargetsCache = undefined;
|
|
225
|
+
this._syncTargetsCacheGeneration++;
|
|
226
|
+
}
|
|
227
|
+
get hasActiveSubscriptions() {
|
|
228
|
+
return this._liveSubscriptions.length > 0 ||
|
|
229
|
+
this._localSubscriptions.length > 0;
|
|
201
230
|
}
|
|
202
231
|
get connectivityState() {
|
|
203
232
|
// Aggregate per-link connectivity: if any link is online, report online.
|
|
@@ -241,27 +270,37 @@ export class SyncEngineLevel {
|
|
|
241
270
|
}
|
|
242
271
|
clear() {
|
|
243
272
|
return __awaiter(this, void 0, void 0, function* () {
|
|
273
|
+
this._syncTargetsCache = undefined;
|
|
274
|
+
this._syncTargetsCacheGeneration++;
|
|
244
275
|
yield this.teardownLiveSync();
|
|
276
|
+
this._syncMode = undefined;
|
|
245
277
|
yield this._permissionsApi.clear();
|
|
246
278
|
yield this._db.clear();
|
|
247
279
|
});
|
|
248
280
|
}
|
|
249
281
|
close() {
|
|
250
282
|
return __awaiter(this, void 0, void 0, function* () {
|
|
283
|
+
this._syncTargetsCache = undefined;
|
|
284
|
+
this._syncTargetsCacheGeneration++;
|
|
251
285
|
yield this.teardownLiveSync();
|
|
252
286
|
yield this._db.close();
|
|
253
287
|
});
|
|
254
288
|
}
|
|
255
289
|
registerIdentity(_a) {
|
|
256
290
|
return __awaiter(this, arguments, void 0, function* ({ did, options }) {
|
|
291
|
+
SyncEngineLevel.validateSyncIdentityOptions(options);
|
|
257
292
|
const registeredIdentities = this._db.sublevel('registeredIdentities');
|
|
258
293
|
const existing = yield this.getIdentityOptions(did);
|
|
259
294
|
if (existing) {
|
|
260
295
|
throw new Error(`SyncEngineLevel: Identity with DID ${did} is already registered.`);
|
|
261
296
|
}
|
|
262
|
-
// if no options are provided, we default to no delegateDid and all protocols (empty array)
|
|
263
|
-
options !== null && options !== void 0 ? options : (options = { protocols: [] });
|
|
264
297
|
yield registeredIdentities.put(did, JSON.stringify(options));
|
|
298
|
+
this._syncTargetsCache = undefined;
|
|
299
|
+
this._syncTargetsCacheGeneration++;
|
|
300
|
+
// If live sync is active, hot-add subscriptions for this identity.
|
|
301
|
+
if (this._syncMode === 'live') {
|
|
302
|
+
yield this.addIdentityToLiveSync(did, options);
|
|
303
|
+
}
|
|
265
304
|
});
|
|
266
305
|
}
|
|
267
306
|
unregisterIdentity(did) {
|
|
@@ -271,7 +310,13 @@ export class SyncEngineLevel {
|
|
|
271
310
|
if (!existing) {
|
|
272
311
|
throw new Error(`SyncEngineLevel: Identity with DID ${did} is not registered.`);
|
|
273
312
|
}
|
|
313
|
+
// If live sync is active, hot-remove subscriptions for this identity.
|
|
314
|
+
if (this._syncMode === 'live') {
|
|
315
|
+
yield this.removeIdentityFromLiveSync(did);
|
|
316
|
+
}
|
|
274
317
|
yield registeredIdentities.del(did);
|
|
318
|
+
this._syncTargetsCache = undefined;
|
|
319
|
+
this._syncTargetsCacheGeneration++;
|
|
275
320
|
});
|
|
276
321
|
}
|
|
277
322
|
getIdentityOptions(did) {
|
|
@@ -297,12 +342,28 @@ export class SyncEngineLevel {
|
|
|
297
342
|
}
|
|
298
343
|
updateIdentityOptions(_a) {
|
|
299
344
|
return __awaiter(this, arguments, void 0, function* ({ did, options }) {
|
|
345
|
+
SyncEngineLevel.validateSyncIdentityOptions(options);
|
|
300
346
|
const registeredIdentities = this._db.sublevel('registeredIdentities');
|
|
301
347
|
const existingOptions = yield this.getIdentityOptions(did);
|
|
302
348
|
if (!existingOptions) {
|
|
303
349
|
throw new Error(`SyncEngineLevel: Identity with DID ${did} is not registered.`);
|
|
304
350
|
}
|
|
305
351
|
yield registeredIdentities.put(did, JSON.stringify(options));
|
|
352
|
+
this._syncTargetsCache = undefined;
|
|
353
|
+
this._syncTargetsCacheGeneration++;
|
|
354
|
+
// Always persist the new delegate to durable links, regardless of
|
|
355
|
+
// sync mode. If sync is stopped or polling, existing persisted links
|
|
356
|
+
// would otherwise keep the old delegateDid. When live sync starts
|
|
357
|
+
// later, initializeLinkTarget() loads the link from LevelDB without
|
|
358
|
+
// normalizing delegateDid, so repair/reconcile paths could use stale
|
|
359
|
+
// delegate data.
|
|
360
|
+
yield this.ledger.updateDelegateDid(did, options.delegateDid);
|
|
361
|
+
// If live sync is active, tear down and rebuild subscriptions with
|
|
362
|
+
// the new options.
|
|
363
|
+
if (this._syncMode === 'live' && this.hasActiveLinksForDid(did)) {
|
|
364
|
+
yield this.removeIdentityFromLiveSync(did);
|
|
365
|
+
yield this.addIdentityToLiveSync(did, options);
|
|
366
|
+
}
|
|
306
367
|
});
|
|
307
368
|
}
|
|
308
369
|
// ---------------------------------------------------------------------------
|
|
@@ -421,7 +482,10 @@ export class SyncEngineLevel {
|
|
|
421
482
|
clearInterval(this._syncIntervalId);
|
|
422
483
|
this._syncIntervalId = undefined;
|
|
423
484
|
}
|
|
485
|
+
this._syncTargetsCache = undefined;
|
|
486
|
+
this._syncTargetsCacheGeneration++;
|
|
424
487
|
yield this.teardownLiveSync();
|
|
488
|
+
this._syncMode = undefined;
|
|
425
489
|
});
|
|
426
490
|
}
|
|
427
491
|
// ---------------------------------------------------------------------------
|
|
@@ -492,90 +556,7 @@ export class SyncEngineLevel {
|
|
|
492
556
|
// Step 2: Initialize replication links and open live subscriptions.
|
|
493
557
|
// Each target's link initialization is independent — process concurrently.
|
|
494
558
|
const syncTargets = yield this.getSyncTargets();
|
|
495
|
-
yield Promise.allSettled(syncTargets.map(
|
|
496
|
-
let link;
|
|
497
|
-
try {
|
|
498
|
-
// Get or create the link in the durable ledger.
|
|
499
|
-
// Use protocol-scoped scope when a protocol is specified, otherwise full-tenant.
|
|
500
|
-
const linkScope = target.protocol
|
|
501
|
-
? { kind: 'protocol', protocol: target.protocol }
|
|
502
|
-
: { kind: 'full' };
|
|
503
|
-
link = yield this.ledger.getOrCreateLink({
|
|
504
|
-
tenantDid: target.did,
|
|
505
|
-
remoteEndpoint: target.dwnUrl,
|
|
506
|
-
scope: linkScope,
|
|
507
|
-
delegateDid: target.delegateDid,
|
|
508
|
-
protocol: target.protocol,
|
|
509
|
-
});
|
|
510
|
-
// Cache the link for fast access by subscription handlers.
|
|
511
|
-
// Use scopeId from the link for consistent runtime identity.
|
|
512
|
-
const linkKey = this.buildLinkKey(target.did, target.dwnUrl, link.scopeId);
|
|
513
|
-
// One-time migration: if the link has no pull checkpoint, check for
|
|
514
|
-
// a legacy cursor in the old syncCursors sublevel. The legacy key
|
|
515
|
-
// used protocol, not scopeId, so we must build it the old way.
|
|
516
|
-
if (!link.pull.contiguousAppliedToken) {
|
|
517
|
-
const legacyKey = buildLegacyCursorKey(target.did, target.dwnUrl, target.protocol);
|
|
518
|
-
const legacyCursor = yield this.getCursor(legacyKey);
|
|
519
|
-
if (legacyCursor) {
|
|
520
|
-
ReplicationLedger.resetCheckpoint(link.pull, legacyCursor);
|
|
521
|
-
yield this.ledger.saveLink(link);
|
|
522
|
-
yield this.deleteLegacyCursor(legacyKey);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
this._activeLinks.set(linkKey, link);
|
|
526
|
-
// Open subscriptions — only transition to live if both succeed.
|
|
527
|
-
// If pull succeeds but push fails, close the pull subscription to
|
|
528
|
-
// avoid a resource leak with inconsistent state.
|
|
529
|
-
const targetWithKey = Object.assign(Object.assign({}, target), { linkKey });
|
|
530
|
-
yield this.openLivePullSubscription(targetWithKey);
|
|
531
|
-
try {
|
|
532
|
-
yield this.openLocalPushSubscription(targetWithKey);
|
|
533
|
-
}
|
|
534
|
-
catch (pushError) {
|
|
535
|
-
// Close the already-opened pull subscription.
|
|
536
|
-
const pullSub = this._liveSubscriptions.find((s) => s.linkKey === linkKey);
|
|
537
|
-
if (pullSub) {
|
|
538
|
-
try {
|
|
539
|
-
yield pullSub.close();
|
|
540
|
-
}
|
|
541
|
-
catch ( /* best effort */_a) { /* best effort */ }
|
|
542
|
-
this._liveSubscriptions = this._liveSubscriptions.filter(s => s !== pullSub);
|
|
543
|
-
}
|
|
544
|
-
throw pushError;
|
|
545
|
-
}
|
|
546
|
-
this.emitEvent({ type: 'link:status-change', tenantDid: target.did, remoteEndpoint: target.dwnUrl, protocol: target.protocol, from: 'initializing', to: 'live' });
|
|
547
|
-
yield this.ledger.setStatus(link, 'live');
|
|
548
|
-
// If the link was marked dirty in a previous session, schedule
|
|
549
|
-
// immediate reconciliation now that subscriptions are open.
|
|
550
|
-
if (link.needsReconcile) {
|
|
551
|
-
this.scheduleReconcile(linkKey, 1000);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
catch (error) {
|
|
555
|
-
const linkKey = link
|
|
556
|
-
? this.buildLinkKey(target.did, target.dwnUrl, link.scopeId)
|
|
557
|
-
: buildLegacyCursorKey(target.did, target.dwnUrl, target.protocol);
|
|
558
|
-
// Detect ProgressGap (410) — the cursor is stale, link needs SMT repair.
|
|
559
|
-
if (error.isProgressGap && link) {
|
|
560
|
-
console.warn(`SyncEngineLevel: ProgressGap detected for ${target.did} -> ${target.dwnUrl}, initiating repair`);
|
|
561
|
-
this.emitEvent({ type: 'gap:detected', tenantDid: target.did, remoteEndpoint: target.dwnUrl, protocol: target.protocol, reason: 'ProgressGap' });
|
|
562
|
-
const gapInfo = error.gapInfo;
|
|
563
|
-
yield this.transitionToRepairing(linkKey, link, {
|
|
564
|
-
resumeToken: gapInfo === null || gapInfo === void 0 ? void 0 : gapInfo.latestAvailable,
|
|
565
|
-
});
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
console.error(`SyncEngineLevel: Failed to open live subscription for ${target.did} -> ${target.dwnUrl}`, error);
|
|
569
|
-
// Clean up in-memory state for the failed link so it doesn't appear
|
|
570
|
-
// active to later code. The durable link remains at 'initializing'.
|
|
571
|
-
this._activeLinks.delete(linkKey);
|
|
572
|
-
this._linkRuntimes.delete(linkKey);
|
|
573
|
-
// Recompute connectivity — if no live subscriptions remain, reset to unknown.
|
|
574
|
-
if (this._liveSubscriptions.length === 0) {
|
|
575
|
-
this._connectivityState = 'unknown';
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
})));
|
|
559
|
+
yield Promise.allSettled(syncTargets.map(t => this.initializeLinkTarget(t)));
|
|
579
560
|
// Step 3: Schedule infrequent SMT integrity check.
|
|
580
561
|
const integrityCheck = () => __awaiter(this, void 0, void 0, function* () {
|
|
581
562
|
if (this._syncLock) {
|
|
@@ -616,7 +597,7 @@ export class SyncEngineLevel {
|
|
|
616
597
|
let drained = 0;
|
|
617
598
|
while (true) {
|
|
618
599
|
const entry = rt.inflight.get(rt.nextCommitOrdinal);
|
|
619
|
-
if (!entry ||
|
|
600
|
+
if (!(entry === null || entry === void 0 ? void 0 : entry.committed)) {
|
|
620
601
|
break;
|
|
621
602
|
}
|
|
622
603
|
// This ordinal is committed — advance the durable checkpoint.
|
|
@@ -694,7 +675,7 @@ export class SyncEngineLevel {
|
|
|
694
675
|
}
|
|
695
676
|
// Verify link still exists and is still repairing.
|
|
696
677
|
const currentLink = this._activeLinks.get(linkKey);
|
|
697
|
-
if (
|
|
678
|
+
if ((currentLink === null || currentLink === void 0 ? void 0 : currentLink.status) !== 'repairing') {
|
|
698
679
|
return;
|
|
699
680
|
}
|
|
700
681
|
try {
|
|
@@ -750,6 +731,10 @@ export class SyncEngineLevel {
|
|
|
750
731
|
// any await, the generation will have incremented and we bail before
|
|
751
732
|
// mutating state — preventing the race where repair continues after teardown.
|
|
752
733
|
const generation = this._engineGeneration;
|
|
734
|
+
// Identity guard helper: if the DID was hot-removed and quickly re-added,
|
|
735
|
+
// `_activeLinks` may contain a *different* link object for the same key.
|
|
736
|
+
// The old repair closure must not mutate the replacement link's state.
|
|
737
|
+
const isStaleLink = () => this._activeLinks.get(linkKey) !== link;
|
|
753
738
|
const { tenantDid: did, remoteEndpoint: dwnUrl, delegateDid, protocol } = link;
|
|
754
739
|
this.emitEvent({ type: 'repair:started', tenantDid: did, remoteEndpoint: dwnUrl, protocol, attempt: ((_a = this._repairAttempts.get(linkKey)) !== null && _a !== void 0 ? _a : 0) + 1 });
|
|
755
740
|
const attempts = ((_b = this._repairAttempts.get(linkKey)) !== null && _b !== void 0 ? _b : 0) + 1;
|
|
@@ -757,9 +742,9 @@ export class SyncEngineLevel {
|
|
|
757
742
|
// Step 1: Close existing subscriptions FIRST to stop old events from
|
|
758
743
|
// mutating local state while repair runs.
|
|
759
744
|
yield this.closeLinkSubscriptions(link);
|
|
760
|
-
if (this._engineGeneration !== generation) {
|
|
745
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
761
746
|
return;
|
|
762
|
-
}
|
|
747
|
+
}
|
|
763
748
|
// Step 2: Clear runtime ordinals immediately — stale state must not
|
|
764
749
|
// persist across repair attempts (successful or failed).
|
|
765
750
|
const rt = this.getOrCreateRuntime(linkKey);
|
|
@@ -768,7 +753,7 @@ export class SyncEngineLevel {
|
|
|
768
753
|
rt.nextCommitOrdinal = 0;
|
|
769
754
|
try {
|
|
770
755
|
// Step 3: Run SMT reconciliation for this link.
|
|
771
|
-
const reconcileOutcome = yield this.createLinkReconciler(() => this._engineGeneration === generation).reconcile({ did, dwnUrl, delegateDid, protocol });
|
|
756
|
+
const reconcileOutcome = yield this.createLinkReconciler(() => this._engineGeneration === generation && !isStaleLink()).reconcile({ did, dwnUrl, delegateDid, protocol });
|
|
772
757
|
if (reconcileOutcome.aborted) {
|
|
773
758
|
return;
|
|
774
759
|
}
|
|
@@ -782,7 +767,7 @@ export class SyncEngineLevel {
|
|
|
782
767
|
const resumeToken = (_c = repairCtx === null || repairCtx === void 0 ? void 0 : repairCtx.resumeToken) !== null && _c !== void 0 ? _c : link.pull.contiguousAppliedToken;
|
|
783
768
|
ReplicationLedger.resetCheckpoint(link.pull, resumeToken);
|
|
784
769
|
yield this.ledger.saveLink(link);
|
|
785
|
-
if (this._engineGeneration !== generation) {
|
|
770
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
786
771
|
return;
|
|
787
772
|
}
|
|
788
773
|
// Step 5: Reopen subscriptions.
|
|
@@ -793,7 +778,7 @@ export class SyncEngineLevel {
|
|
|
793
778
|
// if roots already match).
|
|
794
779
|
link.needsReconcile = true;
|
|
795
780
|
yield this.ledger.saveLink(link);
|
|
796
|
-
if (this._engineGeneration !== generation) {
|
|
781
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
797
782
|
return;
|
|
798
783
|
}
|
|
799
784
|
const target = { did, dwnUrl, delegateDid, protocol, linkKey };
|
|
@@ -805,7 +790,7 @@ export class SyncEngineLevel {
|
|
|
805
790
|
console.warn(`SyncEngineLevel: Stale pull resume token for ${did} -> ${dwnUrl}, resetting to start fresh`);
|
|
806
791
|
ReplicationLedger.resetCheckpoint(link.pull);
|
|
807
792
|
yield this.ledger.saveLink(link);
|
|
808
|
-
if (this._engineGeneration !== generation) {
|
|
793
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
809
794
|
return;
|
|
810
795
|
}
|
|
811
796
|
yield this.openLivePullSubscription(target);
|
|
@@ -814,7 +799,7 @@ export class SyncEngineLevel {
|
|
|
814
799
|
throw pullErr;
|
|
815
800
|
}
|
|
816
801
|
}
|
|
817
|
-
if (this._engineGeneration !== generation) {
|
|
802
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
818
803
|
return;
|
|
819
804
|
}
|
|
820
805
|
try {
|
|
@@ -831,7 +816,7 @@ export class SyncEngineLevel {
|
|
|
831
816
|
}
|
|
832
817
|
throw pushError;
|
|
833
818
|
}
|
|
834
|
-
if (this._engineGeneration !== generation) {
|
|
819
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
835
820
|
return;
|
|
836
821
|
}
|
|
837
822
|
// Note: post-repair reconcile to close the repair-window gap is
|
|
@@ -861,8 +846,9 @@ export class SyncEngineLevel {
|
|
|
861
846
|
this.emitEvent({ type: 'link:status-change', tenantDid: did, remoteEndpoint: dwnUrl, protocol, from: 'repairing', to: 'live' });
|
|
862
847
|
}
|
|
863
848
|
catch (error) {
|
|
864
|
-
// If teardown occurred during repair
|
|
865
|
-
|
|
849
|
+
// If teardown occurred during repair or the link was replaced by a
|
|
850
|
+
// hot-remove + re-add, don't retry or enter degraded_poll.
|
|
851
|
+
if (this._engineGeneration !== generation || isStaleLink()) {
|
|
866
852
|
return;
|
|
867
853
|
}
|
|
868
854
|
console.error(`SyncEngineLevel: Repair failed for ${did} -> ${dwnUrl} (attempt ${attempts})`, error);
|
|
@@ -927,8 +913,14 @@ export class SyncEngineLevel {
|
|
|
927
913
|
clearInterval(existing);
|
|
928
914
|
}
|
|
929
915
|
// Schedule per-link polling with jitter (15-30 seconds).
|
|
916
|
+
// Rejection sampling: mask to 14 bits ([0, 16383]), reject >= 15000.
|
|
930
917
|
const baseInterval = 15000;
|
|
931
|
-
const
|
|
918
|
+
const randomBuf = new Uint32Array(1);
|
|
919
|
+
let jitter;
|
|
920
|
+
do {
|
|
921
|
+
crypto.getRandomValues(randomBuf);
|
|
922
|
+
jitter = randomBuf[0] & 0x3FFF;
|
|
923
|
+
} while (jitter >= baseInterval);
|
|
932
924
|
const interval = baseInterval + jitter;
|
|
933
925
|
const pollGeneration = this._engineGeneration;
|
|
934
926
|
const timer = setInterval(() => __awaiter(this, void 0, void 0, function* () {
|
|
@@ -938,9 +930,12 @@ export class SyncEngineLevel {
|
|
|
938
930
|
this._degradedPollTimers.delete(linkKey);
|
|
939
931
|
return;
|
|
940
932
|
}
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
|
|
933
|
+
// Resolve the *current* link from _activeLinks on each tick, not the
|
|
934
|
+
// captured closure reference. After hot-remove + re-add, the captured
|
|
935
|
+
// `link` object is stale and must not be used for status checks or
|
|
936
|
+
// ledger writes.
|
|
937
|
+
const currentLink = this._activeLinks.get(linkKey);
|
|
938
|
+
if ((currentLink === null || currentLink === void 0 ? void 0 : currentLink.status) !== 'degraded_poll') {
|
|
944
939
|
clearInterval(timer);
|
|
945
940
|
this._degradedPollTimers.delete(linkKey);
|
|
946
941
|
return;
|
|
@@ -949,10 +944,10 @@ export class SyncEngineLevel {
|
|
|
949
944
|
// Attempt repair. Reset attempt counter so repairLink doesn't
|
|
950
945
|
// immediately re-enter degraded_poll on failure.
|
|
951
946
|
this._repairAttempts.set(linkKey, 0);
|
|
952
|
-
yield this.ledger.setStatus(
|
|
947
|
+
yield this.ledger.setStatus(currentLink, 'repairing');
|
|
953
948
|
yield this.repairLink(linkKey);
|
|
954
949
|
// If repairLink succeeded, link is now 'live' — stop polling.
|
|
955
|
-
if (
|
|
950
|
+
if (currentLink.status === 'live') {
|
|
956
951
|
clearInterval(timer);
|
|
957
952
|
this._degradedPollTimers.delete(linkKey);
|
|
958
953
|
}
|
|
@@ -962,7 +957,9 @@ export class SyncEngineLevel {
|
|
|
962
957
|
// This is critical: repairLink sets status to 'repairing' internally,
|
|
963
958
|
// and if we don't restore degraded_poll, the next tick would see
|
|
964
959
|
// status !== 'degraded_poll' and stop the timer permanently.
|
|
965
|
-
|
|
960
|
+
if (this._activeLinks.get(linkKey) === currentLink) {
|
|
961
|
+
yield this.ledger.setStatus(currentLink, 'degraded_poll');
|
|
962
|
+
}
|
|
966
963
|
}
|
|
967
964
|
}), interval);
|
|
968
965
|
this._degradedPollTimers.set(linkKey, timer);
|
|
@@ -1129,6 +1126,193 @@ export class SyncEngineLevel {
|
|
|
1129
1126
|
});
|
|
1130
1127
|
}
|
|
1131
1128
|
// ---------------------------------------------------------------------------
|
|
1129
|
+
// Per-target link initialization (shared by startLiveSync + addIdentityToLiveSync)
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
/**
|
|
1132
|
+
* Initialize a single replication link target: create or resume the durable
|
|
1133
|
+
* link, migrate legacy cursors, open pull + push subscriptions, and
|
|
1134
|
+
* transition the link to `'live'`.
|
|
1135
|
+
*/
|
|
1136
|
+
initializeLinkTarget(target) {
|
|
1137
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
1138
|
+
var _a;
|
|
1139
|
+
let link;
|
|
1140
|
+
try {
|
|
1141
|
+
const linkScope = target.protocol
|
|
1142
|
+
? { kind: 'protocol', protocol: target.protocol }
|
|
1143
|
+
: { kind: 'full' };
|
|
1144
|
+
link = yield this.ledger.getOrCreateLink({
|
|
1145
|
+
tenantDid: target.did,
|
|
1146
|
+
remoteEndpoint: target.dwnUrl,
|
|
1147
|
+
scope: linkScope,
|
|
1148
|
+
delegateDid: target.delegateDid,
|
|
1149
|
+
protocol: target.protocol,
|
|
1150
|
+
});
|
|
1151
|
+
const linkKey = this.buildLinkKey(target.did, target.dwnUrl, link.scopeId);
|
|
1152
|
+
if (!link.pull.contiguousAppliedToken) {
|
|
1153
|
+
const legacyKey = buildLegacyCursorKey(target.did, target.dwnUrl, target.protocol);
|
|
1154
|
+
const legacyCursor = yield this.getCursor(legacyKey);
|
|
1155
|
+
if (legacyCursor) {
|
|
1156
|
+
ReplicationLedger.resetCheckpoint(link.pull, legacyCursor);
|
|
1157
|
+
yield this.ledger.saveLink(link);
|
|
1158
|
+
yield this.deleteLegacyCursor(legacyKey);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
this._activeLinks.set(linkKey, link);
|
|
1162
|
+
const targetWithKey = Object.assign(Object.assign({}, target), { linkKey });
|
|
1163
|
+
yield this.openLivePullSubscription(targetWithKey);
|
|
1164
|
+
try {
|
|
1165
|
+
yield this.openLocalPushSubscription(targetWithKey);
|
|
1166
|
+
}
|
|
1167
|
+
catch (pushError) {
|
|
1168
|
+
const pullSub = this._liveSubscriptions.find((s) => s.linkKey === linkKey);
|
|
1169
|
+
if (pullSub) {
|
|
1170
|
+
try {
|
|
1171
|
+
yield pullSub.close();
|
|
1172
|
+
}
|
|
1173
|
+
catch ( /* best effort */_b) { /* best effort */ }
|
|
1174
|
+
this._liveSubscriptions = this._liveSubscriptions.filter(s => s !== pullSub);
|
|
1175
|
+
}
|
|
1176
|
+
throw pushError;
|
|
1177
|
+
}
|
|
1178
|
+
this.emitEvent({ type: 'link:status-change', tenantDid: target.did, remoteEndpoint: target.dwnUrl, protocol: target.protocol, from: 'initializing', to: 'live' });
|
|
1179
|
+
yield this.ledger.setStatus(link, 'live');
|
|
1180
|
+
if (link.needsReconcile) {
|
|
1181
|
+
this.scheduleReconcile(linkKey, 1000);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
const linkKey = link
|
|
1186
|
+
? this.buildLinkKey(target.did, target.dwnUrl, link.scopeId)
|
|
1187
|
+
: buildLegacyCursorKey(target.did, target.dwnUrl, target.protocol);
|
|
1188
|
+
if (error.isProgressGap && link) {
|
|
1189
|
+
console.warn(`SyncEngineLevel: ProgressGap detected for ${target.did} -> ${target.dwnUrl}, initiating repair`);
|
|
1190
|
+
this.emitEvent({ type: 'gap:detected', tenantDid: target.did, remoteEndpoint: target.dwnUrl, protocol: target.protocol, reason: 'ProgressGap' });
|
|
1191
|
+
yield this.transitionToRepairing(linkKey, link, {
|
|
1192
|
+
resumeToken: (_a = error.gapInfo) === null || _a === void 0 ? void 0 : _a.latestAvailable,
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
console.error(`SyncEngineLevel: Failed to open live subscription for ${target.did} -> ${target.dwnUrl}`, error);
|
|
1197
|
+
this._activeLinks.delete(linkKey);
|
|
1198
|
+
this._linkRuntimes.delete(linkKey);
|
|
1199
|
+
if (this._liveSubscriptions.length === 0) {
|
|
1200
|
+
this._connectivityState = 'unknown';
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
// ---------------------------------------------------------------------------
|
|
1206
|
+
// Hot-add / hot-remove: per-identity live sync management
|
|
1207
|
+
// ---------------------------------------------------------------------------
|
|
1208
|
+
/** Check whether a link key belongs to a given DID. */
|
|
1209
|
+
isLinkKeyForDid(key, did) {
|
|
1210
|
+
return key.startsWith(did + '^') || key.startsWith(did + '_');
|
|
1211
|
+
}
|
|
1212
|
+
/** Check whether this DID has any active links. */
|
|
1213
|
+
hasActiveLinksForDid(did) {
|
|
1214
|
+
for (const key of this._activeLinks.keys()) {
|
|
1215
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1216
|
+
return true;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
/** Hot-add a single identity to the active live sync session. */
|
|
1222
|
+
addIdentityToLiveSync(did, options) {
|
|
1223
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
1224
|
+
const { protocols, delegateDid } = options;
|
|
1225
|
+
const dwnEndpointUrls = yield this.agent.dwn.getDwnEndpointUrlsForTarget(did);
|
|
1226
|
+
if (dwnEndpointUrls.length === 0) {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const targets = [];
|
|
1230
|
+
for (const dwnUrl of dwnEndpointUrls) {
|
|
1231
|
+
if (protocols === 'all') {
|
|
1232
|
+
targets.push({ did, delegateDid, dwnUrl });
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
for (const protocol of protocols) {
|
|
1236
|
+
targets.push({ did, delegateDid, dwnUrl, protocol });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
yield Promise.allSettled(targets.map(t => this.initializeLinkTarget(t)));
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
/** Hot-remove a single identity from the active live sync session. */
|
|
1244
|
+
removeIdentityFromLiveSync(did) {
|
|
1245
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
1246
|
+
for (const sub of this._liveSubscriptions.filter(s => s.did === did)) {
|
|
1247
|
+
try {
|
|
1248
|
+
yield sub.close();
|
|
1249
|
+
}
|
|
1250
|
+
catch ( /* best effort */_a) { /* best effort */ }
|
|
1251
|
+
}
|
|
1252
|
+
this._liveSubscriptions = this._liveSubscriptions.filter(s => s.did !== did);
|
|
1253
|
+
for (const sub of this._localSubscriptions.filter(s => s.did === did)) {
|
|
1254
|
+
try {
|
|
1255
|
+
yield sub.close();
|
|
1256
|
+
}
|
|
1257
|
+
catch ( /* best effort */_b) { /* best effort */ }
|
|
1258
|
+
}
|
|
1259
|
+
this._localSubscriptions = this._localSubscriptions.filter(s => s.did !== did);
|
|
1260
|
+
for (const [key, runtime] of this._pushRuntimes) {
|
|
1261
|
+
if (runtime.did === did) {
|
|
1262
|
+
if (runtime.timer) {
|
|
1263
|
+
clearTimeout(runtime.timer);
|
|
1264
|
+
}
|
|
1265
|
+
this._pushRuntimes.delete(key);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
for (const [key, timer] of this._degradedPollTimers) {
|
|
1269
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1270
|
+
clearInterval(timer);
|
|
1271
|
+
this._degradedPollTimers.delete(key);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
for (const key of this._repairAttempts.keys()) {
|
|
1275
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1276
|
+
this._repairAttempts.delete(key);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
for (const key of this._activeRepairs.keys()) {
|
|
1280
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1281
|
+
this._activeRepairs.delete(key);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
for (const key of this._repairContext.keys()) {
|
|
1285
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1286
|
+
this._repairContext.delete(key);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
for (const [key, timer] of this._repairRetryTimers) {
|
|
1290
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1291
|
+
clearTimeout(timer);
|
|
1292
|
+
this._repairRetryTimers.delete(key);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
for (const [key, timer] of this._reconcileTimers) {
|
|
1296
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1297
|
+
clearTimeout(timer);
|
|
1298
|
+
this._reconcileTimers.delete(key);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
for (const key of this._reconcileInFlight.keys()) {
|
|
1302
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1303
|
+
this._reconcileInFlight.delete(key);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
for (const key of this._activeLinks.keys()) {
|
|
1307
|
+
if (this.isLinkKeyForDid(key, did)) {
|
|
1308
|
+
this._activeLinks.delete(key);
|
|
1309
|
+
this._linkRuntimes.delete(key);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
this._closureContexts.delete(did);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1132
1316
|
// Live pull: MessagesSubscribe to remote DWN
|
|
1133
1317
|
// ---------------------------------------------------------------------------
|
|
1134
1318
|
/**
|
|
@@ -1188,9 +1372,15 @@ export class SyncEngineLevel {
|
|
|
1188
1372
|
// NOTE: The WebSocket client fires handlers without awaiting (fire-and-forget),
|
|
1189
1373
|
// so multiple handlers can be in-flight concurrently. The ordinal tracker
|
|
1190
1374
|
// ensures the checkpoint advances only when all earlier deliveries are committed.
|
|
1375
|
+
// Capture the link reference at subscription-open time so we can
|
|
1376
|
+
// detect remove+re-add via object identity, not just key existence.
|
|
1377
|
+
const capturedLink = link;
|
|
1378
|
+
const isStale = () => this._engineGeneration !== handlerGeneration ||
|
|
1379
|
+
!this._activeLinks.has(cursorKey) ||
|
|
1380
|
+
(capturedLink !== undefined && this._activeLinks.get(cursorKey) !== capturedLink);
|
|
1191
1381
|
const subscriptionHandler = (subMessage) => __awaiter(this, void 0, void 0, function* () {
|
|
1192
1382
|
var _a;
|
|
1193
|
-
if (
|
|
1383
|
+
if (isStale()) {
|
|
1194
1384
|
return;
|
|
1195
1385
|
}
|
|
1196
1386
|
if (subMessage.type === 'eose') {
|
|
@@ -1203,15 +1393,16 @@ export class SyncEngineLevel {
|
|
|
1203
1393
|
}
|
|
1204
1394
|
if (!ReplicationLedger.validateTokenDomain(link.pull, subMessage.cursor)) {
|
|
1205
1395
|
console.warn(`SyncEngineLevel: Token domain mismatch on EOSE for ${did} -> ${dwnUrl}, transitioning to repairing`);
|
|
1206
|
-
|
|
1396
|
+
if (!isStale()) {
|
|
1397
|
+
yield this.transitionToRepairing(cursorKey, link);
|
|
1398
|
+
}
|
|
1207
1399
|
return;
|
|
1208
1400
|
}
|
|
1209
1401
|
ReplicationLedger.setReceivedToken(link.pull, subMessage.cursor);
|
|
1210
|
-
// Drain committed entries. Do NOT unconditionally advance to the
|
|
1211
|
-
// EOSE cursor — earlier stored events may still be in-flight
|
|
1212
|
-
// (handlers are fire-and-forget). The checkpoint advances only as
|
|
1213
|
-
// far as the contiguous drain reaches.
|
|
1214
1402
|
this.drainCommittedPull(cursorKey);
|
|
1403
|
+
if (isStale()) {
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1215
1406
|
yield this.ledger.saveLink(link);
|
|
1216
1407
|
}
|
|
1217
1408
|
// Transport is reachable — set connectivity to online.
|
|
@@ -1243,7 +1434,9 @@ export class SyncEngineLevel {
|
|
|
1243
1434
|
// Domain validation: reject tokens from a different stream/epoch.
|
|
1244
1435
|
if (link && !ReplicationLedger.validateTokenDomain(link.pull, subMessage.cursor)) {
|
|
1245
1436
|
console.warn(`SyncEngineLevel: Token domain mismatch for ${did} -> ${dwnUrl}, transitioning to repairing`);
|
|
1246
|
-
|
|
1437
|
+
if (!isStale()) {
|
|
1438
|
+
yield this.transitionToRepairing(cursorKey, link);
|
|
1439
|
+
}
|
|
1247
1440
|
return;
|
|
1248
1441
|
}
|
|
1249
1442
|
// Subset scope filtering: if the link has protocolPath/contextId prefixes,
|
|
@@ -1255,9 +1448,11 @@ export class SyncEngineLevel {
|
|
|
1255
1448
|
// reconnect/repair. This is safe because the event is intentionally
|
|
1256
1449
|
// excluded from this scope and doesn't need processing.
|
|
1257
1450
|
if (link && !isEventInScope(event.message, link.scope)) {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1451
|
+
if (!isStale()) {
|
|
1452
|
+
ReplicationLedger.setReceivedToken(link.pull, subMessage.cursor);
|
|
1453
|
+
ReplicationLedger.commitContiguousToken(link.pull, subMessage.cursor);
|
|
1454
|
+
yield this.ledger.saveLink(link);
|
|
1455
|
+
}
|
|
1261
1456
|
return;
|
|
1262
1457
|
}
|
|
1263
1458
|
// Assign a delivery ordinal BEFORE async processing begins.
|
|
@@ -1285,6 +1480,9 @@ export class SyncEngineLevel {
|
|
|
1285
1480
|
}
|
|
1286
1481
|
}
|
|
1287
1482
|
yield this.agent.dwn.processRawMessage(did, event.message, { dataStream });
|
|
1483
|
+
if (isStale()) {
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1288
1486
|
// Invalidate closure cache entries that may be affected by this message.
|
|
1289
1487
|
// Must run before closure validation so subsequent evaluations in the
|
|
1290
1488
|
// same session see the updated local state.
|
|
@@ -1296,7 +1494,7 @@ export class SyncEngineLevel {
|
|
|
1296
1494
|
// For protocol-scoped links, verify that all hard dependencies for
|
|
1297
1495
|
// this operation are locally present before considering it committed.
|
|
1298
1496
|
// Full-tenant scope bypasses this entirely (returns complete with 0 queries).
|
|
1299
|
-
if (link
|
|
1497
|
+
if ((link === null || link === void 0 ? void 0 : link.scope.kind) === 'protocol') {
|
|
1300
1498
|
const messageStore = this.agent.dwn.node.storage.messageStore;
|
|
1301
1499
|
let closureCtx = this._closureContexts.get(did);
|
|
1302
1500
|
if (!closureCtx) {
|
|
@@ -1306,6 +1504,9 @@ export class SyncEngineLevel {
|
|
|
1306
1504
|
this._closureContexts.set(did, closureCtx);
|
|
1307
1505
|
}
|
|
1308
1506
|
const closureResult = yield evaluateClosure(event.message, messageStore, link.scope, closureCtx);
|
|
1507
|
+
if (isStale()) {
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1309
1510
|
if (!closureResult.complete) {
|
|
1310
1511
|
const failureCode = closureResult.failure.code;
|
|
1311
1512
|
const failureDetail = closureResult.failure.detail;
|
|
@@ -1322,7 +1523,9 @@ export class SyncEngineLevel {
|
|
|
1322
1523
|
errorCode: failureCode,
|
|
1323
1524
|
errorDetail: failureDetail,
|
|
1324
1525
|
});
|
|
1325
|
-
|
|
1526
|
+
if (!isStale()) {
|
|
1527
|
+
yield this.transitionToRepairing(cursorKey, link);
|
|
1528
|
+
}
|
|
1326
1529
|
return;
|
|
1327
1530
|
}
|
|
1328
1531
|
}
|
|
@@ -1345,7 +1548,7 @@ export class SyncEngineLevel {
|
|
|
1345
1548
|
// Guard: if the link transitioned to repairing while this handler was
|
|
1346
1549
|
// in-flight (e.g., an earlier ordinal's handler failed concurrently),
|
|
1347
1550
|
// skip all state mutations — the repair process owns progression now.
|
|
1348
|
-
if (link && rt && link.status === 'live') {
|
|
1551
|
+
if (link && rt && link.status === 'live' && !isStale()) {
|
|
1349
1552
|
const entry = rt.inflight.get(ordinal);
|
|
1350
1553
|
if (entry) {
|
|
1351
1554
|
entry.committed = true;
|
|
@@ -1396,7 +1599,7 @@ export class SyncEngineLevel {
|
|
|
1396
1599
|
// Transition to repairing immediately — do NOT advance the checkpoint
|
|
1397
1600
|
// past this failure or let later ordinals commit past it. SMT
|
|
1398
1601
|
// reconciliation will discover and fill the gap.
|
|
1399
|
-
if (link) {
|
|
1602
|
+
if (link && !isStale()) {
|
|
1400
1603
|
yield this.transitionToRepairing(cursorKey, link);
|
|
1401
1604
|
}
|
|
1402
1605
|
}
|
|
@@ -1503,9 +1706,14 @@ export class SyncEngineLevel {
|
|
|
1503
1706
|
permissionGrantId = grant.grant.id;
|
|
1504
1707
|
}
|
|
1505
1708
|
const handlerGeneration = this._engineGeneration;
|
|
1709
|
+
// Capture the link for identity-based staleness detection.
|
|
1710
|
+
const capturedPushLink = this._activeLinks.get(target.linkKey);
|
|
1711
|
+
const isPushStale = () => this._engineGeneration !== handlerGeneration ||
|
|
1712
|
+
!this._activeLinks.has(target.linkKey) ||
|
|
1713
|
+
(capturedPushLink !== undefined && this._activeLinks.get(target.linkKey) !== capturedPushLink);
|
|
1506
1714
|
// Subscribe to the local DWN's EventLog.
|
|
1507
1715
|
const subscriptionHandler = (subMessage) => __awaiter(this, void 0, void 0, function* () {
|
|
1508
|
-
if (
|
|
1716
|
+
if (isPushStale()) {
|
|
1509
1717
|
return;
|
|
1510
1718
|
}
|
|
1511
1719
|
if (subMessage.type !== 'event') {
|
|
@@ -1521,7 +1729,7 @@ export class SyncEngineLevel {
|
|
|
1521
1729
|
// Accumulate the message CID for a debounced push.
|
|
1522
1730
|
const targetKey = pushLinkKey;
|
|
1523
1731
|
const cid = yield Message.getCid(subMessage.event.message);
|
|
1524
|
-
if (cid === undefined) {
|
|
1732
|
+
if (cid === undefined || isPushStale()) {
|
|
1525
1733
|
return;
|
|
1526
1734
|
}
|
|
1527
1735
|
// Echo-loop suppression: skip CIDs that were recently pulled from this
|
|
@@ -1579,10 +1787,21 @@ export class SyncEngineLevel {
|
|
|
1579
1787
|
flushPendingPushesForLink(linkKey) {
|
|
1580
1788
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1581
1789
|
var _a, _b;
|
|
1790
|
+
// Guard: bail if this link was hot-removed. Without this, a stale
|
|
1791
|
+
// debounce timer or retry callback could send pushes after the DID
|
|
1792
|
+
// was removed.
|
|
1793
|
+
if (!this._activeLinks.has(linkKey)) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1582
1796
|
const pushRuntime = this._pushRuntimes.get(linkKey);
|
|
1583
1797
|
if (!pushRuntime) {
|
|
1584
1798
|
return;
|
|
1585
1799
|
}
|
|
1800
|
+
// Capture the current active link identity so we can detect
|
|
1801
|
+
// remove+re-add during the await pushMessages() call.
|
|
1802
|
+
const flushLink = this._activeLinks.get(linkKey);
|
|
1803
|
+
const isFlushStale = () => !this._activeLinks.has(linkKey) ||
|
|
1804
|
+
(flushLink !== undefined && this._activeLinks.get(linkKey) !== flushLink);
|
|
1586
1805
|
const { did, dwnUrl, delegateDid, protocol, entries: pushEntries, retryCount } = pushRuntime;
|
|
1587
1806
|
pushRuntime.entries = [];
|
|
1588
1807
|
if (pushEntries.length === 0) {
|
|
@@ -1600,6 +1819,11 @@ export class SyncEngineLevel {
|
|
|
1600
1819
|
agent: this.agent,
|
|
1601
1820
|
permissionsApi: this._permissionsApi,
|
|
1602
1821
|
});
|
|
1822
|
+
// If the link was replaced during pushMessages, abandon all
|
|
1823
|
+
// post-push state mutations — the replacement session owns this key.
|
|
1824
|
+
if (isFlushStale()) {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1603
1827
|
// Auto-clear dead letters for CIDs that succeeded — a previously
|
|
1604
1828
|
// failed message may have been repaired by reconciliation.
|
|
1605
1829
|
for (const cid of result.succeeded) {
|
|
@@ -1618,6 +1842,9 @@ export class SyncEngineLevel {
|
|
|
1618
1842
|
});
|
|
1619
1843
|
}
|
|
1620
1844
|
if (result.failed.length > 0) {
|
|
1845
|
+
if (isFlushStale()) {
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1621
1848
|
const failedSet = new Set(result.failed);
|
|
1622
1849
|
const failedEntries = pushEntries.filter((entry) => failedSet.has(entry.cid));
|
|
1623
1850
|
this.requeueOrReconcile(linkKey, {
|
|
@@ -1636,6 +1863,9 @@ export class SyncEngineLevel {
|
|
|
1636
1863
|
}
|
|
1637
1864
|
}
|
|
1638
1865
|
catch (error) {
|
|
1866
|
+
if (isFlushStale()) {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1639
1869
|
console.error(`SyncEngineLevel: Push batch failed for ${did} -> ${dwnUrl}`, error);
|
|
1640
1870
|
this.requeueOrReconcile(linkKey, {
|
|
1641
1871
|
did, dwnUrl, delegateDid, protocol,
|
|
@@ -1735,7 +1965,16 @@ export class SyncEngineLevel {
|
|
|
1735
1965
|
if (this._engineGeneration !== generation) {
|
|
1736
1966
|
return;
|
|
1737
1967
|
}
|
|
1738
|
-
|
|
1968
|
+
// Guard: bail if this link was hot-removed since the timer was
|
|
1969
|
+
// scheduled. Without this, a stale timer could restart reconcile
|
|
1970
|
+
// work for a DID that is no longer active.
|
|
1971
|
+
if (!this._activeLinks.has(linkKey)) {
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
void this.reconcileLink(linkKey).catch(() => {
|
|
1975
|
+
// Errors are already logged inside doReconcileLink; swallow here
|
|
1976
|
+
// to prevent unhandled-rejection flakes in the test runner.
|
|
1977
|
+
});
|
|
1739
1978
|
}, delayMs);
|
|
1740
1979
|
this._reconcileTimers.set(linkKey, timer);
|
|
1741
1980
|
}
|
|
@@ -1776,10 +2015,14 @@ export class SyncEngineLevel {
|
|
|
1776
2015
|
return;
|
|
1777
2016
|
}
|
|
1778
2017
|
const generation = this._engineGeneration;
|
|
2018
|
+
// Identity guard: if the DID was hot-removed and re-added, this
|
|
2019
|
+
// closure's captured `link` reference may no longer be the active
|
|
2020
|
+
// link object. Bail before mutating the replacement's state.
|
|
2021
|
+
const isStaleLink = () => this._activeLinks.get(linkKey) !== link;
|
|
1779
2022
|
const { tenantDid: did, remoteEndpoint: dwnUrl, delegateDid, protocol } = link;
|
|
1780
2023
|
try {
|
|
1781
|
-
const reconcileOutcome = yield this.createLinkReconciler(() => this._engineGeneration === generation).reconcile({ did, dwnUrl, delegateDid, protocol }, { verifyConvergence: true });
|
|
1782
|
-
if (reconcileOutcome.aborted) {
|
|
2024
|
+
const reconcileOutcome = yield this.createLinkReconciler(() => this._engineGeneration === generation && !isStaleLink()).reconcile({ did, dwnUrl, delegateDid, protocol }, { verifyConvergence: true });
|
|
2025
|
+
if (reconcileOutcome.aborted || isStaleLink()) {
|
|
1783
2026
|
return;
|
|
1784
2027
|
}
|
|
1785
2028
|
if (reconcileOutcome.converged) {
|
|
@@ -1793,10 +2036,15 @@ export class SyncEngineLevel {
|
|
|
1793
2036
|
// Roots still differ — retry after a delay. This can happen when
|
|
1794
2037
|
// pushMessages() had permanent failures, pullMessages() partially
|
|
1795
2038
|
// failed, or new writes arrived during reconciliation.
|
|
1796
|
-
|
|
2039
|
+
if (!isStaleLink()) {
|
|
2040
|
+
this.scheduleReconcile(linkKey, 5000);
|
|
2041
|
+
}
|
|
1797
2042
|
}
|
|
1798
2043
|
}
|
|
1799
2044
|
catch (error) {
|
|
2045
|
+
if (isStaleLink()) {
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
1800
2048
|
console.error(`SyncEngineLevel: Reconciliation failed for ${did} -> ${dwnUrl}`, error);
|
|
1801
2049
|
// Schedule retry with longer delay.
|
|
1802
2050
|
this.scheduleReconcile(linkKey, 5000);
|
|
@@ -1978,9 +2226,9 @@ export class SyncEngineLevel {
|
|
|
1978
2226
|
var _a;
|
|
1979
2227
|
const si = this.stateIndex;
|
|
1980
2228
|
if (si) {
|
|
1981
|
-
const rootHash = protocol
|
|
1982
|
-
? yield si.
|
|
1983
|
-
: yield si.
|
|
2229
|
+
const rootHash = protocol === undefined
|
|
2230
|
+
? yield si.getRoot(did)
|
|
2231
|
+
: yield si.getProtocolRoot(did, protocol);
|
|
1984
2232
|
return hashToHex(rootHash);
|
|
1985
2233
|
}
|
|
1986
2234
|
// Remote mode fallback: go through processRequest → RPC.
|
|
@@ -2106,9 +2354,9 @@ export class SyncEngineLevel {
|
|
|
2106
2354
|
if (si) {
|
|
2107
2355
|
// Fast path: direct StateIndex access (local mode).
|
|
2108
2356
|
const bitPath = SyncEngineLevel.parseBitPrefix(prefix);
|
|
2109
|
-
const hash = protocol
|
|
2110
|
-
? yield si.
|
|
2111
|
-
: yield si.
|
|
2357
|
+
const hash = protocol === undefined
|
|
2358
|
+
? yield si.getSubtreeHash(did, bitPath)
|
|
2359
|
+
: yield si.getProtocolSubtreeHash(did, protocol, bitPath);
|
|
2112
2360
|
hexHash = hashToHex(hash);
|
|
2113
2361
|
}
|
|
2114
2362
|
else {
|
|
@@ -2145,9 +2393,9 @@ export class SyncEngineLevel {
|
|
|
2145
2393
|
const si = this.stateIndex;
|
|
2146
2394
|
if (si) {
|
|
2147
2395
|
const bitPath = SyncEngineLevel.parseBitPrefix(prefix);
|
|
2148
|
-
const hash = protocol
|
|
2149
|
-
? yield si.
|
|
2150
|
-
: yield si.
|
|
2396
|
+
const hash = protocol === undefined
|
|
2397
|
+
? yield si.getSubtreeHash(did, bitPath)
|
|
2398
|
+
: yield si.getProtocolSubtreeHash(did, protocol, bitPath);
|
|
2151
2399
|
return hashToHex(hash);
|
|
2152
2400
|
}
|
|
2153
2401
|
// Remote mode fallback.
|
|
@@ -2179,9 +2427,9 @@ export class SyncEngineLevel {
|
|
|
2179
2427
|
const si = this.stateIndex;
|
|
2180
2428
|
if (si) {
|
|
2181
2429
|
const bitPath = SyncEngineLevel.parseBitPrefix(prefix);
|
|
2182
|
-
return protocol
|
|
2183
|
-
? yield si.
|
|
2184
|
-
: yield si.
|
|
2430
|
+
return protocol === undefined
|
|
2431
|
+
? yield si.getLeaves(did, bitPath)
|
|
2432
|
+
: yield si.getProtocolLeaves(did, protocol, bitPath);
|
|
2185
2433
|
}
|
|
2186
2434
|
// Remote mode fallback.
|
|
2187
2435
|
const response = yield this.agent.dwn.processRequest({
|
|
@@ -2515,31 +2763,47 @@ export class SyncEngineLevel {
|
|
|
2515
2763
|
// ---------------------------------------------------------------------------
|
|
2516
2764
|
/**
|
|
2517
2765
|
* Returns the list of sync targets: (did, dwnUrl, delegateDid?, protocol?) tuples.
|
|
2766
|
+
* Results are cached for up to 30 seconds to avoid redundant DID resolution
|
|
2767
|
+
* on every sync tick. The cache is invalidated when identities are registered,
|
|
2768
|
+
* unregistered, or updated.
|
|
2518
2769
|
*/
|
|
2519
2770
|
getSyncTargets() {
|
|
2520
2771
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2521
2772
|
var _a, e_6, _b, _c;
|
|
2773
|
+
// Return cached targets if still valid.
|
|
2774
|
+
if (this._syncTargetsCache
|
|
2775
|
+
&& (Date.now() - this._syncTargetsCache.timestamp) < SyncEngineLevel.SYNC_TARGETS_CACHE_TTL_MS) {
|
|
2776
|
+
return this._syncTargetsCache.targets;
|
|
2777
|
+
}
|
|
2778
|
+
// Capture the generation before any async work so we can detect
|
|
2779
|
+
// concurrent invalidations (register/unregister/update) that would
|
|
2780
|
+
// make our result stale.
|
|
2781
|
+
const generationAtStart = this._syncTargetsCacheGeneration;
|
|
2522
2782
|
const targets = [];
|
|
2783
|
+
let hasRegisteredIdentities = false;
|
|
2784
|
+
let anyEndpointMissing = false;
|
|
2523
2785
|
try {
|
|
2524
2786
|
for (var _d = true, _e = __asyncValues(this._db.sublevel('registeredIdentities').iterator()), _f; _f = yield _e.next(), _a = _f.done, !_a; _d = true) {
|
|
2525
2787
|
_c = _f.value;
|
|
2526
2788
|
_d = false;
|
|
2527
2789
|
const [did, options] = _c;
|
|
2790
|
+
hasRegisteredIdentities = true;
|
|
2528
2791
|
let parsed;
|
|
2529
2792
|
try {
|
|
2530
2793
|
parsed = JSON.parse(options);
|
|
2531
2794
|
}
|
|
2532
2795
|
catch (error) {
|
|
2533
|
-
console.warn(`SyncEngineLevel: Corrupt sync options for ${did},
|
|
2534
|
-
|
|
2796
|
+
console.warn(`SyncEngineLevel: Corrupt sync options for ${did}, skipping identity:`, error);
|
|
2797
|
+
continue;
|
|
2535
2798
|
}
|
|
2536
2799
|
const { protocols, delegateDid } = parsed;
|
|
2537
2800
|
const dwnEndpointUrls = yield this.agent.dwn.getDwnEndpointUrlsForTarget(did);
|
|
2538
2801
|
if (dwnEndpointUrls.length === 0) {
|
|
2802
|
+
anyEndpointMissing = true;
|
|
2539
2803
|
continue;
|
|
2540
2804
|
}
|
|
2541
2805
|
for (const dwnUrl of dwnEndpointUrls) {
|
|
2542
|
-
if (protocols
|
|
2806
|
+
if (protocols === 'all') {
|
|
2543
2807
|
// Sync all protocols (global tree).
|
|
2544
2808
|
targets.push({ did, delegateDid, dwnUrl });
|
|
2545
2809
|
}
|
|
@@ -2558,6 +2822,17 @@ export class SyncEngineLevel {
|
|
|
2558
2822
|
}
|
|
2559
2823
|
finally { if (e_6) throw e_6.error; }
|
|
2560
2824
|
}
|
|
2825
|
+
// Only cache when:
|
|
2826
|
+
// - The result is non-empty (empty = transient resolution failure).
|
|
2827
|
+
// - All registered identities resolved successfully (partial =
|
|
2828
|
+
// one identity's endpoints failed transiently; caching would
|
|
2829
|
+
// suppress retries for that identity for the full TTL).
|
|
2830
|
+
// - The generation hasn't changed (a concurrent register/unregister
|
|
2831
|
+
// invalidated the cache while we were awaiting).
|
|
2832
|
+
const isComplete = hasRegisteredIdentities && !anyEndpointMissing;
|
|
2833
|
+
if (targets.length > 0 && isComplete && this._syncTargetsCacheGeneration === generationAtStart) {
|
|
2834
|
+
this._syncTargetsCache = { targets, timestamp: Date.now() };
|
|
2835
|
+
}
|
|
2561
2836
|
return targets;
|
|
2562
2837
|
});
|
|
2563
2838
|
}
|
|
@@ -2585,6 +2860,8 @@ export class SyncEngineLevel {
|
|
|
2585
2860
|
SyncEngineLevel.ECHO_SUPPRESS_TTL_MS = 60000;
|
|
2586
2861
|
/** Maximum entries in the echo-loop suppression cache. */
|
|
2587
2862
|
SyncEngineLevel.ECHO_SUPPRESS_MAX_ENTRIES = 10000;
|
|
2863
|
+
/** TTL for the sync targets cache (30 seconds). */
|
|
2864
|
+
SyncEngineLevel.SYNC_TARGETS_CACHE_TTL_MS = 30000;
|
|
2588
2865
|
/** Maximum consecutive failures before entering backoff. */
|
|
2589
2866
|
SyncEngineLevel.MAX_CONSECUTIVE_FAILURES = 5;
|
|
2590
2867
|
/** Backoff multiplier for consecutive failures (caps at 4x the configured interval). */
|