@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.
Files changed (3) hide show
  1. package/README.md +30 -9
  2. package/package.json +1 -1
  3. 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.4` (publish it before updating consumers):
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.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 but advances
125
- `remoteBaseline.revision` only with a complete state; revision gaps trigger a fresh bootstrap.
126
-
127
- The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
128
- `subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
129
- operations over `remoteBaseline.state`; rejected operations are kept in diagnostics and are not
130
- replayed. Session tokens are used for fencing but are never persisted. The legacy
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
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.5';
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) return;
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 whenever it fails or is unavailable.
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
- if (remoteRevision > candidate.remoteBaseline.revision + 1) {
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 stopCloudSubscription = () => {
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
- if (typeof cloud?.subscribe !== 'function' || !initialized || hydrationRequired) return;
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
- unsubscribeCloud = cloud.subscribe((remoteValue) => {
3525
- if (destroyed) return;
3526
- return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
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
- }, context);
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
- // The reuse predicate must read the RAW loaded owner before
3538
- // normalizeLoadedWorkspace adopts the current session identity onto the
3539
- // workspace; epochs are intentionally not compared (consumers may advance
3540
- // the persisted epoch on every launch).
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 committedRevision = Number(loaded?.remoteBaseline?.revision || 0);
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
- committedRevision > 0;
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({type: 'operations_rejected', rejectedCount: identityChecked.rejectedCount});
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,