@recordtimelabel/core 0.4.4 → 0.4.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.
- package/README.md +22 -9
- package/package.json +1 -1
- package/src/index.js +221 -21
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,24 @@ 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()`, `getSnapshot()`, `subscribe(listener)`,
|
|
135
|
+
and `destroy()`. `sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
|
|
136
|
+
subsequent batches up to `RTL_MAX_SYNC_DRAIN_ROUNDS`; it stops on retry/deferred/protocol
|
|
137
|
+
conditions and reports `syncDrainLimitReached` as a retryable failure instead of silently
|
|
138
|
+
reporting success. Hosts may provide `beforeRound({round, pendingCount, snapshot})` to block
|
|
139
|
+
gateway writes while a host-specific hydration/import gate is active, and `runRound({round,
|
|
140
|
+
pendingCount, run})` to serialize that gate check and the gateway call with host mutations.
|
|
141
|
+
`getSnapshot().state` is derived by replaying pending operations over `remoteBaseline.state`;
|
|
142
|
+
rejected operations are kept in diagnostics and are not replayed. Session tokens are used for
|
|
143
|
+
fencing but are never persisted. The legacy
|
|
131
144
|
`createSyncEngine` and app adapters remain available and are not implicitly migrated by this API.
|
|
132
145
|
|
|
133
146
|
`expandedGroups` is a local view projection, not durable cloud domain state. Durable workspaces,
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -31,7 +31,7 @@ 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.6';
|
|
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',
|
|
@@ -42,6 +42,8 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
42
42
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
43
43
|
]);
|
|
44
44
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
45
|
+
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
46
|
+
export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
|
|
45
47
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
46
48
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
47
49
|
export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -2954,6 +2956,7 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2954
2956
|
});
|
|
2955
2957
|
}
|
|
2956
2958
|
let rejectedCount = 0;
|
|
2959
|
+
let deduplicatedCount = 0;
|
|
2957
2960
|
toArray(operations).forEach((operation, index) => {
|
|
2958
2961
|
const id = normalizeId(operation?.id);
|
|
2959
2962
|
const fingerprint = rtlOperationWireFingerprint(operation);
|
|
@@ -2961,7 +2964,10 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2961
2964
|
let reason = rtlOperationIdentityReason(operation, workspace);
|
|
2962
2965
|
if (!reason) reason = rtlLifecycleOperationReason(operation, lifecycleState);
|
|
2963
2966
|
if (!reason && knownFingerprint) {
|
|
2964
|
-
if (knownFingerprint === fingerprint)
|
|
2967
|
+
if (knownFingerprint === fingerprint) {
|
|
2968
|
+
deduplicatedCount += 1;
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2965
2971
|
reason = RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.ID_CONFLICT;
|
|
2966
2972
|
}
|
|
2967
2973
|
if (!reason) {
|
|
@@ -2991,7 +2997,7 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2991
2997
|
}
|
|
2992
2998
|
};
|
|
2993
2999
|
});
|
|
2994
|
-
return {accepted, rejected, rejectedCount};
|
|
3000
|
+
return {accepted, rejected, rejectedCount, deduplicatedCount};
|
|
2995
3001
|
};
|
|
2996
3002
|
|
|
2997
3003
|
const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
@@ -3134,11 +3140,15 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3134
3140
|
session,
|
|
3135
3141
|
client = 'recordtimelabel-client',
|
|
3136
3142
|
clock = () => Date.now(),
|
|
3137
|
-
logger = console
|
|
3143
|
+
logger = console,
|
|
3144
|
+
initialBaseline = 'require-fresh'
|
|
3138
3145
|
} = {}) => {
|
|
3139
3146
|
if (!storage?.load || !storage?.save) {
|
|
3140
3147
|
throw new Error('createRecordTimeLabelSyncEngine requires storage.load() and storage.save()');
|
|
3141
3148
|
}
|
|
3149
|
+
if (initialBaseline !== 'require-fresh' && initialBaseline !== 'reuse-committed') {
|
|
3150
|
+
throw new Error('recordtimelabel_invalid_initial_baseline_policy');
|
|
3151
|
+
}
|
|
3142
3152
|
|
|
3143
3153
|
let workspace = rtlEmptyDurableWorkspace();
|
|
3144
3154
|
let initialized = false;
|
|
@@ -3277,6 +3287,42 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3277
3287
|
return baseline;
|
|
3278
3288
|
};
|
|
3279
3289
|
|
|
3290
|
+
// Optional bounded catch-up port. `cloud.catchUp(context, {fromRevision,
|
|
3291
|
+
// targetRevision, attemptId, mode})` resolves a FULL baseline object
|
|
3292
|
+
// (`{success: true, state, revision, changeCursor?}`, the same shape
|
|
3293
|
+
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3294
|
+
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3295
|
+
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3296
|
+
// bootstrap walk on transport/protocol failure. A successful but stale
|
|
3297
|
+
// catch-up response is fail-closed: falling back would hide a revision
|
|
3298
|
+
// contract violation and could apply a baseline older than the ACK.
|
|
3299
|
+
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
3300
|
+
if (typeof cloud?.catchUp === 'function') {
|
|
3301
|
+
try {
|
|
3302
|
+
const caught = await cloud.catchUp(context, {
|
|
3303
|
+
fromRevision: workspace.remoteBaseline.revision,
|
|
3304
|
+
targetRevision,
|
|
3305
|
+
...bootstrapAttemptOptions(`${mode}-catchup`)
|
|
3306
|
+
});
|
|
3307
|
+
if (rtlEnvelopeSuccess(caught)) {
|
|
3308
|
+
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3309
|
+
}
|
|
3310
|
+
} catch (error) {
|
|
3311
|
+
if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
|
|
3312
|
+
throw error;
|
|
3313
|
+
}
|
|
3314
|
+
logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
return requireBootstrapRevision(
|
|
3318
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3319
|
+
context,
|
|
3320
|
+
bootstrapAttemptOptions(mode)
|
|
3321
|
+
)),
|
|
3322
|
+
targetRevision
|
|
3323
|
+
);
|
|
3324
|
+
};
|
|
3325
|
+
|
|
3280
3326
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
3281
3327
|
const source = loaded && typeof loaded === 'object' ? loaded : {};
|
|
3282
3328
|
const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
|
|
@@ -3460,13 +3506,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3460
3506
|
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3461
3507
|
}
|
|
3462
3508
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3463
|
-
baselineValue =
|
|
3464
|
-
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3465
|
-
context,
|
|
3466
|
-
bootstrapAttemptOptions('gap-recovery')
|
|
3467
|
-
)),
|
|
3468
|
-
remoteRevision
|
|
3469
|
-
);
|
|
3509
|
+
baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
|
|
3470
3510
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3471
3511
|
}
|
|
3472
3512
|
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
@@ -3505,6 +3545,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3505
3545
|
const captured = capture();
|
|
3506
3546
|
const loaded = await storage.load();
|
|
3507
3547
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3548
|
+
// Read the raw owner and baseline before normalizeLoadedWorkspace adopts
|
|
3549
|
+
// the current session identity. Reuse still validates the normalized
|
|
3550
|
+
// candidate below because owner/epoch fences may replace loaded data with
|
|
3551
|
+
// an empty workspace.
|
|
3552
|
+
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
3553
|
+
const loadedBaseline = loaded?.remoteBaseline;
|
|
3554
|
+
const committedRevision = Number(loadedBaseline?.revision);
|
|
3555
|
+
const hasValidCommittedBaseline = rtlDurableIsObject(loadedBaseline) &&
|
|
3556
|
+
rtlDurableIsObject(loadedBaseline.state) &&
|
|
3557
|
+
Number.isFinite(committedRevision) &&
|
|
3558
|
+
committedRevision > 0;
|
|
3508
3559
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
3509
3560
|
const identityChecked = rtlQuarantineOperations(
|
|
3510
3561
|
candidate,
|
|
@@ -3515,7 +3566,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3515
3566
|
candidate.rejectedOperations = identityChecked.rejected;
|
|
3516
3567
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3517
3568
|
|
|
3518
|
-
|
|
3569
|
+
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
3570
|
+
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
3571
|
+
hasValidCommittedBaseline &&
|
|
3572
|
+
candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
|
|
3573
|
+
candidate.remoteBaseline.revision === committedRevision &&
|
|
3574
|
+
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
3575
|
+
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
3519
3576
|
let bootstrap;
|
|
3520
3577
|
try {
|
|
3521
3578
|
bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
@@ -3633,10 +3690,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3633
3690
|
type: 'local_applied',
|
|
3634
3691
|
operations: clone([...durableOperations, ...viewOperations]),
|
|
3635
3692
|
operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
|
|
3636
|
-
rejectedCount: identityChecked.rejectedCount
|
|
3693
|
+
rejectedCount: identityChecked.rejectedCount,
|
|
3694
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3695
|
+
});
|
|
3696
|
+
} else if (identityChecked.deduplicatedCount > 0 && identityChecked.rejectedCount === 0) {
|
|
3697
|
+
notify({
|
|
3698
|
+
type: 'operations_deduplicated',
|
|
3699
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3637
3700
|
});
|
|
3638
3701
|
} else {
|
|
3639
|
-
notify({
|
|
3702
|
+
notify({
|
|
3703
|
+
type: 'operations_rejected',
|
|
3704
|
+
rejectedCount: identityChecked.rejectedCount,
|
|
3705
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3706
|
+
});
|
|
3640
3707
|
}
|
|
3641
3708
|
return getSnapshot();
|
|
3642
3709
|
};
|
|
@@ -3830,19 +3897,28 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3830
3897
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
3831
3898
|
if (rtlDurableIsObject(responseState)) {
|
|
3832
3899
|
applyRemoteBaseline(candidate, responseBaseline);
|
|
3900
|
+
} else if (
|
|
3901
|
+
rejectedCount === 0 &&
|
|
3902
|
+
!sawRetryable &&
|
|
3903
|
+
appliedOperations.length === completedOperations.length &&
|
|
3904
|
+
appliedOperations.length > 0 &&
|
|
3905
|
+
Number.isFinite(responseRevision) &&
|
|
3906
|
+
responseRevision === candidate.remoteBaseline.revision + 1
|
|
3907
|
+
) {
|
|
3908
|
+
// The v2 gateway ACK is intentionally compact and omits state. Promote
|
|
3909
|
+
// only an all-applied transaction at exactly the next revision; any
|
|
3910
|
+
// larger jump still needs an authoritative bootstrap baseline.
|
|
3911
|
+
candidate.remoteBaseline.revision = responseRevision;
|
|
3912
|
+
if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
|
|
3913
|
+
candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
|
|
3914
|
+
}
|
|
3833
3915
|
} else if (
|
|
3834
3916
|
rejectedCount === 0 &&
|
|
3835
3917
|
Number.isFinite(responseRevision) &&
|
|
3836
3918
|
responseRevision > candidate.remoteBaseline.revision + 1 &&
|
|
3837
3919
|
typeof cloud?.bootstrap === 'function'
|
|
3838
3920
|
) {
|
|
3839
|
-
const freshBaseline =
|
|
3840
|
-
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3841
|
-
context,
|
|
3842
|
-
bootstrapAttemptOptions('gap-recovery')
|
|
3843
|
-
)),
|
|
3844
|
-
responseRevision
|
|
3845
|
-
);
|
|
3921
|
+
const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
|
|
3846
3922
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3847
3923
|
applyRemoteBaseline(candidate, freshBaseline);
|
|
3848
3924
|
}
|
|
@@ -3898,6 +3974,120 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3898
3974
|
};
|
|
3899
3975
|
};
|
|
3900
3976
|
|
|
3977
|
+
// `sync()` intentionally sends only one immutable FIFO batch. Consumers
|
|
3978
|
+
// that own the outbox scan may use this method to drain subsequent batches
|
|
3979
|
+
// without reimplementing the stop conditions in each host application.
|
|
3980
|
+
// `beforeRound` is a host gate (hydration/import/account state); `runRound`
|
|
3981
|
+
// can serialize that gate check and the gateway call with host mutations.
|
|
3982
|
+
const syncUntilIdleInternal = async (reason, options = {}) => {
|
|
3983
|
+
const maxRounds = Number.isSafeInteger(Number(options?.maxRounds)) &&
|
|
3984
|
+
Number(options.maxRounds) > 0
|
|
3985
|
+
? Number(options.maxRounds)
|
|
3986
|
+
: RTL_MAX_SYNC_DRAIN_ROUNDS;
|
|
3987
|
+
const beforeRound = typeof options?.beforeRound === 'function'
|
|
3988
|
+
? options.beforeRound
|
|
3989
|
+
: null;
|
|
3990
|
+
const runRound = typeof options?.runRound === 'function'
|
|
3991
|
+
? options.runRound
|
|
3992
|
+
: null;
|
|
3993
|
+
const totals = {
|
|
3994
|
+
appliedCount: 0,
|
|
3995
|
+
syncedCount: 0,
|
|
3996
|
+
rejectedCount: 0,
|
|
3997
|
+
retryCount: 0,
|
|
3998
|
+
identityRejectedCount: 0
|
|
3999
|
+
};
|
|
4000
|
+
let lastResult = null;
|
|
4001
|
+
let rounds = 0;
|
|
4002
|
+
|
|
4003
|
+
for (let round = 0; round < maxRounds; round += 1) {
|
|
4004
|
+
rounds += 1;
|
|
4005
|
+
const executeRound = async () => {
|
|
4006
|
+
if (beforeRound) {
|
|
4007
|
+
const gate = await beforeRound({
|
|
4008
|
+
round,
|
|
4009
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4010
|
+
snapshot: getSnapshot()
|
|
4011
|
+
});
|
|
4012
|
+
if (gate === false || gate?.allowed === false) {
|
|
4013
|
+
const details = gate && typeof gate === 'object' ? {...gate} : {};
|
|
4014
|
+
delete details.allowed;
|
|
4015
|
+
return {
|
|
4016
|
+
...details,
|
|
4017
|
+
success: true,
|
|
4018
|
+
deferred: true,
|
|
4019
|
+
pendingSync: workspace.pendingOperations.length > 0,
|
|
4020
|
+
reason: details.reason || 'sync_gate_blocked',
|
|
4021
|
+
pendingCount: workspace.pendingOperations.length
|
|
4022
|
+
};
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
return syncInternal(reason);
|
|
4026
|
+
};
|
|
4027
|
+
const result = runRound
|
|
4028
|
+
? await runRound({
|
|
4029
|
+
round,
|
|
4030
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4031
|
+
run: executeRound
|
|
4032
|
+
})
|
|
4033
|
+
: await executeRound();
|
|
4034
|
+
lastResult = result && typeof result === 'object'
|
|
4035
|
+
? result
|
|
4036
|
+
: {success: false, reason: 'sync_round_missing_result'};
|
|
4037
|
+
Object.keys(totals).forEach((key) => {
|
|
4038
|
+
const value = Number(lastResult?.[key]);
|
|
4039
|
+
if (Number.isFinite(value)) totals[key] += value;
|
|
4040
|
+
});
|
|
4041
|
+
|
|
4042
|
+
const pendingCount = Number(lastResult?.pendingCount ?? workspace.pendingOperations.length);
|
|
4043
|
+
const madeProgress = Number(lastResult?.syncedCount || lastResult?.appliedCount || 0) > 0 ||
|
|
4044
|
+
Number(lastResult?.rejectedCount || 0) > 0 ||
|
|
4045
|
+
Number(lastResult?.retryCount || 0) > 0 ||
|
|
4046
|
+
Number(lastResult?.identityRejectedCount || 0) > 0;
|
|
4047
|
+
if (
|
|
4048
|
+
lastResult.deferred ||
|
|
4049
|
+
lastResult.success === false ||
|
|
4050
|
+
lastResult.retryable ||
|
|
4051
|
+
lastResult.protocolError ||
|
|
4052
|
+
lastResult.skipped ||
|
|
4053
|
+
lastResult.retryCount > 0 ||
|
|
4054
|
+
pendingCount === 0 ||
|
|
4055
|
+
!madeProgress
|
|
4056
|
+
) break;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
const pendingCount = Number(lastResult?.pendingCount ?? workspace.pendingOperations.length);
|
|
4060
|
+
const result = {
|
|
4061
|
+
...(lastResult || {success: true}),
|
|
4062
|
+
...totals,
|
|
4063
|
+
pendingCount,
|
|
4064
|
+
syncDrainRounds: rounds,
|
|
4065
|
+
drained: pendingCount === 0
|
|
4066
|
+
};
|
|
4067
|
+
if (
|
|
4068
|
+
pendingCount > 0 &&
|
|
4069
|
+
rounds >= maxRounds &&
|
|
4070
|
+
!result.deferred &&
|
|
4071
|
+
result.success !== false &&
|
|
4072
|
+
!result.retryable &&
|
|
4073
|
+
!result.skipped
|
|
4074
|
+
) {
|
|
4075
|
+
const retryAt = now() + RTL_SYNC_DRAIN_RETRY_DELAY_MS;
|
|
4076
|
+
return {
|
|
4077
|
+
...result,
|
|
4078
|
+
success: false,
|
|
4079
|
+
retryable: true,
|
|
4080
|
+
pendingSync: true,
|
|
4081
|
+
reason: 'sync_drain_limit_reached',
|
|
4082
|
+
syncDrainLimitReached: true,
|
|
4083
|
+
drainLimitReached: true,
|
|
4084
|
+
retryAfterMs: RTL_SYNC_DRAIN_RETRY_DELAY_MS,
|
|
4085
|
+
retryAt
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
return result;
|
|
4089
|
+
};
|
|
4090
|
+
|
|
3901
4091
|
const engine = {
|
|
3902
4092
|
init() {
|
|
3903
4093
|
return enqueue(async () => {
|
|
@@ -3914,6 +4104,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3914
4104
|
return enqueue(() => syncInternal(reason));
|
|
3915
4105
|
},
|
|
3916
4106
|
|
|
4107
|
+
syncUntilIdle(reason, options = {}) {
|
|
4108
|
+
return enqueue(() => syncUntilIdleInternal(reason, options));
|
|
4109
|
+
},
|
|
4110
|
+
|
|
4111
|
+
waitForIdle() {
|
|
4112
|
+
return queue.catch(() => null);
|
|
4113
|
+
},
|
|
4114
|
+
|
|
3917
4115
|
getSnapshot,
|
|
3918
4116
|
|
|
3919
4117
|
subscribe(listener) {
|
|
@@ -4964,6 +5162,8 @@ export default {
|
|
|
4964
5162
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
4965
5163
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
4966
5164
|
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
5165
|
+
RTL_MAX_SYNC_DRAIN_ROUNDS,
|
|
5166
|
+
RTL_SYNC_DRAIN_RETRY_DELAY_MS,
|
|
4967
5167
|
RTL_MAX_REQUEST_BYTES,
|
|
4968
5168
|
RTL_MAX_TARGET_WRITES,
|
|
4969
5169
|
RTL_TRASH_RETENTION_MS,
|