@enbox/agent 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +18 -5
  2. package/dist/browser.mjs +11 -11
  3. package/dist/browser.mjs.map +4 -4
  4. package/dist/esm/agent-did-resolver-cache.js +5 -5
  5. package/dist/esm/agent-did-resolver-cache.js.map +1 -1
  6. package/dist/esm/crypto-api.js.map +1 -1
  7. package/dist/esm/did-api.js +1 -1
  8. package/dist/esm/did-api.js.map +1 -1
  9. package/dist/esm/dwn-api.js +93 -53
  10. package/dist/esm/dwn-api.js.map +1 -1
  11. package/dist/esm/dwn-discovery-payload.js +7 -4
  12. package/dist/esm/dwn-discovery-payload.js.map +1 -1
  13. package/dist/esm/dwn-key-delivery.js +8 -3
  14. package/dist/esm/dwn-key-delivery.js.map +1 -1
  15. package/dist/esm/enbox-connect-protocol.js +34 -14
  16. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  17. package/dist/esm/enbox-user-agent.js +11 -3
  18. package/dist/esm/enbox-user-agent.js.map +1 -1
  19. package/dist/esm/hd-identity-vault.js +33 -18
  20. package/dist/esm/hd-identity-vault.js.map +1 -1
  21. package/dist/esm/identity-api.js +5 -4
  22. package/dist/esm/identity-api.js.map +1 -1
  23. package/dist/esm/index.js +1 -0
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/local-dwn.js.map +1 -1
  26. package/dist/esm/local-key-manager.js.map +1 -1
  27. package/dist/esm/permissions-api.js +9 -5
  28. package/dist/esm/permissions-api.js.map +1 -1
  29. package/dist/esm/prototyping/crypto/jose/jwe-flattened.js +9 -9
  30. package/dist/esm/prototyping/crypto/jose/jwe-flattened.js.map +1 -1
  31. package/dist/esm/secret-store.js +106 -0
  32. package/dist/esm/secret-store.js.map +1 -0
  33. package/dist/esm/store-data.js +32 -11
  34. package/dist/esm/store-data.js.map +1 -1
  35. package/dist/esm/sync-closure-resolver.js +1 -1
  36. package/dist/esm/sync-closure-resolver.js.map +1 -1
  37. package/dist/esm/sync-engine-level.js +418 -141
  38. package/dist/esm/sync-engine-level.js.map +1 -1
  39. package/dist/esm/sync-replication-ledger.js +25 -0
  40. package/dist/esm/sync-replication-ledger.js.map +1 -1
  41. package/dist/esm/test-harness.js +32 -5
  42. package/dist/esm/test-harness.js.map +1 -1
  43. package/dist/esm/types/sync.js +9 -3
  44. package/dist/esm/types/sync.js.map +1 -1
  45. package/dist/esm/utils.js.map +1 -1
  46. package/dist/types/agent-did-resolver-cache.d.ts +1 -1
  47. package/dist/types/agent-did-resolver-cache.d.ts.map +1 -1
  48. package/dist/types/anonymous-dwn-api.d.ts +2 -2
  49. package/dist/types/anonymous-dwn-api.d.ts.map +1 -1
  50. package/dist/types/crypto-api.d.ts +1 -1
  51. package/dist/types/crypto-api.d.ts.map +1 -1
  52. package/dist/types/did-api.d.ts +2 -2
  53. package/dist/types/did-api.d.ts.map +1 -1
  54. package/dist/types/dwn-api.d.ts +51 -11
  55. package/dist/types/dwn-api.d.ts.map +1 -1
  56. package/dist/types/dwn-key-delivery.d.ts +4 -1
  57. package/dist/types/dwn-key-delivery.d.ts.map +1 -1
  58. package/dist/types/enbox-connect-protocol.d.ts +3 -2
  59. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  60. package/dist/types/enbox-user-agent.d.ts +5 -1
  61. package/dist/types/enbox-user-agent.d.ts.map +1 -1
  62. package/dist/types/hd-identity-vault.d.ts +9 -2
  63. package/dist/types/hd-identity-vault.d.ts.map +1 -1
  64. package/dist/types/identity-api.d.ts +1 -1
  65. package/dist/types/identity-api.d.ts.map +1 -1
  66. package/dist/types/index.d.ts +1 -0
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/dist/types/local-dwn.d.ts +3 -3
  69. package/dist/types/local-dwn.d.ts.map +1 -1
  70. package/dist/types/local-key-manager.d.ts +2 -2
  71. package/dist/types/local-key-manager.d.ts.map +1 -1
  72. package/dist/types/permissions-api.d.ts +1 -1
  73. package/dist/types/permissions-api.d.ts.map +1 -1
  74. package/dist/types/secret-store.d.ts +81 -0
  75. package/dist/types/secret-store.d.ts.map +1 -0
  76. package/dist/types/store-data.d.ts +15 -3
  77. package/dist/types/store-data.d.ts.map +1 -1
  78. package/dist/types/sync-engine-level.d.ts +52 -16
  79. package/dist/types/sync-engine-level.d.ts.map +1 -1
  80. package/dist/types/sync-replication-ledger.d.ts +10 -1
  81. package/dist/types/sync-replication-ledger.d.ts.map +1 -1
  82. package/dist/types/test-harness.d.ts +3 -0
  83. package/dist/types/test-harness.d.ts.map +1 -1
  84. package/dist/types/types/agent.d.ts +3 -0
  85. package/dist/types/types/agent.d.ts.map +1 -1
  86. package/dist/types/types/sync.d.ts +27 -4
  87. package/dist/types/types/sync.d.ts.map +1 -1
  88. package/package.json +3 -3
  89. package/src/agent-did-resolver-cache.ts +5 -5
  90. package/src/anonymous-dwn-api.ts +2 -2
  91. package/src/crypto-api.ts +1 -1
  92. package/src/did-api.ts +3 -3
  93. package/src/dwn-api.ts +107 -69
  94. package/src/dwn-discovery-payload.ts +5 -4
  95. package/src/dwn-key-delivery.ts +8 -2
  96. package/src/enbox-connect-protocol.ts +38 -21
  97. package/src/enbox-user-agent.ts +15 -3
  98. package/src/hd-identity-vault.ts +47 -21
  99. package/src/identity-api.ts +6 -5
  100. package/src/index.ts +1 -0
  101. package/src/local-dwn.ts +3 -3
  102. package/src/local-key-manager.ts +2 -2
  103. package/src/permissions-api.ts +12 -8
  104. package/src/prototyping/crypto/jose/jwe-flattened.ts +8 -8
  105. package/src/secret-store.ts +173 -0
  106. package/src/store-data.ts +40 -14
  107. package/src/sync-closure-resolver.ts +2 -2
  108. package/src/sync-engine-level.ts +423 -162
  109. package/src/sync-replication-ledger.ts +26 -1
  110. package/src/test-harness.ts +40 -5
  111. package/src/types/agent.ts +3 -0
  112. package/src/types/sync.ts +35 -7
  113. 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((target) => __awaiter(this, void 0, void 0, function* () {
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 || !entry.committed) {
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 (!currentLink || currentLink.status !== 'repairing') {
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
- } // Teardown occurred.
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, don't retry or enter degraded_poll.
865
- if (this._engineGeneration !== generation) {
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 jitter = Math.floor(Math.random() * 15000);
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
- // If the link was transitioned out of degraded_poll externally (e.g.,
942
- // by teardown or manual intervention), stop polling.
943
- if (link.status !== 'degraded_poll') {
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(link, 'repairing');
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 (link.status === 'live') {
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
- yield this.ledger.setStatus(link, 'degraded_poll');
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 (this._engineGeneration !== handlerGeneration) {
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
- yield this.transitionToRepairing(cursorKey, link);
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
- yield this.transitionToRepairing(cursorKey, link);
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
- ReplicationLedger.setReceivedToken(link.pull, subMessage.cursor);
1259
- ReplicationLedger.commitContiguousToken(link.pull, subMessage.cursor);
1260
- yield this.ledger.saveLink(link);
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 && link.scope.kind === 'protocol') {
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
- yield this.transitionToRepairing(cursorKey, link);
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 (this._engineGeneration !== handlerGeneration) {
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
- void this.reconcileLink(linkKey);
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
- this.scheduleReconcile(linkKey, 5000);
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 !== undefined
1982
- ? yield si.getProtocolRoot(did, protocol)
1983
- : yield si.getRoot(did);
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 !== undefined
2110
- ? yield si.getProtocolSubtreeHash(did, protocol, bitPath)
2111
- : yield si.getSubtreeHash(did, bitPath);
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 !== undefined
2149
- ? yield si.getProtocolSubtreeHash(did, protocol, bitPath)
2150
- : yield si.getSubtreeHash(did, bitPath);
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 !== undefined
2183
- ? yield si.getProtocolLeaves(did, protocol, bitPath)
2184
- : yield si.getLeaves(did, bitPath);
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}, falling back to global sync:`, error);
2534
- parsed = { protocols: [] };
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.length === 0) {
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). */