@enbox/agent 0.5.12 → 0.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.mjs +9 -9
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/sync-engine-level.js +470 -301
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-link-id.js +20 -0
- package/dist/esm/sync-link-id.js.map +1 -0
- package/dist/esm/sync-link-reconciler.js +106 -0
- package/dist/esm/sync-link-reconciler.js.map +1 -0
- package/dist/esm/sync-replication-ledger.js +28 -1
- package/dist/esm/sync-replication-ledger.js.map +1 -1
- package/dist/types/sync-engine-level.d.ts +53 -10
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-link-id.d.ts +17 -0
- package/dist/types/sync-link-id.d.ts.map +1 -0
- package/dist/types/sync-link-reconciler.d.ts +57 -0
- package/dist/types/sync-link-reconciler.d.ts.map +1 -0
- package/dist/types/sync-replication-ledger.d.ts +9 -0
- package/dist/types/sync-replication-ledger.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +14 -5
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/sync-engine-level.ts +499 -324
- package/src/sync-link-id.ts +24 -0
- package/src/sync-link-reconciler.ts +155 -0
- package/src/sync-replication-ledger.ts +27 -1
- package/src/types/sync.ts +9 -3
package/src/sync-engine-level.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { createClosureContext, invalidateClosureCache } from './sync-closure-typ
|
|
|
21
21
|
import { AgentPermissionsApi } from './permissions-api.js';
|
|
22
22
|
import { DwnInterface } from './types/dwn.js';
|
|
23
23
|
import { isRecordsWrite } from './utils.js';
|
|
24
|
+
import { SyncLinkReconciler } from './sync-link-reconciler.js';
|
|
24
25
|
import { topologicalSort } from './sync-topological-sort.js';
|
|
26
|
+
import { buildLegacyCursorKey, buildLinkId } from './sync-link-id.js';
|
|
25
27
|
import { fetchRemoteMessages, pullMessages, pushMessages } from './sync-messages.js';
|
|
26
28
|
|
|
27
29
|
export type SyncEngineLevelParams = {
|
|
@@ -46,20 +48,16 @@ const MAX_DIFF_DEPTH = 16;
|
|
|
46
48
|
const BATCHED_DIFF_DEPTH = 8;
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
const CURSOR_SEPARATOR = '^';
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Debounce window for push-on-write. When the local EventLog emits events,
|
|
57
|
-
* we batch them and push after this delay to avoid a push per individual write.
|
|
51
|
+
* Debounce window for batching writes that arrive while a push is in flight.
|
|
52
|
+
* The first write in a quiet window triggers an immediate push; subsequent
|
|
53
|
+
* writes arriving during the push are batched and flushed after this delay
|
|
54
|
+
* once the in-flight push completes.
|
|
58
55
|
*/
|
|
59
|
-
const PUSH_DEBOUNCE_MS =
|
|
56
|
+
const PUSH_DEBOUNCE_MS = 100;
|
|
60
57
|
|
|
61
58
|
/** Tracks a live subscription to a remote DWN for one sync target. */
|
|
62
59
|
type LiveSubscription = {
|
|
60
|
+
linkKey: string;
|
|
63
61
|
did: string;
|
|
64
62
|
dwnUrl: string;
|
|
65
63
|
delegateDid?: string;
|
|
@@ -69,6 +67,7 @@ type LiveSubscription = {
|
|
|
69
67
|
|
|
70
68
|
/** Tracks a local EventLog subscription for push-on-write. */
|
|
71
69
|
type LocalSubscription = {
|
|
70
|
+
linkKey: string;
|
|
72
71
|
did: string;
|
|
73
72
|
dwnUrl: string;
|
|
74
73
|
delegateDid?: string;
|
|
@@ -150,6 +149,18 @@ type LinkRuntimeState = {
|
|
|
150
149
|
inflight: Map<number, InFlightCommit>;
|
|
151
150
|
};
|
|
152
151
|
|
|
152
|
+
type PushRuntimeState = {
|
|
153
|
+
did: string;
|
|
154
|
+
dwnUrl: string;
|
|
155
|
+
delegateDid?: string;
|
|
156
|
+
protocol?: string;
|
|
157
|
+
entries: { cid: string }[];
|
|
158
|
+
retryCount: number;
|
|
159
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
160
|
+
/** True while a push HTTP request is in flight for this link. */
|
|
161
|
+
flushing?: boolean;
|
|
162
|
+
};
|
|
163
|
+
|
|
153
164
|
export class SyncEngineLevel implements SyncEngine {
|
|
154
165
|
/**
|
|
155
166
|
* Holds the instance of a `EnboxPlatformAgent` that represents the current execution context for
|
|
@@ -170,8 +181,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
170
181
|
|
|
171
182
|
/**
|
|
172
183
|
* Durable replication ledger — persists per-link checkpoint state.
|
|
173
|
-
* Used by live sync to track pull
|
|
174
|
-
* Poll-mode sync still uses the legacy `getCursor`/`setCursor` path.
|
|
184
|
+
* Used by live sync to track pull progression per link.
|
|
175
185
|
* Lazily initialized on first use to avoid sublevel() calls on mock dbs.
|
|
176
186
|
*/
|
|
177
187
|
private _ledger?: ReplicationLedger;
|
|
@@ -211,7 +221,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
211
221
|
* and bail if it has changed — this prevents stale work from mutating
|
|
212
222
|
* state after teardown or mode switch.
|
|
213
223
|
*/
|
|
214
|
-
private
|
|
224
|
+
private _engineGeneration = 0;
|
|
215
225
|
|
|
216
226
|
/** Active live pull subscriptions (remote -> local via MessagesSubscribe). */
|
|
217
227
|
private _liveSubscriptions: LiveSubscription[] = [];
|
|
@@ -222,17 +232,11 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
222
232
|
/** Connectivity state derived from subscription health. */
|
|
223
233
|
private _connectivityState: SyncConnectivityState = 'unknown';
|
|
224
234
|
|
|
225
|
-
/** Debounce timer for batched push-on-write. */
|
|
226
|
-
private _pushDebounceTimer?: ReturnType<typeof setTimeout>;
|
|
227
|
-
|
|
228
235
|
/** Registered event listeners for observability. */
|
|
229
236
|
private _eventListeners: Set<SyncEventListener> = new Set();
|
|
230
237
|
|
|
231
|
-
/**
|
|
232
|
-
private
|
|
233
|
-
did: string; dwnUrl: string; delegateDid?: string; protocol?: string;
|
|
234
|
-
entries: { cid: string; localToken?: ProgressToken }[];
|
|
235
|
-
}> = new Map();
|
|
238
|
+
/** Per-link push runtime: queue, debounce timer, retry state. */
|
|
239
|
+
private _pushRuntimes: Map<string, PushRuntimeState> = new Map();
|
|
236
240
|
|
|
237
241
|
/**
|
|
238
242
|
* CIDs recently received via pull subscription, keyed by `cid|dwnUrl` to
|
|
@@ -334,11 +338,13 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
334
338
|
}
|
|
335
339
|
|
|
336
340
|
public async clear(): Promise<void> {
|
|
341
|
+
await this.teardownLiveSync();
|
|
337
342
|
await this._permissionsApi.clear();
|
|
338
343
|
await this._db.clear();
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
public async close(): Promise<void> {
|
|
347
|
+
await this.teardownLiveSync();
|
|
342
348
|
await this._db.close();
|
|
343
349
|
}
|
|
344
350
|
|
|
@@ -405,98 +411,61 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
405
411
|
|
|
406
412
|
this._syncLock = true;
|
|
407
413
|
try {
|
|
408
|
-
//
|
|
414
|
+
// Group targets by remote endpoint so each URL group can be reconciled
|
|
415
|
+
// concurrently. Within a group, targets are processed sequentially so
|
|
416
|
+
// that a single network failure skips the rest of that group.
|
|
409
417
|
const syncTargets = await this.getSyncTargets();
|
|
410
|
-
const
|
|
411
|
-
let hadFailure = false;
|
|
412
|
-
|
|
418
|
+
const byUrl = new Map<string, typeof syncTargets>();
|
|
413
419
|
for (const target of syncTargets) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
420
|
+
let group = byUrl.get(target.dwnUrl);
|
|
421
|
+
if (!group) {
|
|
422
|
+
group = [];
|
|
423
|
+
byUrl.set(target.dwnUrl, group);
|
|
418
424
|
}
|
|
425
|
+
group.push(target);
|
|
426
|
+
}
|
|
419
427
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const localRoot = await this.getLocalRoot(did, delegateDid, protocol);
|
|
423
|
-
const remoteRoot = await this.getRemoteRoot(did, dwnUrl, delegateDid, protocol);
|
|
424
|
-
|
|
425
|
-
if (localRoot === remoteRoot) {
|
|
426
|
-
// Trees are identical — nothing to sync for this target.
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
428
|
+
let groupsSucceeded = 0;
|
|
429
|
+
let groupsFailed = 0;
|
|
429
430
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
// Separate entries into three categories:
|
|
443
|
-
// 1. Fully prefetched: have message + inline data (or no data needed)
|
|
444
|
-
// 2. Need data fetch: have message but missing data for RecordsWrite
|
|
445
|
-
// 3. Need full fetch: no message at all
|
|
446
|
-
const prefetched: (MessagesSyncDiffEntry & { message: GenericMessage })[] = [];
|
|
447
|
-
const needsFetchCids: string[] = [];
|
|
448
|
-
|
|
449
|
-
for (const entry of diff.onlyRemote) {
|
|
450
|
-
if (!entry.message) {
|
|
451
|
-
// No message at all — need full fetch.
|
|
452
|
-
needsFetchCids.push(entry.messageCid);
|
|
453
|
-
} else if (
|
|
454
|
-
entry.message.descriptor.interface === 'Records' &&
|
|
455
|
-
entry.message.descriptor.method === 'Write' &&
|
|
456
|
-
(entry.message.descriptor as any).dataCid &&
|
|
457
|
-
!entry.encodedData
|
|
458
|
-
) {
|
|
459
|
-
// RecordsWrite with data but data wasn't inlined (too large).
|
|
460
|
-
// Need to fetch individually to get the data stream.
|
|
461
|
-
needsFetchCids.push(entry.messageCid);
|
|
462
|
-
} else {
|
|
463
|
-
// Fully prefetched (message + data or no data needed).
|
|
464
|
-
prefetched.push(entry as MessagesSyncDiffEntry & { message: GenericMessage });
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
await this.pullMessages({
|
|
468
|
-
did, dwnUrl, delegateDid, protocol,
|
|
469
|
-
messageCids: needsFetchCids,
|
|
470
|
-
prefetched,
|
|
471
|
-
});
|
|
472
|
-
}
|
|
431
|
+
const results = await Promise.allSettled([...byUrl.entries()].map(async ([dwnUrl, targets]) => {
|
|
432
|
+
for (const target of targets) {
|
|
433
|
+
const { did, delegateDid, protocol } = target;
|
|
434
|
+
try {
|
|
435
|
+
await this.createLinkReconciler().reconcile({
|
|
436
|
+
did, dwnUrl, delegateDid, protocol,
|
|
437
|
+
}, { direction });
|
|
438
|
+
} catch (error: any) {
|
|
439
|
+
// Skip remaining targets for this DWN endpoint.
|
|
440
|
+
groupsFailed++;
|
|
441
|
+
console.error(`SyncEngineLevel: Error syncing ${did} with ${dwnUrl}`, error);
|
|
442
|
+
return;
|
|
473
443
|
}
|
|
444
|
+
}
|
|
445
|
+
groupsSucceeded++;
|
|
446
|
+
}));
|
|
474
447
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
} catch (error: any) {
|
|
482
|
-
// Skip this DWN endpoint for remaining targets and log the real cause.
|
|
483
|
-
errored.add(dwnUrl);
|
|
484
|
-
hadFailure = true;
|
|
485
|
-
console.error(`SyncEngineLevel: Error syncing ${did} with ${dwnUrl}`, error);
|
|
448
|
+
// Check for unexpected rejections (should not happen given inner try/catch).
|
|
449
|
+
for (const result of results) {
|
|
450
|
+
if (result.status === 'rejected') {
|
|
451
|
+
groupsFailed++;
|
|
486
452
|
}
|
|
487
453
|
}
|
|
488
454
|
|
|
489
|
-
// Track
|
|
490
|
-
|
|
455
|
+
// Track connectivity based on per-group outcomes. If at least one
|
|
456
|
+
// group succeeded, stay online — partial reachability is still online.
|
|
457
|
+
if (groupsSucceeded > 0) {
|
|
458
|
+
this._consecutiveFailures = 0;
|
|
459
|
+
this._connectivityState = 'online';
|
|
460
|
+
} else if (groupsFailed > 0) {
|
|
491
461
|
this._consecutiveFailures++;
|
|
492
462
|
if (this._connectivityState === 'online') {
|
|
493
463
|
this._connectivityState = 'offline';
|
|
494
464
|
}
|
|
495
|
-
} else {
|
|
465
|
+
} else if (syncTargets.length > 0) {
|
|
466
|
+
// All targets had matching roots (no reconciliation needed).
|
|
496
467
|
this._consecutiveFailures = 0;
|
|
497
|
-
|
|
498
|
-
this._connectivityState = 'online';
|
|
499
|
-
}
|
|
468
|
+
this._connectivityState = 'online';
|
|
500
469
|
}
|
|
501
470
|
} finally {
|
|
502
471
|
this._syncLock = false;
|
|
@@ -535,6 +504,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
535
504
|
* and tearing down any live subscriptions.
|
|
536
505
|
*/
|
|
537
506
|
public async stopSync(timeout: number = 2000): Promise<void> {
|
|
507
|
+
this._engineGeneration++;
|
|
538
508
|
let elapsedTimeout = 0;
|
|
539
509
|
|
|
540
510
|
while (this._syncLock) {
|
|
@@ -559,7 +529,9 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
559
529
|
// ---------------------------------------------------------------------------
|
|
560
530
|
|
|
561
531
|
private async startPollSync(intervalMilliseconds: number): Promise<void> {
|
|
532
|
+
const generation = this._engineGeneration;
|
|
562
533
|
const intervalSync = async (): Promise<void> => {
|
|
534
|
+
if (this._engineGeneration !== generation) { return; }
|
|
563
535
|
if (this._syncLock) {
|
|
564
536
|
return;
|
|
565
537
|
}
|
|
@@ -582,6 +554,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
582
554
|
? intervalMilliseconds * backoffMultiplier
|
|
583
555
|
: intervalMilliseconds;
|
|
584
556
|
|
|
557
|
+
if (this._engineGeneration !== generation) { return; }
|
|
585
558
|
if (!this._syncIntervalId) {
|
|
586
559
|
this._syncIntervalId = setInterval(intervalSync, effectiveInterval);
|
|
587
560
|
}
|
|
@@ -619,8 +592,9 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
619
592
|
}
|
|
620
593
|
|
|
621
594
|
// Step 2: Initialize replication links and open live subscriptions.
|
|
595
|
+
// Each target's link initialization is independent — process concurrently.
|
|
622
596
|
const syncTargets = await this.getSyncTargets();
|
|
623
|
-
|
|
597
|
+
await Promise.allSettled(syncTargets.map(async (target) => {
|
|
624
598
|
let link: ReplicationLinkState | undefined;
|
|
625
599
|
try {
|
|
626
600
|
// Get or create the link in the durable ledger.
|
|
@@ -637,20 +611,34 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
637
611
|
});
|
|
638
612
|
|
|
639
613
|
// Cache the link for fast access by subscription handlers.
|
|
640
|
-
|
|
614
|
+
// Use scopeId from the link for consistent runtime identity.
|
|
615
|
+
const linkKey = this.buildLinkKey(target.did, target.dwnUrl, link.scopeId);
|
|
616
|
+
|
|
617
|
+
// One-time migration: if the link has no pull checkpoint, check for
|
|
618
|
+
// a legacy cursor in the old syncCursors sublevel. The legacy key
|
|
619
|
+
// used protocol, not scopeId, so we must build it the old way.
|
|
620
|
+
if (!link.pull.contiguousAppliedToken) {
|
|
621
|
+
const legacyKey = buildLegacyCursorKey(target.did, target.dwnUrl, target.protocol);
|
|
622
|
+
const legacyCursor = await this.getCursor(legacyKey);
|
|
623
|
+
if (legacyCursor) {
|
|
624
|
+
ReplicationLedger.resetCheckpoint(link.pull, legacyCursor);
|
|
625
|
+
await this.ledger.saveLink(link);
|
|
626
|
+
await this.deleteLegacyCursor(legacyKey);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
641
630
|
this._activeLinks.set(linkKey, link);
|
|
642
631
|
|
|
643
632
|
// Open subscriptions — only transition to live if both succeed.
|
|
644
633
|
// If pull succeeds but push fails, close the pull subscription to
|
|
645
634
|
// avoid a resource leak with inconsistent state.
|
|
646
|
-
|
|
635
|
+
const targetWithKey = { ...target, linkKey };
|
|
636
|
+
await this.openLivePullSubscription(targetWithKey);
|
|
647
637
|
try {
|
|
648
|
-
await this.openLocalPushSubscription(
|
|
638
|
+
await this.openLocalPushSubscription(targetWithKey);
|
|
649
639
|
} catch (pushError) {
|
|
650
640
|
// Close the already-opened pull subscription.
|
|
651
|
-
const pullSub = this._liveSubscriptions.find(
|
|
652
|
-
s => s.did === target.did && s.dwnUrl === target.dwnUrl && s.protocol === target.protocol
|
|
653
|
-
);
|
|
641
|
+
const pullSub = this._liveSubscriptions.find((s) => s.linkKey === linkKey);
|
|
654
642
|
if (pullSub) {
|
|
655
643
|
try { await pullSub.close(); } catch { /* best effort */ }
|
|
656
644
|
this._liveSubscriptions = this._liveSubscriptions.filter(s => s !== pullSub);
|
|
@@ -660,8 +648,16 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
660
648
|
|
|
661
649
|
this.emitEvent({ type: 'link:status-change', tenantDid: target.did, remoteEndpoint: target.dwnUrl, protocol: target.protocol, from: 'initializing', to: 'live' });
|
|
662
650
|
await this.ledger.setStatus(link!, 'live');
|
|
651
|
+
|
|
652
|
+
// If the link was marked dirty in a previous session, schedule
|
|
653
|
+
// immediate reconciliation now that subscriptions are open.
|
|
654
|
+
if (link!.needsReconcile) {
|
|
655
|
+
this.scheduleReconcile(linkKey, 1000);
|
|
656
|
+
}
|
|
663
657
|
} catch (error: any) {
|
|
664
|
-
const linkKey =
|
|
658
|
+
const linkKey = link
|
|
659
|
+
? this.buildLinkKey(target.did, target.dwnUrl, link.scopeId)
|
|
660
|
+
: buildLegacyCursorKey(target.did, target.dwnUrl, target.protocol);
|
|
665
661
|
|
|
666
662
|
// Detect ProgressGap (410) — the cursor is stale, link needs SMT repair.
|
|
667
663
|
if ((error as any).isProgressGap && link) {
|
|
@@ -671,7 +667,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
671
667
|
await this.transitionToRepairing(linkKey, link, {
|
|
672
668
|
resumeToken: gapInfo?.latestAvailable,
|
|
673
669
|
});
|
|
674
|
-
|
|
670
|
+
return;
|
|
675
671
|
}
|
|
676
672
|
|
|
677
673
|
console.error(`SyncEngineLevel: Failed to open live subscription for ${target.did} -> ${target.dwnUrl}`, error);
|
|
@@ -686,7 +682,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
686
682
|
this._connectivityState = 'unknown';
|
|
687
683
|
}
|
|
688
684
|
}
|
|
689
|
-
}
|
|
685
|
+
}));
|
|
690
686
|
|
|
691
687
|
// Step 3: Schedule infrequent SMT integrity check.
|
|
692
688
|
const integrityCheck = async (): Promise<void> => {
|
|
@@ -833,12 +829,12 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
833
829
|
const backoff = SyncEngineLevel.REPAIR_BACKOFF_MS;
|
|
834
830
|
const delayMs = backoff[Math.min(attempts - 1, backoff.length - 1)];
|
|
835
831
|
|
|
836
|
-
const timerGeneration = this.
|
|
832
|
+
const timerGeneration = this._engineGeneration;
|
|
837
833
|
const timer = setTimeout(async (): Promise<void> => {
|
|
838
834
|
this._repairRetryTimers.delete(linkKey);
|
|
839
835
|
|
|
840
836
|
// Bail if teardown occurred since this timer was scheduled.
|
|
841
|
-
if (this.
|
|
837
|
+
if (this._engineGeneration !== timerGeneration) { return; }
|
|
842
838
|
|
|
843
839
|
// Verify link still exists and is still repairing.
|
|
844
840
|
const currentLink = this._activeLinks.get(linkKey);
|
|
@@ -868,6 +864,15 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
868
864
|
|
|
869
865
|
const promise = this.doRepairLink(linkKey).finally(() => {
|
|
870
866
|
this._activeRepairs.delete(linkKey);
|
|
867
|
+
|
|
868
|
+
// Post-repair reconcile: if doRepairLink() marked needsReconcile
|
|
869
|
+
// (to close the gap between diff snapshot and new push subscription),
|
|
870
|
+
// schedule reconciliation NOW — after _activeRepairs is cleared so
|
|
871
|
+
// scheduleReconcile() won't skip it.
|
|
872
|
+
const link = this._activeLinks.get(linkKey);
|
|
873
|
+
if (link?.needsReconcile && link.status === 'live') {
|
|
874
|
+
this.scheduleReconcile(linkKey, 500);
|
|
875
|
+
}
|
|
871
876
|
});
|
|
872
877
|
this._activeRepairs.set(linkKey, promise);
|
|
873
878
|
return promise;
|
|
@@ -886,7 +891,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
886
891
|
// Capture the sync generation at repair start. If teardown occurs during
|
|
887
892
|
// any await, the generation will have incremented and we bail before
|
|
888
893
|
// mutating state — preventing the race where repair continues after teardown.
|
|
889
|
-
const generation = this.
|
|
894
|
+
const generation = this._engineGeneration;
|
|
890
895
|
|
|
891
896
|
const { tenantDid: did, remoteEndpoint: dwnUrl, delegateDid, protocol } = link;
|
|
892
897
|
|
|
@@ -897,7 +902,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
897
902
|
// Step 1: Close existing subscriptions FIRST to stop old events from
|
|
898
903
|
// mutating local state while repair runs.
|
|
899
904
|
await this.closeLinkSubscriptions(link);
|
|
900
|
-
if (this.
|
|
905
|
+
if (this._engineGeneration !== generation) { return; } // Teardown occurred.
|
|
901
906
|
|
|
902
907
|
// Step 2: Clear runtime ordinals immediately — stale state must not
|
|
903
908
|
// persist across repair attempts (successful or failed).
|
|
@@ -908,72 +913,64 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
908
913
|
|
|
909
914
|
try {
|
|
910
915
|
// Step 3: Run SMT reconciliation for this link.
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (
|
|
915
|
-
|
|
916
|
-
if (localRoot !== remoteRoot) {
|
|
917
|
-
const diff = await this.diffWithRemote({ did, dwnUrl, delegateDid, protocol });
|
|
918
|
-
if (this._syncGeneration !== generation) { return; }
|
|
919
|
-
|
|
920
|
-
if (diff.onlyRemote.length > 0) {
|
|
921
|
-
const prefetched: (MessagesSyncDiffEntry & { message: GenericMessage })[] = [];
|
|
922
|
-
const needsFetchCids: string[] = [];
|
|
923
|
-
for (const entry of diff.onlyRemote) {
|
|
924
|
-
if (!entry.message || (entry.message.descriptor.interface === 'Records' &&
|
|
925
|
-
entry.message.descriptor.method === 'Write' &&
|
|
926
|
-
(entry.message.descriptor as any).dataCid && !entry.encodedData)) {
|
|
927
|
-
needsFetchCids.push(entry.messageCid);
|
|
928
|
-
} else {
|
|
929
|
-
prefetched.push(entry as MessagesSyncDiffEntry & { message: GenericMessage });
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
await this.pullMessages({ did, dwnUrl, delegateDid, protocol, messageCids: needsFetchCids, prefetched });
|
|
933
|
-
if (this._syncGeneration !== generation) { return; }
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
if (diff.onlyLocal.length > 0) {
|
|
937
|
-
await this.pushMessages({ did, dwnUrl, delegateDid, protocol, messageCids: diff.onlyLocal });
|
|
938
|
-
if (this._syncGeneration !== generation) { return; }
|
|
939
|
-
}
|
|
940
|
-
}
|
|
916
|
+
const reconcileOutcome = await this.createLinkReconciler(
|
|
917
|
+
() => this._engineGeneration === generation
|
|
918
|
+
).reconcile({ did, dwnUrl, delegateDid, protocol });
|
|
919
|
+
if (reconcileOutcome.aborted) { return; }
|
|
941
920
|
|
|
942
|
-
// Step 4: Determine the post-repair resume token.
|
|
921
|
+
// Step 4: Determine the post-repair pull resume token.
|
|
943
922
|
// - If repair was triggered by ProgressGap, use the stored resumeToken
|
|
944
923
|
// (from gapInfo.latestAvailable) so the reopened subscription replays
|
|
945
924
|
// from a valid boundary, closing the race window between SMT and resubscribe.
|
|
946
925
|
// - Otherwise, use the existing contiguousAppliedToken if still valid.
|
|
947
|
-
//
|
|
948
|
-
// the local EventLog has delivered to the remote. SMT repair handles
|
|
949
|
-
// pull-side convergence; push-side convergence is handled by the diff's
|
|
950
|
-
// onlyLocal push. The push checkpoint remains the local authority.
|
|
926
|
+
// Push is opportunistic — no push checkpoint to reset.
|
|
951
927
|
const repairCtx = this._repairContext.get(linkKey);
|
|
952
928
|
const resumeToken = repairCtx?.resumeToken ?? link.pull.contiguousAppliedToken;
|
|
953
929
|
ReplicationLedger.resetCheckpoint(link.pull, resumeToken);
|
|
954
930
|
await this.ledger.saveLink(link);
|
|
955
|
-
if (this.
|
|
931
|
+
if (this._engineGeneration !== generation) { return; }
|
|
932
|
+
|
|
933
|
+
// Step 5: Reopen subscriptions.
|
|
934
|
+
// Mark needsReconcile BEFORE reopening — local push starts from "now",
|
|
935
|
+
// so any writes between the diff snapshot (step 3) and the new push
|
|
936
|
+
// subscription are invisible to both mechanisms. A short post-reopen
|
|
937
|
+
// reconcile will close this gap (cheap: SMT root comparison short-circuits
|
|
938
|
+
// if roots already match).
|
|
939
|
+
link.needsReconcile = true;
|
|
940
|
+
await this.ledger.saveLink(link);
|
|
941
|
+
if (this._engineGeneration !== generation) { return; }
|
|
956
942
|
|
|
957
|
-
|
|
958
|
-
const target = { did, dwnUrl, delegateDid, protocol };
|
|
959
|
-
await this.openLivePullSubscription(target);
|
|
960
|
-
if (this._syncGeneration !== generation) { return; }
|
|
943
|
+
const target = { did, dwnUrl, delegateDid, protocol, linkKey };
|
|
961
944
|
try {
|
|
962
|
-
await this.
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
945
|
+
await this.openLivePullSubscription(target);
|
|
946
|
+
} catch (pullErr: any) {
|
|
947
|
+
if (pullErr.isProgressGap) {
|
|
948
|
+
console.warn(`SyncEngineLevel: Stale pull resume token for ${did} -> ${dwnUrl}, resetting to start fresh`);
|
|
949
|
+
ReplicationLedger.resetCheckpoint(link.pull);
|
|
950
|
+
await this.ledger.saveLink(link);
|
|
951
|
+
if (this._engineGeneration !== generation) { return; }
|
|
952
|
+
await this.openLivePullSubscription(target);
|
|
953
|
+
} else {
|
|
954
|
+
throw pullErr;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (this._engineGeneration !== generation) { return; }
|
|
958
|
+
try {
|
|
959
|
+
await this.openLocalPushSubscription(target);
|
|
966
960
|
} catch (pushError) {
|
|
967
|
-
const pullSub = this._liveSubscriptions.find(
|
|
968
|
-
s => s.did === did && s.dwnUrl === dwnUrl && s.protocol === protocol
|
|
969
|
-
);
|
|
961
|
+
const pullSub = this._liveSubscriptions.find((s) => s.linkKey === linkKey);
|
|
970
962
|
if (pullSub) {
|
|
971
963
|
try { await pullSub.close(); } catch { /* best effort */ }
|
|
972
964
|
this._liveSubscriptions = this._liveSubscriptions.filter(s => s !== pullSub);
|
|
973
965
|
}
|
|
974
966
|
throw pushError;
|
|
975
967
|
}
|
|
976
|
-
if (this.
|
|
968
|
+
if (this._engineGeneration !== generation) { return; }
|
|
969
|
+
|
|
970
|
+
// Note: post-repair reconcile to close the repair-window gap is
|
|
971
|
+
// scheduled by repairLink() AFTER _activeRepairs is cleared — not
|
|
972
|
+
// here, because scheduleReconcile() would skip it while _activeRepairs
|
|
973
|
+
// still contains this link.
|
|
977
974
|
|
|
978
975
|
// Step 6: Clean up repair context and transition to live.
|
|
979
976
|
this._repairContext.delete(linkKey);
|
|
@@ -991,7 +988,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
991
988
|
|
|
992
989
|
} catch (error: any) {
|
|
993
990
|
// If teardown occurred during repair, don't retry or enter degraded_poll.
|
|
994
|
-
if (this.
|
|
991
|
+
if (this._engineGeneration !== generation) { return; }
|
|
995
992
|
|
|
996
993
|
console.error(`SyncEngineLevel: Repair failed for ${did} -> ${dwnUrl} (attempt ${attempts})`, error);
|
|
997
994
|
this.emitEvent({ type: 'repair:failed', tenantDid: did, remoteEndpoint: dwnUrl, protocol, attempt: attempts, error: String(error.message ?? error) });
|
|
@@ -1011,21 +1008,18 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1011
1008
|
* Close pull and push subscriptions for a specific link.
|
|
1012
1009
|
*/
|
|
1013
1010
|
private async closeLinkSubscriptions(link: ReplicationLinkState): Promise<void> {
|
|
1014
|
-
const { tenantDid: did, remoteEndpoint: dwnUrl
|
|
1011
|
+
const { tenantDid: did, remoteEndpoint: dwnUrl } = link;
|
|
1012
|
+
const linkKey = this.buildLinkKey(did, dwnUrl, link.scopeId);
|
|
1015
1013
|
|
|
1016
1014
|
// Close pull subscription.
|
|
1017
|
-
const pullSub = this._liveSubscriptions.find(
|
|
1018
|
-
s => s.did === did && s.dwnUrl === dwnUrl && s.protocol === protocol
|
|
1019
|
-
);
|
|
1015
|
+
const pullSub = this._liveSubscriptions.find((s) => s.linkKey === linkKey);
|
|
1020
1016
|
if (pullSub) {
|
|
1021
1017
|
try { await pullSub.close(); } catch { /* best effort */ }
|
|
1022
1018
|
this._liveSubscriptions = this._liveSubscriptions.filter(s => s !== pullSub);
|
|
1023
1019
|
}
|
|
1024
1020
|
|
|
1025
1021
|
// Close local push subscription.
|
|
1026
|
-
const pushSub = this._localSubscriptions.find(
|
|
1027
|
-
s => s.did === did && s.dwnUrl === dwnUrl && s.protocol === protocol
|
|
1028
|
-
);
|
|
1022
|
+
const pushSub = this._localSubscriptions.find((s) => s.linkKey === linkKey);
|
|
1029
1023
|
if (pushSub) {
|
|
1030
1024
|
try { await pushSub.close(); } catch { /* best effort */ }
|
|
1031
1025
|
this._localSubscriptions = this._localSubscriptions.filter(s => s !== pushSub);
|
|
@@ -1057,10 +1051,10 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1057
1051
|
const jitter = Math.floor(Math.random() * 15_000);
|
|
1058
1052
|
const interval = baseInterval + jitter;
|
|
1059
1053
|
|
|
1060
|
-
const pollGeneration = this.
|
|
1054
|
+
const pollGeneration = this._engineGeneration;
|
|
1061
1055
|
const timer = setInterval(async (): Promise<void> => {
|
|
1062
1056
|
// Bail if teardown occurred since this timer was created.
|
|
1063
|
-
if (this.
|
|
1057
|
+
if (this._engineGeneration !== pollGeneration) {
|
|
1064
1058
|
clearInterval(timer);
|
|
1065
1059
|
this._degradedPollTimers.delete(linkKey);
|
|
1066
1060
|
return;
|
|
@@ -1105,16 +1099,15 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1105
1099
|
// Increment generation to invalidate all in-flight async operations
|
|
1106
1100
|
// (repairs, retry timers, degraded-poll ticks). Any async work that
|
|
1107
1101
|
// captured the previous generation will bail on its next checkpoint.
|
|
1108
|
-
this.
|
|
1102
|
+
this._engineGeneration++;
|
|
1109
1103
|
|
|
1110
|
-
// Clear
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1104
|
+
// Clear per-link push runtime state.
|
|
1105
|
+
for (const pushRuntime of this._pushRuntimes.values()) {
|
|
1106
|
+
if (pushRuntime.timer) {
|
|
1107
|
+
clearTimeout(pushRuntime.timer);
|
|
1108
|
+
}
|
|
1114
1109
|
}
|
|
1115
|
-
|
|
1116
|
-
// Flush any pending push CIDs.
|
|
1117
|
-
this._pendingPushCids.clear();
|
|
1110
|
+
this._pushRuntimes.clear();
|
|
1118
1111
|
|
|
1119
1112
|
// Close all live pull subscriptions.
|
|
1120
1113
|
for (const sub of this._liveSubscriptions) {
|
|
@@ -1149,8 +1142,16 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1149
1142
|
this._repairRetryTimers.clear();
|
|
1150
1143
|
this._repairContext.clear();
|
|
1151
1144
|
|
|
1145
|
+
// Clear reconcile timers and in-flight operations.
|
|
1146
|
+
for (const timer of this._reconcileTimers.values()) {
|
|
1147
|
+
clearTimeout(timer);
|
|
1148
|
+
}
|
|
1149
|
+
this._reconcileTimers.clear();
|
|
1150
|
+
this._reconcileInFlight.clear();
|
|
1151
|
+
|
|
1152
1152
|
// Clear closure evaluation contexts.
|
|
1153
1153
|
this._closureContexts.clear();
|
|
1154
|
+
this._recentlyPulledCids.clear();
|
|
1154
1155
|
|
|
1155
1156
|
// Clear the in-memory link and runtime state.
|
|
1156
1157
|
this._activeLinks.clear();
|
|
@@ -1167,13 +1168,27 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1167
1168
|
*/
|
|
1168
1169
|
private async openLivePullSubscription(target: {
|
|
1169
1170
|
did: string; dwnUrl: string; delegateDid?: string; protocol?: string;
|
|
1171
|
+
linkKey: string;
|
|
1170
1172
|
}): Promise<void> {
|
|
1171
1173
|
const { did, delegateDid, dwnUrl, protocol } = target;
|
|
1172
1174
|
|
|
1173
|
-
// Resolve the cursor from the link's pull checkpoint
|
|
1174
|
-
|
|
1175
|
+
// Resolve the cursor from the link's durable pull checkpoint.
|
|
1176
|
+
// Legacy syncCursors migration happens at link load time in startLiveSync().
|
|
1177
|
+
const cursorKey = target.linkKey;
|
|
1175
1178
|
const link = this._activeLinks.get(cursorKey);
|
|
1176
|
-
|
|
1179
|
+
let cursor = link?.pull.contiguousAppliedToken;
|
|
1180
|
+
|
|
1181
|
+
// Guard against corrupted tokens with empty fields — these would fail
|
|
1182
|
+
// MessagesSubscribe JSON schema validation (minLength: 1). Discard and
|
|
1183
|
+
// start from the beginning rather than crash the subscription.
|
|
1184
|
+
if (cursor && (!cursor.streamId || !cursor.messageCid || !cursor.epoch || !cursor.position)) {
|
|
1185
|
+
console.warn(`SyncEngineLevel: Discarding stored cursor with empty field(s) for ${did} -> ${dwnUrl}`);
|
|
1186
|
+
cursor = undefined;
|
|
1187
|
+
if (link) {
|
|
1188
|
+
ReplicationLedger.resetCheckpoint(link.pull);
|
|
1189
|
+
await this.ledger.saveLink(link);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1177
1192
|
|
|
1178
1193
|
// Build the MessagesSubscribe filters.
|
|
1179
1194
|
// When the link has protocolPathPrefixes, include them in the filter so the
|
|
@@ -1205,11 +1220,17 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1205
1220
|
permissionGrantId = grant.grant.id;
|
|
1206
1221
|
}
|
|
1207
1222
|
|
|
1223
|
+
const handlerGeneration = this._engineGeneration;
|
|
1224
|
+
|
|
1208
1225
|
// Define the subscription handler that processes incoming events.
|
|
1209
1226
|
// NOTE: The WebSocket client fires handlers without awaiting (fire-and-forget),
|
|
1210
1227
|
// so multiple handlers can be in-flight concurrently. The ordinal tracker
|
|
1211
1228
|
// ensures the checkpoint advances only when all earlier deliveries are committed.
|
|
1212
1229
|
const subscriptionHandler = async (subMessage: SubscriptionMessage): Promise<void> => {
|
|
1230
|
+
if (this._engineGeneration !== handlerGeneration) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1213
1234
|
if (subMessage.type === 'eose') {
|
|
1214
1235
|
// End-of-stored-events — catch-up complete.
|
|
1215
1236
|
if (link) {
|
|
@@ -1231,8 +1252,6 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1231
1252
|
// far as the contiguous drain reaches.
|
|
1232
1253
|
this.drainCommittedPull(cursorKey);
|
|
1233
1254
|
await this.ledger.saveLink(link);
|
|
1234
|
-
} else {
|
|
1235
|
-
await this.setCursor(cursorKey, subMessage.cursor);
|
|
1236
1255
|
}
|
|
1237
1256
|
// Transport is reachable — set connectivity to online.
|
|
1238
1257
|
if (link) {
|
|
@@ -1241,6 +1260,10 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1241
1260
|
if (prevEoseConnectivity !== 'online') {
|
|
1242
1261
|
this.emitEvent({ type: 'link:connectivity-change', tenantDid: did, remoteEndpoint: dwnUrl, protocol, from: prevEoseConnectivity, to: 'online' });
|
|
1243
1262
|
}
|
|
1263
|
+
// If the link was marked dirty, schedule reconciliation now that it's healthy.
|
|
1264
|
+
if (link.needsReconcile) {
|
|
1265
|
+
this.scheduleReconcile(cursorKey, 500);
|
|
1266
|
+
}
|
|
1244
1267
|
} else {
|
|
1245
1268
|
this._connectivityState = 'online';
|
|
1246
1269
|
}
|
|
@@ -1387,9 +1410,6 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1387
1410
|
console.warn(`SyncEngineLevel: Pull in-flight overflow for ${did} -> ${dwnUrl}, transitioning to repairing`);
|
|
1388
1411
|
await this.transitionToRepairing(cursorKey, link);
|
|
1389
1412
|
}
|
|
1390
|
-
} else if (!link) {
|
|
1391
|
-
// Legacy path: no link available, use simple cursor persistence.
|
|
1392
|
-
await this.setCursor(cursorKey, subMessage.cursor);
|
|
1393
1413
|
}
|
|
1394
1414
|
} catch (error: any) {
|
|
1395
1415
|
console.error(`SyncEngineLevel: Error processing live-pull event for ${did}`, error);
|
|
@@ -1426,7 +1446,11 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1426
1446
|
// a fresh cursor-stamped message after reconnection.
|
|
1427
1447
|
const resubscribeFactory: ResubscribeFactory = async (resumeCursor?: ProgressToken) => {
|
|
1428
1448
|
// On reconnect, use the latest durable checkpoint position if available.
|
|
1429
|
-
|
|
1449
|
+
// Discard tokens with empty fields to avoid schema validation failures.
|
|
1450
|
+
let effectiveCursor = resumeCursor ?? link?.pull.contiguousAppliedToken ?? cursor;
|
|
1451
|
+
if (effectiveCursor && (!effectiveCursor.streamId || !effectiveCursor.messageCid || !effectiveCursor.epoch || !effectiveCursor.position)) {
|
|
1452
|
+
effectiveCursor = undefined;
|
|
1453
|
+
}
|
|
1430
1454
|
const resumeRequest = {
|
|
1431
1455
|
...subscribeRequest,
|
|
1432
1456
|
messageParams: { ...subscribeRequest.messageParams, cursor: effectiveCursor },
|
|
@@ -1464,15 +1488,16 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1464
1488
|
}
|
|
1465
1489
|
|
|
1466
1490
|
this._liveSubscriptions.push({
|
|
1491
|
+
linkKey : cursorKey,
|
|
1467
1492
|
did,
|
|
1468
1493
|
dwnUrl,
|
|
1469
1494
|
delegateDid,
|
|
1470
1495
|
protocol,
|
|
1471
|
-
close: async (): Promise<void> => { await reply.subscription!.close(); },
|
|
1496
|
+
close : async (): Promise<void> => { await reply.subscription!.close(); },
|
|
1472
1497
|
});
|
|
1473
1498
|
|
|
1474
1499
|
// Set per-link connectivity to online after successful subscription setup.
|
|
1475
|
-
const pullLink = this._activeLinks.get(
|
|
1500
|
+
const pullLink = this._activeLinks.get(cursorKey);
|
|
1476
1501
|
if (pullLink) {
|
|
1477
1502
|
const prevPullConnectivity = pullLink.connectivity;
|
|
1478
1503
|
pullLink.connectivity = 'online';
|
|
@@ -1492,7 +1517,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1492
1517
|
*/
|
|
1493
1518
|
private async openLocalPushSubscription(target: {
|
|
1494
1519
|
did: string; dwnUrl: string; delegateDid?: string; protocol?: string;
|
|
1495
|
-
|
|
1520
|
+
linkKey: string;
|
|
1496
1521
|
}): Promise<void> {
|
|
1497
1522
|
const { did, delegateDid, dwnUrl, protocol } = target;
|
|
1498
1523
|
|
|
@@ -1512,41 +1537,28 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1512
1537
|
permissionGrantId = grant.grant.id;
|
|
1513
1538
|
}
|
|
1514
1539
|
|
|
1540
|
+
const handlerGeneration = this._engineGeneration;
|
|
1541
|
+
|
|
1515
1542
|
// Subscribe to the local DWN's EventLog.
|
|
1516
1543
|
const subscriptionHandler = async (subMessage: SubscriptionMessage): Promise<void> => {
|
|
1544
|
+
if (this._engineGeneration !== handlerGeneration) {
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1517
1548
|
if (subMessage.type !== 'event') {
|
|
1518
1549
|
return;
|
|
1519
1550
|
}
|
|
1520
1551
|
|
|
1521
|
-
// Subset scope filtering
|
|
1522
|
-
//
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
const pushLink = this._activeLinks.get(this.buildCursorKey(did, dwnUrl, protocol));
|
|
1552
|
+
// Subset scope filtering: only push events that match the link's
|
|
1553
|
+
// scope prefixes. Events outside the scope are not our responsibility.
|
|
1554
|
+
const pushLinkKey = target.linkKey;
|
|
1555
|
+
const pushLink = this._activeLinks.get(pushLinkKey);
|
|
1526
1556
|
if (pushLink && !isEventInScope(subMessage.event.message, pushLink.scope)) {
|
|
1527
|
-
// Guard: only mutate durable state when the link is live/initializing.
|
|
1528
|
-
// During repair/degraded_poll, orchestration owns checkpoint progression.
|
|
1529
|
-
if (pushLink.status !== 'live' && pushLink.status !== 'initializing') {
|
|
1530
|
-
return;
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// Validate token domain before committing — a stream/epoch mismatch
|
|
1534
|
-
// on the local EventLog should trigger repair, not silently overwrite.
|
|
1535
|
-
if (!ReplicationLedger.validateTokenDomain(pushLink.push, subMessage.cursor)) {
|
|
1536
|
-
await this.transitionToRepairing(
|
|
1537
|
-
this.buildCursorKey(did, dwnUrl, protocol), pushLink
|
|
1538
|
-
);
|
|
1539
|
-
return;
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
ReplicationLedger.setReceivedToken(pushLink.push, subMessage.cursor);
|
|
1543
|
-
ReplicationLedger.commitContiguousToken(pushLink.push, subMessage.cursor);
|
|
1544
|
-
await this.ledger.saveLink(pushLink);
|
|
1545
1557
|
return;
|
|
1546
1558
|
}
|
|
1547
1559
|
|
|
1548
1560
|
// Accumulate the message CID for a debounced push.
|
|
1549
|
-
const targetKey =
|
|
1561
|
+
const targetKey = pushLinkKey;
|
|
1550
1562
|
const cid = await Message.getCid(subMessage.event.message);
|
|
1551
1563
|
if (cid === undefined) {
|
|
1552
1564
|
return;
|
|
@@ -1559,32 +1571,28 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1559
1571
|
return;
|
|
1560
1572
|
}
|
|
1561
1573
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
}
|
|
1567
|
-
pending.entries.push({ cid, localToken: subMessage.cursor });
|
|
1574
|
+
const pushRuntime = this.getOrCreatePushRuntime(targetKey, {
|
|
1575
|
+
did, dwnUrl, delegateDid, protocol,
|
|
1576
|
+
});
|
|
1577
|
+
pushRuntime.entries.push({ cid });
|
|
1568
1578
|
|
|
1569
|
-
//
|
|
1570
|
-
|
|
1571
|
-
|
|
1579
|
+
// Immediate-first: if no push is in flight and no batch timer is
|
|
1580
|
+
// pending, push immediately. Otherwise, the pending batch timer
|
|
1581
|
+
// or the post-flush drain will pick up the new entry.
|
|
1582
|
+
if (!pushRuntime.flushing && !pushRuntime.timer) {
|
|
1583
|
+
void this.flushPendingPushesForLink(targetKey);
|
|
1572
1584
|
}
|
|
1573
|
-
this._pushDebounceTimer = setTimeout((): void => {
|
|
1574
|
-
void this.flushPendingPushes();
|
|
1575
|
-
}, PUSH_DEBOUNCE_MS);
|
|
1576
1585
|
};
|
|
1577
1586
|
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
// writes during repair would otherwise be missed by push-on-write.
|
|
1587
|
+
// Subscribe to the local DWN EventLog from "now" — opportunistic push
|
|
1588
|
+
// does not replay from a stored cursor. Any writes missed during outages
|
|
1589
|
+
// are recovered by the post-repair reconciliation path.
|
|
1582
1590
|
const response = await this.agent.dwn.processRequest({
|
|
1583
1591
|
author : did,
|
|
1584
1592
|
target : did,
|
|
1585
1593
|
messageType : DwnInterface.MessagesSubscribe,
|
|
1586
1594
|
granteeDid : delegateDid,
|
|
1587
|
-
messageParams : { filters, permissionGrantId
|
|
1595
|
+
messageParams : { filters, permissionGrantId },
|
|
1588
1596
|
subscriptionHandler : subscriptionHandler as any,
|
|
1589
1597
|
});
|
|
1590
1598
|
|
|
@@ -1594,11 +1602,12 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1594
1602
|
}
|
|
1595
1603
|
|
|
1596
1604
|
this._localSubscriptions.push({
|
|
1605
|
+
linkKey : target.linkKey ?? buildLegacyCursorKey(did, dwnUrl, protocol),
|
|
1597
1606
|
did,
|
|
1598
1607
|
dwnUrl,
|
|
1599
1608
|
delegateDid,
|
|
1600
1609
|
protocol,
|
|
1601
|
-
close: async (): Promise<void> => { await reply.subscription!.close(); },
|
|
1610
|
+
close : async (): Promise<void> => { await reply.subscription!.close(); },
|
|
1602
1611
|
});
|
|
1603
1612
|
}
|
|
1604
1613
|
|
|
@@ -1606,112 +1615,261 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1606
1615
|
* Flushes accumulated push CIDs to remote DWNs.
|
|
1607
1616
|
*/
|
|
1608
1617
|
private async flushPendingPushes(): Promise<void> {
|
|
1609
|
-
this.
|
|
1618
|
+
await Promise.all([...this._pushRuntimes.keys()].map(async (linkKey) => {
|
|
1619
|
+
await this.flushPendingPushesForLink(linkKey);
|
|
1620
|
+
}));
|
|
1621
|
+
}
|
|
1610
1622
|
|
|
1611
|
-
|
|
1612
|
-
this.
|
|
1623
|
+
private async flushPendingPushesForLink(linkKey: string): Promise<void> {
|
|
1624
|
+
const pushRuntime = this._pushRuntimes.get(linkKey);
|
|
1625
|
+
if (!pushRuntime) {
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1613
1628
|
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1629
|
+
const { did, dwnUrl, delegateDid, protocol, entries: pushEntries, retryCount } = pushRuntime;
|
|
1630
|
+
pushRuntime.entries = [];
|
|
1631
|
+
|
|
1632
|
+
if (pushEntries.length === 0) {
|
|
1633
|
+
if (!pushRuntime.timer && !pushRuntime.flushing && retryCount === 0) {
|
|
1634
|
+
this._pushRuntimes.delete(linkKey);
|
|
1619
1635
|
}
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1620
1638
|
|
|
1621
|
-
|
|
1639
|
+
const cids = pushEntries.map((entry) => entry.cid);
|
|
1640
|
+
pushRuntime.flushing = true;
|
|
1622
1641
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1642
|
+
try {
|
|
1643
|
+
const result = await pushMessages({
|
|
1644
|
+
did, dwnUrl, delegateDid, protocol,
|
|
1645
|
+
messageCids : cids,
|
|
1646
|
+
agent : this.agent,
|
|
1647
|
+
permissionsApi : this._permissionsApi,
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
if (result.failed.length > 0) {
|
|
1651
|
+
const failedSet = new Set(result.failed);
|
|
1652
|
+
const failedEntries = pushEntries.filter((entry) => failedSet.has(entry.cid));
|
|
1653
|
+
this.requeueOrReconcile(linkKey, {
|
|
1625
1654
|
did, dwnUrl, delegateDid, protocol,
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
permissionsApi : this._permissionsApi,
|
|
1655
|
+
entries : failedEntries,
|
|
1656
|
+
retryCount : retryCount + 1,
|
|
1629
1657
|
});
|
|
1630
|
-
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
if (link) {
|
|
1637
|
-
const succeededSet = new Set(result.succeeded);
|
|
1638
|
-
// Track highest contiguous success: if a CID fails, we stop advancing.
|
|
1639
|
-
let hitFailure = false;
|
|
1640
|
-
for (const entry of pushEntries) {
|
|
1641
|
-
if (hitFailure) { break; }
|
|
1642
|
-
if (succeededSet.has(entry.cid) && entry.localToken) {
|
|
1643
|
-
if (!ReplicationLedger.validateTokenDomain(link.push, entry.localToken)) {
|
|
1644
|
-
console.warn(`SyncEngineLevel: Push checkpoint domain mismatch for ${did} -> ${dwnUrl}, transitioning to repairing`);
|
|
1645
|
-
await this.transitionToRepairing(targetKey, link);
|
|
1646
|
-
break;
|
|
1647
|
-
}
|
|
1648
|
-
ReplicationLedger.setReceivedToken(link.push, entry.localToken);
|
|
1649
|
-
ReplicationLedger.commitContiguousToken(link.push, entry.localToken);
|
|
1650
|
-
} else {
|
|
1651
|
-
// This CID failed or had no token — stop advancing.
|
|
1652
|
-
hitFailure = true;
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
await this.ledger.saveLink(link);
|
|
1658
|
+
} else {
|
|
1659
|
+
// Successful push — reset retry count so subsequent unrelated
|
|
1660
|
+
// batches on this link start with a fresh budget.
|
|
1661
|
+
pushRuntime.retryCount = 0;
|
|
1662
|
+
if (!pushRuntime.timer && pushRuntime.entries.length === 0) {
|
|
1663
|
+
this._pushRuntimes.delete(linkKey);
|
|
1656
1664
|
}
|
|
1665
|
+
}
|
|
1666
|
+
} catch (error: any) {
|
|
1667
|
+
console.error(`SyncEngineLevel: Push batch failed for ${did} -> ${dwnUrl}`, error);
|
|
1668
|
+
this.requeueOrReconcile(linkKey, {
|
|
1669
|
+
did, dwnUrl, delegateDid, protocol,
|
|
1670
|
+
entries : pushEntries,
|
|
1671
|
+
retryCount : retryCount + 1,
|
|
1672
|
+
});
|
|
1673
|
+
} finally {
|
|
1674
|
+
pushRuntime.flushing = false;
|
|
1675
|
+
|
|
1676
|
+
// If new entries accumulated while this push was in flight, schedule
|
|
1677
|
+
// a short drain to flush them. This gives a brief batching window
|
|
1678
|
+
// for burst writes while keeping single-write latency low.
|
|
1679
|
+
const rt = this._pushRuntimes.get(linkKey);
|
|
1680
|
+
if (rt && rt.entries.length > 0 && !rt.timer) {
|
|
1681
|
+
rt.timer = setTimeout((): void => {
|
|
1682
|
+
rt.timer = undefined;
|
|
1683
|
+
void this.flushPendingPushesForLink(linkKey);
|
|
1684
|
+
}, PUSH_DEBOUNCE_MS);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1657
1688
|
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
if (result.failed.length > 0) {
|
|
1661
|
-
console.error(
|
|
1662
|
-
`SyncEngineLevel: Push-on-write failed for ${did} -> ${dwnUrl}: ` +
|
|
1663
|
-
`${result.failed.length} transient failures of ${cids.length} messages`
|
|
1664
|
-
);
|
|
1665
|
-
const failedSet = new Set(result.failed);
|
|
1666
|
-
const failedEntries = pushEntries.filter(e => failedSet.has(e.cid));
|
|
1667
|
-
let requeued = this._pendingPushCids.get(targetKey);
|
|
1668
|
-
if (!requeued) {
|
|
1669
|
-
requeued = { did, dwnUrl, delegateDid, protocol, entries: [] };
|
|
1670
|
-
this._pendingPushCids.set(targetKey, requeued);
|
|
1671
|
-
}
|
|
1672
|
-
requeued.entries.push(...failedEntries);
|
|
1689
|
+
/** Push retry backoff schedule: immediate, 250ms, 1s, 2s, then give up. */
|
|
1690
|
+
private static readonly PUSH_RETRY_BACKOFF_MS = [0, 250, 1000, 2000];
|
|
1673
1691
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1692
|
+
/**
|
|
1693
|
+
* Re-queues a failed push batch for retry, or marks the link
|
|
1694
|
+
* `needsReconcile` if retries are exhausted. Bounded to prevent
|
|
1695
|
+
* infinite retry loops.
|
|
1696
|
+
*/
|
|
1697
|
+
private requeueOrReconcile(targetKey: string, pending: {
|
|
1698
|
+
did: string; dwnUrl: string; delegateDid?: string; protocol?: string;
|
|
1699
|
+
entries: { cid: string }[];
|
|
1700
|
+
retryCount: number;
|
|
1701
|
+
}): void {
|
|
1702
|
+
const maxRetries = SyncEngineLevel.PUSH_RETRY_BACKOFF_MS.length;
|
|
1703
|
+
const pushRuntime = this.getOrCreatePushRuntime(targetKey, pending);
|
|
1704
|
+
|
|
1705
|
+
if (pending.retryCount >= maxRetries) {
|
|
1706
|
+
// Retry budget exhausted — mark link dirty for reconciliation.
|
|
1707
|
+
if (pushRuntime.timer) {
|
|
1708
|
+
clearTimeout(pushRuntime.timer);
|
|
1709
|
+
}
|
|
1710
|
+
this._pushRuntimes.delete(targetKey);
|
|
1711
|
+
const link = this._activeLinks.get(targetKey);
|
|
1712
|
+
if (link && !link.needsReconcile) {
|
|
1713
|
+
link.needsReconcile = true;
|
|
1714
|
+
void this.ledger.saveLink(link).then(() => {
|
|
1715
|
+
this.emitEvent({ type: 'reconcile:needed', tenantDid: pending.did, remoteEndpoint: pending.dwnUrl, protocol: pending.protocol, reason: 'push-retry-exhausted' });
|
|
1716
|
+
this.scheduleReconcile(targetKey);
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1694
1721
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1722
|
+
pushRuntime.entries.push(...pending.entries);
|
|
1723
|
+
pushRuntime.retryCount = pending.retryCount;
|
|
1724
|
+
const delayMs = SyncEngineLevel.PUSH_RETRY_BACKOFF_MS[pending.retryCount] ?? 2000;
|
|
1725
|
+
if (pushRuntime.timer) {
|
|
1726
|
+
clearTimeout(pushRuntime.timer);
|
|
1727
|
+
}
|
|
1728
|
+
pushRuntime.timer = setTimeout((): void => {
|
|
1729
|
+
pushRuntime.timer = undefined;
|
|
1730
|
+
void this.flushPendingPushesForLink(targetKey);
|
|
1731
|
+
}, delayMs);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
private createLinkReconciler(shouldContinue?: () => boolean): SyncLinkReconciler {
|
|
1735
|
+
return new SyncLinkReconciler({
|
|
1736
|
+
getLocalRoot : async (did, delegateDid, protocol) => this.getLocalRoot(did, delegateDid, protocol),
|
|
1737
|
+
getRemoteRoot : async (did, dwnUrl, delegateDid, protocol) => this.getRemoteRoot(did, dwnUrl, delegateDid, protocol),
|
|
1738
|
+
diffWithRemote : async (target) => this.diffWithRemote(target),
|
|
1739
|
+
pullMessages : async (params) => this.pullMessages(params),
|
|
1740
|
+
pushMessages : async (params) => this.pushMessages(params),
|
|
1741
|
+
shouldContinue,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// ---------------------------------------------------------------------------
|
|
1746
|
+
// Per-link reconciliation
|
|
1747
|
+
// ---------------------------------------------------------------------------
|
|
1748
|
+
|
|
1749
|
+
/** Active reconcile timers, keyed by link key. */
|
|
1750
|
+
private _reconcileTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
1751
|
+
|
|
1752
|
+
/** Active reconcile operations, keyed by link key (dedup). */
|
|
1753
|
+
private _reconcileInFlight: Map<string, Promise<void>> = new Map();
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Schedule a per-link reconciliation after a short debounce. Coalesces
|
|
1757
|
+
* repeated requests for the same link.
|
|
1758
|
+
*/
|
|
1759
|
+
private scheduleReconcile(linkKey: string, delayMs: number = 1500): void {
|
|
1760
|
+
if (this._reconcileTimers.has(linkKey)) { return; }
|
|
1761
|
+
if (this._reconcileInFlight.has(linkKey)) { return; }
|
|
1762
|
+
if (this._activeRepairs.has(linkKey)) { return; }
|
|
1763
|
+
|
|
1764
|
+
const generation = this._engineGeneration;
|
|
1765
|
+
const timer = setTimeout((): void => {
|
|
1766
|
+
this._reconcileTimers.delete(linkKey);
|
|
1767
|
+
if (this._engineGeneration !== generation) { return; }
|
|
1768
|
+
void this.reconcileLink(linkKey);
|
|
1769
|
+
}, delayMs);
|
|
1770
|
+
this._reconcileTimers.set(linkKey, timer);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Run SMT reconciliation for a single link. Deduplicates concurrent calls.
|
|
1775
|
+
* On success, clears `needsReconcile`. On failure, schedules retry.
|
|
1776
|
+
*/
|
|
1777
|
+
private async reconcileLink(linkKey: string): Promise<void> {
|
|
1778
|
+
const existing = this._reconcileInFlight.get(linkKey);
|
|
1779
|
+
if (existing) { return existing; }
|
|
1780
|
+
|
|
1781
|
+
const promise = this.doReconcileLink(linkKey).finally(() => {
|
|
1782
|
+
this._reconcileInFlight.delete(linkKey);
|
|
1783
|
+
});
|
|
1784
|
+
this._reconcileInFlight.set(linkKey, promise);
|
|
1785
|
+
return promise;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Internal reconciliation implementation for a single link. Runs the
|
|
1790
|
+
* same SMT diff + pull/push that `sync()` does, but scoped to one link.
|
|
1791
|
+
*/
|
|
1792
|
+
private async doReconcileLink(linkKey: string): Promise<void> {
|
|
1793
|
+
const link = this._activeLinks.get(linkKey);
|
|
1794
|
+
if (!link) { return; }
|
|
1795
|
+
|
|
1796
|
+
// Only reconcile live links — repairing/degraded links have their own
|
|
1797
|
+
// recovery path. Reconciling during repair would race with SMT diff.
|
|
1798
|
+
if (link.status !== 'live') {
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// Skip if a repair is in progress for this link.
|
|
1803
|
+
if (this._activeRepairs.has(linkKey)) {
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
const generation = this._engineGeneration;
|
|
1808
|
+
const { tenantDid: did, remoteEndpoint: dwnUrl, delegateDid, protocol } = link;
|
|
1809
|
+
|
|
1810
|
+
try {
|
|
1811
|
+
const reconcileOutcome = await this.createLinkReconciler(
|
|
1812
|
+
() => this._engineGeneration === generation
|
|
1813
|
+
).reconcile({ did, dwnUrl, delegateDid, protocol }, { verifyConvergence: true });
|
|
1814
|
+
if (reconcileOutcome.aborted) { return; }
|
|
1815
|
+
|
|
1816
|
+
if (reconcileOutcome.converged) {
|
|
1817
|
+
await this.ledger.clearNeedsReconcile(link);
|
|
1818
|
+
this.emitEvent({ type: 'reconcile:completed', tenantDid: did, remoteEndpoint: dwnUrl, protocol });
|
|
1819
|
+
} else {
|
|
1820
|
+
// Roots still differ — retry after a delay. This can happen when
|
|
1821
|
+
// pushMessages() had permanent failures, pullMessages() partially
|
|
1822
|
+
// failed, or new writes arrived during reconciliation.
|
|
1823
|
+
this.scheduleReconcile(linkKey, 5000);
|
|
1700
1824
|
}
|
|
1701
|
-
})
|
|
1825
|
+
} catch (error: any) {
|
|
1826
|
+
console.error(`SyncEngineLevel: Reconciliation failed for ${did} -> ${dwnUrl}`, error);
|
|
1827
|
+
// Schedule retry with longer delay.
|
|
1828
|
+
this.scheduleReconcile(linkKey, 5000);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
private getOrCreatePushRuntime(linkKey: string, params: {
|
|
1833
|
+
did: string;
|
|
1834
|
+
dwnUrl: string;
|
|
1835
|
+
delegateDid?: string;
|
|
1836
|
+
protocol?: string;
|
|
1837
|
+
}): PushRuntimeState {
|
|
1838
|
+
let pushRuntime = this._pushRuntimes.get(linkKey);
|
|
1839
|
+
if (!pushRuntime) {
|
|
1840
|
+
pushRuntime = {
|
|
1841
|
+
...params,
|
|
1842
|
+
entries : [],
|
|
1843
|
+
retryCount : 0,
|
|
1844
|
+
};
|
|
1845
|
+
this._pushRuntimes.set(linkKey, pushRuntime);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
return pushRuntime;
|
|
1702
1849
|
}
|
|
1703
1850
|
|
|
1704
1851
|
// ---------------------------------------------------------------------------
|
|
1705
1852
|
// Cursor persistence
|
|
1706
1853
|
// ---------------------------------------------------------------------------
|
|
1707
1854
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1855
|
+
/**
|
|
1856
|
+
* Build the runtime key for a replication link.
|
|
1857
|
+
*
|
|
1858
|
+
* Live-mode subscription methods (`openLivePullSubscription`,
|
|
1859
|
+
* `openLocalPushSubscription`) receive `linkKey` directly and never
|
|
1860
|
+
* call this. The remaining callers are poll-mode `sync()` and the
|
|
1861
|
+
* live-mode startup/error paths that already have `link.scopeId`.
|
|
1862
|
+
*
|
|
1863
|
+
* The `undefined` fallback (which produces a legacy cursor key) exists
|
|
1864
|
+
* only for the no-protocol full-tenant targets in poll mode.
|
|
1865
|
+
*/
|
|
1866
|
+
private buildLinkKey(did: string, dwnUrl: string, scopeIdOrProtocol?: string): string {
|
|
1867
|
+
return scopeIdOrProtocol ? buildLinkId(did, dwnUrl, scopeIdOrProtocol) : buildLegacyCursorKey(did, dwnUrl);
|
|
1711
1868
|
}
|
|
1712
1869
|
|
|
1713
1870
|
/**
|
|
1714
|
-
*
|
|
1871
|
+
* @deprecated Used by poll-mode sync and one-time migration only. Live mode
|
|
1872
|
+
* uses ReplicationLedger checkpoints. Handles migration from old string cursors:
|
|
1715
1873
|
* if the stored value is a bare string (pre-ProgressToken format), it is treated
|
|
1716
1874
|
* as absent — the sync engine will do a full SMT reconciliation on first startup
|
|
1717
1875
|
* after upgrade, which is correct and safe.
|
|
@@ -1723,15 +1881,18 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1723
1881
|
try {
|
|
1724
1882
|
const parsed = JSON.parse(raw);
|
|
1725
1883
|
if (parsed && typeof parsed === 'object' &&
|
|
1726
|
-
typeof parsed.streamId === 'string' &&
|
|
1727
|
-
typeof parsed.epoch === 'string' &&
|
|
1728
|
-
typeof parsed.position === 'string' &&
|
|
1729
|
-
typeof parsed.messageCid === 'string') {
|
|
1884
|
+
typeof parsed.streamId === 'string' && parsed.streamId.length > 0 &&
|
|
1885
|
+
typeof parsed.epoch === 'string' && parsed.epoch.length > 0 &&
|
|
1886
|
+
typeof parsed.position === 'string' && parsed.position.length > 0 &&
|
|
1887
|
+
typeof parsed.messageCid === 'string' && parsed.messageCid.length > 0) {
|
|
1730
1888
|
return parsed as ProgressToken;
|
|
1731
1889
|
}
|
|
1732
1890
|
} catch {
|
|
1733
|
-
// Not valid JSON (old string cursor) —
|
|
1891
|
+
// Not valid JSON (old string cursor) — fall through to delete.
|
|
1734
1892
|
}
|
|
1893
|
+
// Entry exists but is unparseable or has invalid/empty fields. Delete it
|
|
1894
|
+
// so subsequent startups don't re-check it on every launch.
|
|
1895
|
+
await this.deleteLegacyCursor(key);
|
|
1735
1896
|
return undefined;
|
|
1736
1897
|
} catch (error) {
|
|
1737
1898
|
const e = error as { code: string };
|
|
@@ -1742,9 +1903,20 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1742
1903
|
}
|
|
1743
1904
|
}
|
|
1744
1905
|
|
|
1745
|
-
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Delete a legacy cursor from the old syncCursors sublevel.
|
|
1909
|
+
* Called as part of one-time migration to ReplicationLedger.
|
|
1910
|
+
*/
|
|
1911
|
+
private async deleteLegacyCursor(key: string): Promise<void> {
|
|
1746
1912
|
const cursors = this._db.sublevel('syncCursors');
|
|
1747
|
-
|
|
1913
|
+
try {
|
|
1914
|
+
await cursors.del(key);
|
|
1915
|
+
} catch {
|
|
1916
|
+
// Best-effort — ignore LEVEL_NOT_FOUND and transient I/O errors alike.
|
|
1917
|
+
// A failed delete leaves the bad entry for one more re-check on the
|
|
1918
|
+
// next startup, which is harmless.
|
|
1919
|
+
}
|
|
1748
1920
|
}
|
|
1749
1921
|
|
|
1750
1922
|
// ---------------------------------------------------------------------------
|
|
@@ -1762,8 +1934,11 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1762
1934
|
}
|
|
1763
1935
|
|
|
1764
1936
|
// Check for inline base64url-encoded data (small records from EventLog).
|
|
1937
|
+
// Delete the transport-level field so the DWN schema validator does not
|
|
1938
|
+
// reject the message for having unevaluated properties.
|
|
1765
1939
|
const encodedData = (event.message as any).encodedData as string | undefined;
|
|
1766
1940
|
if (encodedData) {
|
|
1941
|
+
delete (event.message as any).encodedData;
|
|
1767
1942
|
const bytes = Encoder.base64UrlToBytes(encodedData);
|
|
1768
1943
|
return new ReadableStream<Uint8Array>({
|
|
1769
1944
|
start(controller): void {
|