@recordtimelabel/core 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/package.json +1 -1
- package/src/index.js +323 -20
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
|
|
|
17
17
|
"@recordtimelabel/core": "file:../recordtimelabel-core"
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The current strict durable transport contract is prepared in package version `0.4.
|
|
20
|
+
For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The current strict durable transport contract is prepared in package version `0.4.6` (publish it before updating consumers):
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.4.
|
|
23
|
+
"@recordtimelabel/core": "0.4.6"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
|
|
@@ -33,6 +33,8 @@ If this checkout's `package.json` is ahead of the published version, publish the
|
|
|
33
33
|
- `applyOperation(state, operation)`
|
|
34
34
|
- `applyRecordTimeLabelOperation(state, operation)`
|
|
35
35
|
- `createRecordTimeLabelSyncEngine({ storage, cloud, session, client, clock, logger })`
|
|
36
|
+
- `RTL_MAX_SYNC_DRAIN_ROUNDS`
|
|
37
|
+
- `RTL_SYNC_DRAIN_RETRY_DELAY_MS`
|
|
36
38
|
- `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
|
|
37
39
|
- `RECORD_TIMELABEL_PROTOCOL_CAPABILITIES`
|
|
38
40
|
- `toRecordTimeLabelWireOperation(operation)`
|
|
@@ -121,13 +123,32 @@ modes with a unique `attemptId`; all require a fresh logical attempt. Structured
|
|
|
121
123
|
failures, missing state, and missing/invalid revisions fail closed. UID or workspace
|
|
122
124
|
epoch changes clear the visible workspace and require `init()` before another dispatch
|
|
123
125
|
or sync; token refresh for the same UID/epoch only refreshes the subscription fence. An
|
|
124
|
-
operation acknowledgement records its revision diagnostically
|
|
125
|
-
`remoteBaseline.revision`
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
operation acknowledgement records its revision diagnostically and advances
|
|
127
|
+
`remoteBaseline.revision` with a complete state, or with an all-applied no-state ACK at
|
|
128
|
+
exactly the next revision; larger revision gaps trigger a fresh bootstrap. Re-dispatching
|
|
129
|
+
the same operation ID and wire content is reported as deduplicated, while different wire
|
|
130
|
+
content is quarantined as an ID conflict. Rejected entries are terminal diagnostics in this
|
|
131
|
+
API; an intentional retry currently requires a new operation ID.
|
|
132
|
+
|
|
133
|
+
The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`,
|
|
134
|
+
`syncUntilIdle(reason, options)`, `waitForIdle()`, `waitForRemoteReady()`, `getSnapshot()`,
|
|
135
|
+
`subscribe(listener)`, and `destroy()`. A cloud `subscribe(listener, context)` disposer may
|
|
136
|
+
carry a `.ready` promise. After `init()` settles, hosts can await `waitForRemoteReady()` to
|
|
137
|
+
prove that the current subscription's initial root observation and any bounded catch-up have
|
|
138
|
+
settled; this wait rejects when the listener fails before readiness and fails closed across a
|
|
139
|
+
session generation change or destroy. `init()` deliberately does not await this promise because
|
|
140
|
+
the initial listener callback may itself enter the engine queue. Anonymous/no-subscription use
|
|
141
|
+
is immediately ready.
|
|
142
|
+
|
|
143
|
+
`sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
|
|
144
|
+
subsequent batches up to `RTL_MAX_SYNC_DRAIN_ROUNDS`; it stops on retry/deferred/protocol
|
|
145
|
+
conditions and reports `syncDrainLimitReached` as a retryable failure instead of silently
|
|
146
|
+
reporting success. Hosts may provide `beforeRound({round, pendingCount, snapshot})` to block
|
|
147
|
+
gateway writes while a host-specific hydration/import gate is active, and `runRound({round,
|
|
148
|
+
pendingCount, run})` to serialize that gate check and the gateway call with host mutations.
|
|
149
|
+
`getSnapshot().state` is derived by replaying pending operations over `remoteBaseline.state`;
|
|
150
|
+
rejected operations are kept in diagnostics and are not replayed. Session tokens are used for
|
|
151
|
+
fencing but are never persisted. The legacy
|
|
131
152
|
`createSyncEngine` and app adapters remain available and are not implicitly migrated by this API.
|
|
132
153
|
|
|
133
154
|
`expandedGroups` is a local view projection, not durable cloud domain state. Durable workspaces,
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -31,17 +31,20 @@ const REQUIRED_FOLDERS = [
|
|
|
31
31
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.
|
|
34
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.7';
|
|
35
35
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
36
36
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
37
37
|
'fifo-retry-fence',
|
|
38
38
|
'baseline-refresh-after-rejection',
|
|
39
39
|
'sync-batch-boundary',
|
|
40
|
+
'remote-subscription-readiness',
|
|
40
41
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
41
42
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
42
43
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
43
44
|
]);
|
|
44
45
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
46
|
+
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
47
|
+
export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
|
|
45
48
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
46
49
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
47
50
|
export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -2954,6 +2957,7 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2954
2957
|
});
|
|
2955
2958
|
}
|
|
2956
2959
|
let rejectedCount = 0;
|
|
2960
|
+
let deduplicatedCount = 0;
|
|
2957
2961
|
toArray(operations).forEach((operation, index) => {
|
|
2958
2962
|
const id = normalizeId(operation?.id);
|
|
2959
2963
|
const fingerprint = rtlOperationWireFingerprint(operation);
|
|
@@ -2961,7 +2965,10 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2961
2965
|
let reason = rtlOperationIdentityReason(operation, workspace);
|
|
2962
2966
|
if (!reason) reason = rtlLifecycleOperationReason(operation, lifecycleState);
|
|
2963
2967
|
if (!reason && knownFingerprint) {
|
|
2964
|
-
if (knownFingerprint === fingerprint)
|
|
2968
|
+
if (knownFingerprint === fingerprint) {
|
|
2969
|
+
deduplicatedCount += 1;
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2965
2972
|
reason = RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.ID_CONFLICT;
|
|
2966
2973
|
}
|
|
2967
2974
|
if (!reason) {
|
|
@@ -2991,7 +2998,7 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2991
2998
|
}
|
|
2992
2999
|
};
|
|
2993
3000
|
});
|
|
2994
|
-
return {accepted, rejected, rejectedCount};
|
|
3001
|
+
return {accepted, rejected, rejectedCount, deduplicatedCount};
|
|
2995
3002
|
};
|
|
2996
3003
|
|
|
2997
3004
|
const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
@@ -3151,6 +3158,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3151
3158
|
let unsubscribeCloud = null;
|
|
3152
3159
|
let unsubscribeSession = null;
|
|
3153
3160
|
let subscriptionContext = null;
|
|
3161
|
+
let remoteReadyGeneration = 0;
|
|
3162
|
+
let remoteReadyState = {
|
|
3163
|
+
generation: 0,
|
|
3164
|
+
settled: true,
|
|
3165
|
+
promise: Promise.resolve({
|
|
3166
|
+
success: true,
|
|
3167
|
+
skipped: true,
|
|
3168
|
+
reason: 'not_initialized',
|
|
3169
|
+
generation: 0
|
|
3170
|
+
})
|
|
3171
|
+
};
|
|
3154
3172
|
let bootstrapAttemptSequence = 0;
|
|
3155
3173
|
const listeners = new Set();
|
|
3156
3174
|
let queue = Promise.resolve();
|
|
@@ -3287,7 +3305,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3287
3305
|
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3288
3306
|
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3289
3307
|
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3290
|
-
// bootstrap walk
|
|
3308
|
+
// bootstrap walk on transport/protocol failure. A successful but stale
|
|
3309
|
+
// catch-up response is fail-closed: falling back would hide a revision
|
|
3310
|
+
// contract violation and could apply a baseline older than the ACK.
|
|
3291
3311
|
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
3292
3312
|
if (typeof cloud?.catchUp === 'function') {
|
|
3293
3313
|
try {
|
|
@@ -3300,6 +3320,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3300
3320
|
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3301
3321
|
}
|
|
3302
3322
|
} catch (error) {
|
|
3323
|
+
if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
|
|
3324
|
+
throw error;
|
|
3325
|
+
}
|
|
3303
3326
|
logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
|
|
3304
3327
|
}
|
|
3305
3328
|
}
|
|
@@ -3490,7 +3513,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3490
3513
|
return {success: true, ignored: true};
|
|
3491
3514
|
}
|
|
3492
3515
|
let baselineValue = remoteValue;
|
|
3493
|
-
|
|
3516
|
+
const remoteState = remote?.state ?? remote?.data;
|
|
3517
|
+
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
3518
|
+
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
3494
3519
|
if (typeof cloud?.bootstrap !== 'function') {
|
|
3495
3520
|
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3496
3521
|
}
|
|
@@ -3506,7 +3531,49 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3506
3531
|
return getSnapshot();
|
|
3507
3532
|
};
|
|
3508
3533
|
|
|
3509
|
-
const
|
|
3534
|
+
const createRemoteReadyState = () => {
|
|
3535
|
+
const generation = ++remoteReadyGeneration;
|
|
3536
|
+
let resolvePromise;
|
|
3537
|
+
let rejectPromise;
|
|
3538
|
+
const state = {
|
|
3539
|
+
generation,
|
|
3540
|
+
settled: false,
|
|
3541
|
+
promise: new Promise((resolve, reject) => {
|
|
3542
|
+
resolvePromise = resolve;
|
|
3543
|
+
rejectPromise = reject;
|
|
3544
|
+
}),
|
|
3545
|
+
resolve(value = {}) {
|
|
3546
|
+
if (state.settled) return;
|
|
3547
|
+
state.settled = true;
|
|
3548
|
+
resolvePromise({
|
|
3549
|
+
success: true,
|
|
3550
|
+
...(value && typeof value === 'object' ? value : {}),
|
|
3551
|
+
generation
|
|
3552
|
+
});
|
|
3553
|
+
},
|
|
3554
|
+
reject(error) {
|
|
3555
|
+
if (state.settled) return;
|
|
3556
|
+
state.settled = true;
|
|
3557
|
+
rejectPromise(error);
|
|
3558
|
+
}
|
|
3559
|
+
};
|
|
3560
|
+
// Readiness is an opt-in host boundary. Keep a rejection observable to a
|
|
3561
|
+
// waiter without turning clients that have not adopted it yet into an
|
|
3562
|
+
// unhandled-rejection source.
|
|
3563
|
+
state.promise.catch(() => null);
|
|
3564
|
+
return state;
|
|
3565
|
+
};
|
|
3566
|
+
|
|
3567
|
+
const staleRemoteReady = (state, reason = 'stale_session') => {
|
|
3568
|
+
if (!state || state.settled) return;
|
|
3569
|
+
state.resolve({success: false, stale: true, reason});
|
|
3570
|
+
};
|
|
3571
|
+
|
|
3572
|
+
const isCurrentRemoteReady = (state) => (
|
|
3573
|
+
!destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
|
|
3574
|
+
);
|
|
3575
|
+
|
|
3576
|
+
const stopCloudSubscription = ({settleReady = true} = {}) => {
|
|
3510
3577
|
if (typeof unsubscribeCloud === 'function') {
|
|
3511
3578
|
try { unsubscribeCloud(); } catch (error) {
|
|
3512
3579
|
logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
|
|
@@ -3514,32 +3581,104 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3514
3581
|
}
|
|
3515
3582
|
unsubscribeCloud = null;
|
|
3516
3583
|
subscriptionContext = null;
|
|
3584
|
+
if (settleReady) staleRemoteReady(remoteReadyState);
|
|
3517
3585
|
};
|
|
3518
3586
|
|
|
3519
3587
|
const startCloudSubscription = (captured, candidate) => {
|
|
3520
3588
|
stopCloudSubscription();
|
|
3521
|
-
|
|
3589
|
+
const readyState = createRemoteReadyState();
|
|
3590
|
+
remoteReadyState = readyState;
|
|
3591
|
+
if (!initialized || hydrationRequired) {
|
|
3592
|
+
readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
|
|
3593
|
+
return;
|
|
3594
|
+
}
|
|
3595
|
+
if (!captured?.uid) {
|
|
3596
|
+
readyState.resolve({skipped: true, reason: 'anonymous'});
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
if (typeof cloud?.subscribe !== 'function') {
|
|
3600
|
+
readyState.resolve({skipped: true, reason: 'no_subscription'});
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3522
3603
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3523
3604
|
subscriptionContext = context;
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3605
|
+
let subscriptionReturned = false;
|
|
3606
|
+
let adapterReadyExpected = false;
|
|
3607
|
+
let firstCallbackResult = null;
|
|
3608
|
+
const onRemote = (remoteValue) => {
|
|
3609
|
+
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
3610
|
+
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
3611
|
+
}
|
|
3612
|
+
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
3613
|
+
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
3614
|
+
callbackResult.then((result) => {
|
|
3615
|
+
if (
|
|
3616
|
+
subscriptionReturned &&
|
|
3617
|
+
!adapterReadyExpected &&
|
|
3618
|
+
isCurrentRemoteReady(readyState)
|
|
3619
|
+
) {
|
|
3620
|
+
readyState.resolve({
|
|
3621
|
+
revision: workspace.remoteBaseline.revision,
|
|
3622
|
+
result
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
}).catch((error) => {
|
|
3626
|
+
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
3627
|
+
readyState.reject(error);
|
|
3628
|
+
}
|
|
3527
3629
|
logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
|
|
3528
|
-
return {success: false, error};
|
|
3529
3630
|
});
|
|
3530
|
-
|
|
3631
|
+
return callbackResult;
|
|
3632
|
+
};
|
|
3633
|
+
let disposer;
|
|
3634
|
+
try {
|
|
3635
|
+
disposer = cloud.subscribe(onRemote, context);
|
|
3636
|
+
} catch (error) {
|
|
3637
|
+
readyState.reject(error);
|
|
3638
|
+
logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
unsubscribeCloud = typeof disposer === 'function' ? disposer : null;
|
|
3642
|
+
adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
|
|
3643
|
+
subscriptionReturned = true;
|
|
3644
|
+
if (adapterReadyExpected) {
|
|
3645
|
+
Promise.resolve(disposer.ready).then((result) => {
|
|
3646
|
+
if (!isCurrentRemoteReady(readyState)) return;
|
|
3647
|
+
readyState.resolve({
|
|
3648
|
+
revision: workspace.remoteBaseline.revision,
|
|
3649
|
+
result
|
|
3650
|
+
});
|
|
3651
|
+
}).catch((error) => {
|
|
3652
|
+
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
3653
|
+
readyState.reject(error);
|
|
3654
|
+
}
|
|
3655
|
+
});
|
|
3656
|
+
} else if (firstCallbackResult) {
|
|
3657
|
+
firstCallbackResult.then((result) => {
|
|
3658
|
+
if (!isCurrentRemoteReady(readyState)) return;
|
|
3659
|
+
readyState.resolve({
|
|
3660
|
+
revision: workspace.remoteBaseline.revision,
|
|
3661
|
+
result
|
|
3662
|
+
});
|
|
3663
|
+
}).catch(() => null);
|
|
3664
|
+
}
|
|
3531
3665
|
};
|
|
3532
3666
|
|
|
3533
3667
|
const initialize = async () => {
|
|
3534
3668
|
const captured = capture();
|
|
3535
3669
|
const loaded = await storage.load();
|
|
3536
3670
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3537
|
-
//
|
|
3538
|
-
//
|
|
3539
|
-
//
|
|
3540
|
-
//
|
|
3671
|
+
// Read the raw owner and baseline before normalizeLoadedWorkspace adopts
|
|
3672
|
+
// the current session identity. Reuse still validates the normalized
|
|
3673
|
+
// candidate below because owner/epoch fences may replace loaded data with
|
|
3674
|
+
// an empty workspace.
|
|
3541
3675
|
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
3542
|
-
const
|
|
3676
|
+
const loadedBaseline = loaded?.remoteBaseline;
|
|
3677
|
+
const committedRevision = Number(loadedBaseline?.revision);
|
|
3678
|
+
const hasValidCommittedBaseline = rtlDurableIsObject(loadedBaseline) &&
|
|
3679
|
+
rtlDurableIsObject(loadedBaseline.state) &&
|
|
3680
|
+
Number.isFinite(committedRevision) &&
|
|
3681
|
+
committedRevision > 0;
|
|
3543
3682
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
3544
3683
|
const identityChecked = rtlQuarantineOperations(
|
|
3545
3684
|
candidate,
|
|
@@ -3552,7 +3691,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3552
3691
|
|
|
3553
3692
|
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
3554
3693
|
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
3555
|
-
|
|
3694
|
+
hasValidCommittedBaseline &&
|
|
3695
|
+
candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
|
|
3696
|
+
candidate.remoteBaseline.revision === committedRevision &&
|
|
3697
|
+
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
3556
3698
|
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
3557
3699
|
let bootstrap;
|
|
3558
3700
|
try {
|
|
@@ -3671,10 +3813,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3671
3813
|
type: 'local_applied',
|
|
3672
3814
|
operations: clone([...durableOperations, ...viewOperations]),
|
|
3673
3815
|
operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
|
|
3674
|
-
rejectedCount: identityChecked.rejectedCount
|
|
3816
|
+
rejectedCount: identityChecked.rejectedCount,
|
|
3817
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3818
|
+
});
|
|
3819
|
+
} else if (identityChecked.deduplicatedCount > 0 && identityChecked.rejectedCount === 0) {
|
|
3820
|
+
notify({
|
|
3821
|
+
type: 'operations_deduplicated',
|
|
3822
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3675
3823
|
});
|
|
3676
3824
|
} else {
|
|
3677
|
-
notify({
|
|
3825
|
+
notify({
|
|
3826
|
+
type: 'operations_rejected',
|
|
3827
|
+
rejectedCount: identityChecked.rejectedCount,
|
|
3828
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3829
|
+
});
|
|
3678
3830
|
}
|
|
3679
3831
|
return getSnapshot();
|
|
3680
3832
|
};
|
|
@@ -3868,6 +4020,21 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3868
4020
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
3869
4021
|
if (rtlDurableIsObject(responseState)) {
|
|
3870
4022
|
applyRemoteBaseline(candidate, responseBaseline);
|
|
4023
|
+
} else if (
|
|
4024
|
+
rejectedCount === 0 &&
|
|
4025
|
+
!sawRetryable &&
|
|
4026
|
+
appliedOperations.length === completedOperations.length &&
|
|
4027
|
+
appliedOperations.length > 0 &&
|
|
4028
|
+
Number.isFinite(responseRevision) &&
|
|
4029
|
+
responseRevision === candidate.remoteBaseline.revision + 1
|
|
4030
|
+
) {
|
|
4031
|
+
// The v2 gateway ACK is intentionally compact and omits state. Promote
|
|
4032
|
+
// only an all-applied transaction at exactly the next revision; any
|
|
4033
|
+
// larger jump still needs an authoritative bootstrap baseline.
|
|
4034
|
+
candidate.remoteBaseline.revision = responseRevision;
|
|
4035
|
+
if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
|
|
4036
|
+
candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
|
|
4037
|
+
}
|
|
3871
4038
|
} else if (
|
|
3872
4039
|
rejectedCount === 0 &&
|
|
3873
4040
|
Number.isFinite(responseRevision) &&
|
|
@@ -3930,6 +4097,120 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3930
4097
|
};
|
|
3931
4098
|
};
|
|
3932
4099
|
|
|
4100
|
+
// `sync()` intentionally sends only one immutable FIFO batch. Consumers
|
|
4101
|
+
// that own the outbox scan may use this method to drain subsequent batches
|
|
4102
|
+
// without reimplementing the stop conditions in each host application.
|
|
4103
|
+
// `beforeRound` is a host gate (hydration/import/account state); `runRound`
|
|
4104
|
+
// can serialize that gate check and the gateway call with host mutations.
|
|
4105
|
+
const syncUntilIdleInternal = async (reason, options = {}) => {
|
|
4106
|
+
const maxRounds = Number.isSafeInteger(Number(options?.maxRounds)) &&
|
|
4107
|
+
Number(options.maxRounds) > 0
|
|
4108
|
+
? Number(options.maxRounds)
|
|
4109
|
+
: RTL_MAX_SYNC_DRAIN_ROUNDS;
|
|
4110
|
+
const beforeRound = typeof options?.beforeRound === 'function'
|
|
4111
|
+
? options.beforeRound
|
|
4112
|
+
: null;
|
|
4113
|
+
const runRound = typeof options?.runRound === 'function'
|
|
4114
|
+
? options.runRound
|
|
4115
|
+
: null;
|
|
4116
|
+
const totals = {
|
|
4117
|
+
appliedCount: 0,
|
|
4118
|
+
syncedCount: 0,
|
|
4119
|
+
rejectedCount: 0,
|
|
4120
|
+
retryCount: 0,
|
|
4121
|
+
identityRejectedCount: 0
|
|
4122
|
+
};
|
|
4123
|
+
let lastResult = null;
|
|
4124
|
+
let rounds = 0;
|
|
4125
|
+
|
|
4126
|
+
for (let round = 0; round < maxRounds; round += 1) {
|
|
4127
|
+
rounds += 1;
|
|
4128
|
+
const executeRound = async () => {
|
|
4129
|
+
if (beforeRound) {
|
|
4130
|
+
const gate = await beforeRound({
|
|
4131
|
+
round,
|
|
4132
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4133
|
+
snapshot: getSnapshot()
|
|
4134
|
+
});
|
|
4135
|
+
if (gate === false || gate?.allowed === false) {
|
|
4136
|
+
const details = gate && typeof gate === 'object' ? {...gate} : {};
|
|
4137
|
+
delete details.allowed;
|
|
4138
|
+
return {
|
|
4139
|
+
...details,
|
|
4140
|
+
success: true,
|
|
4141
|
+
deferred: true,
|
|
4142
|
+
pendingSync: workspace.pendingOperations.length > 0,
|
|
4143
|
+
reason: details.reason || 'sync_gate_blocked',
|
|
4144
|
+
pendingCount: workspace.pendingOperations.length
|
|
4145
|
+
};
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
return syncInternal(reason);
|
|
4149
|
+
};
|
|
4150
|
+
const result = runRound
|
|
4151
|
+
? await runRound({
|
|
4152
|
+
round,
|
|
4153
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4154
|
+
run: executeRound
|
|
4155
|
+
})
|
|
4156
|
+
: await executeRound();
|
|
4157
|
+
lastResult = result && typeof result === 'object'
|
|
4158
|
+
? result
|
|
4159
|
+
: {success: false, reason: 'sync_round_missing_result'};
|
|
4160
|
+
Object.keys(totals).forEach((key) => {
|
|
4161
|
+
const value = Number(lastResult?.[key]);
|
|
4162
|
+
if (Number.isFinite(value)) totals[key] += value;
|
|
4163
|
+
});
|
|
4164
|
+
|
|
4165
|
+
const pendingCount = Number(lastResult?.pendingCount ?? workspace.pendingOperations.length);
|
|
4166
|
+
const madeProgress = Number(lastResult?.syncedCount || lastResult?.appliedCount || 0) > 0 ||
|
|
4167
|
+
Number(lastResult?.rejectedCount || 0) > 0 ||
|
|
4168
|
+
Number(lastResult?.retryCount || 0) > 0 ||
|
|
4169
|
+
Number(lastResult?.identityRejectedCount || 0) > 0;
|
|
4170
|
+
if (
|
|
4171
|
+
lastResult.deferred ||
|
|
4172
|
+
lastResult.success === false ||
|
|
4173
|
+
lastResult.retryable ||
|
|
4174
|
+
lastResult.protocolError ||
|
|
4175
|
+
lastResult.skipped ||
|
|
4176
|
+
lastResult.retryCount > 0 ||
|
|
4177
|
+
pendingCount === 0 ||
|
|
4178
|
+
!madeProgress
|
|
4179
|
+
) break;
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
const pendingCount = Number(lastResult?.pendingCount ?? workspace.pendingOperations.length);
|
|
4183
|
+
const result = {
|
|
4184
|
+
...(lastResult || {success: true}),
|
|
4185
|
+
...totals,
|
|
4186
|
+
pendingCount,
|
|
4187
|
+
syncDrainRounds: rounds,
|
|
4188
|
+
drained: pendingCount === 0
|
|
4189
|
+
};
|
|
4190
|
+
if (
|
|
4191
|
+
pendingCount > 0 &&
|
|
4192
|
+
rounds >= maxRounds &&
|
|
4193
|
+
!result.deferred &&
|
|
4194
|
+
result.success !== false &&
|
|
4195
|
+
!result.retryable &&
|
|
4196
|
+
!result.skipped
|
|
4197
|
+
) {
|
|
4198
|
+
const retryAt = now() + RTL_SYNC_DRAIN_RETRY_DELAY_MS;
|
|
4199
|
+
return {
|
|
4200
|
+
...result,
|
|
4201
|
+
success: false,
|
|
4202
|
+
retryable: true,
|
|
4203
|
+
pendingSync: true,
|
|
4204
|
+
reason: 'sync_drain_limit_reached',
|
|
4205
|
+
syncDrainLimitReached: true,
|
|
4206
|
+
drainLimitReached: true,
|
|
4207
|
+
retryAfterMs: RTL_SYNC_DRAIN_RETRY_DELAY_MS,
|
|
4208
|
+
retryAt
|
|
4209
|
+
};
|
|
4210
|
+
}
|
|
4211
|
+
return result;
|
|
4212
|
+
};
|
|
4213
|
+
|
|
3933
4214
|
const engine = {
|
|
3934
4215
|
init() {
|
|
3935
4216
|
return enqueue(async () => {
|
|
@@ -3946,6 +4227,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3946
4227
|
return enqueue(() => syncInternal(reason));
|
|
3947
4228
|
},
|
|
3948
4229
|
|
|
4230
|
+
syncUntilIdle(reason, options = {}) {
|
|
4231
|
+
return enqueue(() => syncUntilIdleInternal(reason, options));
|
|
4232
|
+
},
|
|
4233
|
+
|
|
4234
|
+
waitForIdle() {
|
|
4235
|
+
return queue.catch(() => null);
|
|
4236
|
+
},
|
|
4237
|
+
|
|
4238
|
+
waitForRemoteReady() {
|
|
4239
|
+
if (destroyed) {
|
|
4240
|
+
return Promise.resolve({
|
|
4241
|
+
success: false,
|
|
4242
|
+
stale: true,
|
|
4243
|
+
reason: 'stale_session',
|
|
4244
|
+
generation: remoteReadyState.generation
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4247
|
+
return remoteReadyState.promise;
|
|
4248
|
+
},
|
|
4249
|
+
|
|
3949
4250
|
getSnapshot,
|
|
3950
4251
|
|
|
3951
4252
|
subscribe(listener) {
|
|
@@ -4996,6 +5297,8 @@ export default {
|
|
|
4996
5297
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
4997
5298
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
4998
5299
|
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
5300
|
+
RTL_MAX_SYNC_DRAIN_ROUNDS,
|
|
5301
|
+
RTL_SYNC_DRAIN_RETRY_DELAY_MS,
|
|
4999
5302
|
RTL_MAX_REQUEST_BYTES,
|
|
5000
5303
|
RTL_MAX_TARGET_WRITES,
|
|
5001
5304
|
RTL_TRASH_RETENTION_MS,
|