@recordtimelabel/core 0.4.5 → 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.
Files changed (3) hide show
  1. package/README.md +22 -9
  2. package/package.json +1 -1
  3. package/src/index.js +180 -12
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,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 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()`, `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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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,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.5';
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) return;
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) => {
@@ -3287,7 +3293,9 @@ export const createRecordTimeLabelSyncEngine = ({
3287
3293
  // normalizeBootstrapResponse accepts) or `{success: false,
3288
3294
  // bootstrapRequired: true}`; it may throw on transport errors. Gap
3289
3295
  // recovery prefers it over `cloud.bootstrap` and falls back to a fresh
3290
- // bootstrap walk whenever it fails or is unavailable.
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.
3291
3299
  const recoverBaseline = async (context, targetRevision, mode) => {
3292
3300
  if (typeof cloud?.catchUp === 'function') {
3293
3301
  try {
@@ -3300,6 +3308,9 @@ export const createRecordTimeLabelSyncEngine = ({
3300
3308
  return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
3301
3309
  }
3302
3310
  } catch (error) {
3311
+ if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
3312
+ throw error;
3313
+ }
3303
3314
  logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
3304
3315
  }
3305
3316
  }
@@ -3534,12 +3545,17 @@ export const createRecordTimeLabelSyncEngine = ({
3534
3545
  const captured = capture();
3535
3546
  const loaded = await storage.load();
3536
3547
  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).
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.
3541
3552
  const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
3542
- const committedRevision = Number(loaded?.remoteBaseline?.revision || 0);
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;
3543
3559
  let candidate = normalizeLoadedWorkspace(loaded, captured);
3544
3560
  const identityChecked = rtlQuarantineOperations(
3545
3561
  candidate,
@@ -3552,7 +3568,10 @@ export const createRecordTimeLabelSyncEngine = ({
3552
3568
 
3553
3569
  const canReuseCommitted = initialBaseline === 'reuse-committed' &&
3554
3570
  captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
3555
- committedRevision > 0;
3571
+ hasValidCommittedBaseline &&
3572
+ candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
3573
+ candidate.remoteBaseline.revision === committedRevision &&
3574
+ (!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
3556
3575
  if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
3557
3576
  let bootstrap;
3558
3577
  try {
@@ -3671,10 +3690,20 @@ export const createRecordTimeLabelSyncEngine = ({
3671
3690
  type: 'local_applied',
3672
3691
  operations: clone([...durableOperations, ...viewOperations]),
3673
3692
  operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
3674
- 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
3675
3700
  });
3676
3701
  } else {
3677
- notify({type: 'operations_rejected', rejectedCount: identityChecked.rejectedCount});
3702
+ notify({
3703
+ type: 'operations_rejected',
3704
+ rejectedCount: identityChecked.rejectedCount,
3705
+ deduplicatedCount: identityChecked.deduplicatedCount
3706
+ });
3678
3707
  }
3679
3708
  return getSnapshot();
3680
3709
  };
@@ -3868,6 +3897,21 @@ export const createRecordTimeLabelSyncEngine = ({
3868
3897
  const responseState = responseBaseline?.state ?? responseBaseline?.data;
3869
3898
  if (rtlDurableIsObject(responseState)) {
3870
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
+ }
3871
3915
  } else if (
3872
3916
  rejectedCount === 0 &&
3873
3917
  Number.isFinite(responseRevision) &&
@@ -3930,6 +3974,120 @@ export const createRecordTimeLabelSyncEngine = ({
3930
3974
  };
3931
3975
  };
3932
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
+
3933
4091
  const engine = {
3934
4092
  init() {
3935
4093
  return enqueue(async () => {
@@ -3946,6 +4104,14 @@ export const createRecordTimeLabelSyncEngine = ({
3946
4104
  return enqueue(() => syncInternal(reason));
3947
4105
  },
3948
4106
 
4107
+ syncUntilIdle(reason, options = {}) {
4108
+ return enqueue(() => syncUntilIdleInternal(reason, options));
4109
+ },
4110
+
4111
+ waitForIdle() {
4112
+ return queue.catch(() => null);
4113
+ },
4114
+
3949
4115
  getSnapshot,
3950
4116
 
3951
4117
  subscribe(listener) {
@@ -4996,6 +5162,8 @@ export default {
4996
5162
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
4997
5163
  RTL_SYNC_PROTOCOL_VERSION,
4998
5164
  RTL_MAX_OPERATIONS_PER_REQUEST,
5165
+ RTL_MAX_SYNC_DRAIN_ROUNDS,
5166
+ RTL_SYNC_DRAIN_RETRY_DELAY_MS,
4999
5167
  RTL_MAX_REQUEST_BYTES,
5000
5168
  RTL_MAX_TARGET_WRITES,
5001
5169
  RTL_TRASH_RETENTION_MS,