@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.
Files changed (3) hide show
  1. package/README.md +22 -9
  2. package/package.json +1 -1
  3. 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.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.4",
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.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) 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) => {
@@ -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 = requireBootstrapRevision(
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
- if (captured?.uid && typeof cloud?.bootstrap === 'function') {
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({type: 'operations_rejected', rejectedCount: identityChecked.rejectedCount});
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 = requireBootstrapRevision(
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,