@recordtimelabel/core 0.4.3 → 0.4.5

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 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 strict durable transport contract is prepared in package version `0.4.3` (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.4` (publish it before updating consumers):
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.4.3"
23
+ "@recordtimelabel/core": "0.4.4"
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.
@@ -111,14 +111,18 @@ Pending operations also carry an internal `syncBatchId`. Operations dispatched i
111
111
  one call share a batch boundary, while legacy pending operations are assigned one
112
112
  stable boundary during migration. The engine sends only the first FIFO batch boundary,
113
113
  capped at 20 operations, and derives a stable request ID from that boundary and the
114
- ordered operation IDs. `syncBatchId`, identity fences, and retry scheduling fields stay
115
- outside canonical wire operations.
114
+ ordered canonical wire operations. Durable retry metadata does not affect request identity,
115
+ while reusing an operation ID with different wire content is quarantined. A bounded history
116
+ of completed operation fingerprints is kept in `syncMeta`; `syncBatchId`, identity fences,
117
+ and retry scheduling fields stay outside canonical wire operations.
116
118
 
117
119
  Bootstrap attempts use `initial-hydration`, `rejection-rebase`, or `gap-recovery`
118
120
  modes with a unique `attemptId`; all require a fresh logical attempt. Structured
119
121
  failures, missing state, and missing/invalid revisions fail closed. UID or workspace
120
122
  epoch changes clear the visible workspace and require `init()` before another dispatch
121
- or sync; token refresh for the same UID/epoch only refreshes the subscription fence.
123
+ 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.
122
126
 
123
127
  The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
124
128
  `subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
@@ -149,7 +153,7 @@ durable state.
149
153
  Gateway-only compatibility code may opt into successful legacy envelopes with
150
154
  `{allowLegacySuccessWithoutResults: true}`. New durable clients never enable that fallback.
151
155
  Capabilities `operation-conflict-quarantine`, `strict-operation-results`, and
152
- `lifecycle-generation-fence` gate the corresponding 0.4.3 behavior. Single
156
+ `lifecycle-generation-fence` gate the corresponding 0.4.4 behavior. Single
153
157
  `record.restore`, `folder.restore`, and `trash.purge` operations require a positive
154
158
  `expectedGeneration`; batch lifecycle operations retain their existing contract. The gateway
155
159
  validator derives enforcement from `client.capabilities`. The planner is strict by default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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.3';
34
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.4.5';
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',
@@ -2648,6 +2648,7 @@ export const createRecordTimeLabelController = ({
2648
2648
  const RTL_DURABLE_SCHEMA_VERSION = 1;
2649
2649
  const RTL_RETRY_BASE_MS = 1000;
2650
2650
  const RTL_RETRY_MAX_MS = 60 * 1000;
2651
+ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
2651
2652
 
2652
2653
  const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
2653
2654
 
@@ -2763,13 +2764,10 @@ const rtlNormalizePendingOperation = (operation, options = {}) => {
2763
2764
  };
2764
2765
 
2765
2766
  const rtlStableSyncBatchId = (operations = []) => {
2766
- const source = toArray(operations).map((operation) => normalizeId(operation?.id)).join('|');
2767
- let hash = 2166136261;
2768
- for (let index = 0; index < source.length; index += 1) {
2769
- hash ^= source.charCodeAt(index);
2770
- hash = Math.imul(hash, 16777619);
2771
- }
2772
- return `durable-batch:${(hash >>> 0).toString(16).padStart(8, '0')}`;
2767
+ return buildRecordTimeLabelRequestId(
2768
+ 'durable-batch',
2769
+ toArray(operations).map((operation) => toRecordTimeLabelWireOperation(operation))
2770
+ );
2773
2771
  };
2774
2772
 
2775
2773
  const rtlNormalizeRemoteBaseline = (value = {}) => {
@@ -2883,6 +2881,34 @@ const rtlOperationWireFingerprint = (operation) => JSON.stringify(
2883
2881
  canonicalizeFirestoreV2DocumentValue(toRecordTimeLabelWireOperation(operation))
2884
2882
  );
2885
2883
 
2884
+ const rtlCompletedOperationFingerprint = (value) => (
2885
+ typeof value === 'string' ? value : value?.fingerprint
2886
+ );
2887
+
2888
+ const rtlRememberCompletedOperations = (workspace, completedOperations, timestamp) => {
2889
+ const existing = rtlDurableIsObject(workspace?.syncMeta?.completedOperationFingerprints)
2890
+ ? workspace.syncMeta.completedOperationFingerprints
2891
+ : {};
2892
+ const history = {...existing};
2893
+ toArray(completedOperations).forEach(({operation, status}) => {
2894
+ const id = normalizeId(operation?.id);
2895
+ if (!id) return;
2896
+ delete history[id];
2897
+ history[id] = {
2898
+ fingerprint: rtlOperationWireFingerprint(operation),
2899
+ completedAt: timestamp,
2900
+ status
2901
+ };
2902
+ });
2903
+ const boundedHistory = Object.fromEntries(
2904
+ Object.entries(history).slice(-RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT)
2905
+ );
2906
+ workspace.syncMeta = {
2907
+ ...(workspace.syncMeta || {}),
2908
+ completedOperationFingerprints: boundedHistory
2909
+ };
2910
+ };
2911
+
2886
2912
  const rtlLifecycleOperationReason = (operation, state) => {
2887
2913
  if (![
2888
2914
  OPERATION_TYPES.RECORD_RESTORE,
@@ -2905,6 +2931,19 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
2905
2931
  const accepted = [];
2906
2932
  const rejected = {...(workspace?.rejectedOperations || {})};
2907
2933
  const knownById = new Map();
2934
+ Object.entries(workspace?.syncMeta?.completedOperationFingerprints || {}).forEach(([rawId, value]) => {
2935
+ const id = normalizeId(rawId);
2936
+ const fingerprint = rtlCompletedOperationFingerprint(value);
2937
+ if (id && typeof fingerprint === 'string' && !knownById.has(id)) {
2938
+ knownById.set(id, fingerprint);
2939
+ }
2940
+ });
2941
+ Object.values(workspace?.rejectedOperations || {}).forEach((entry) => {
2942
+ const id = normalizeId(entry?.operation?.id || entry?.id);
2943
+ if (id && entry?.operation && !knownById.has(id)) {
2944
+ knownById.set(id, rtlOperationWireFingerprint(entry.operation));
2945
+ }
2946
+ });
2908
2947
  let lifecycleState = operations === workspace?.pendingOperations
2909
2948
  ? normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {})
2910
2949
  : rtlDeriveDurableVisibleState(workspace);
@@ -3095,11 +3134,15 @@ export const createRecordTimeLabelSyncEngine = ({
3095
3134
  session,
3096
3135
  client = 'recordtimelabel-client',
3097
3136
  clock = () => Date.now(),
3098
- logger = console
3137
+ logger = console,
3138
+ initialBaseline = 'require-fresh'
3099
3139
  } = {}) => {
3100
3140
  if (!storage?.load || !storage?.save) {
3101
3141
  throw new Error('createRecordTimeLabelSyncEngine requires storage.load() and storage.save()');
3102
3142
  }
3143
+ if (initialBaseline !== 'require-fresh' && initialBaseline !== 'reuse-committed') {
3144
+ throw new Error('recordtimelabel_invalid_initial_baseline_policy');
3145
+ }
3103
3146
 
3104
3147
  let workspace = rtlEmptyDurableWorkspace();
3105
3148
  let initialized = false;
@@ -3226,6 +3269,49 @@ export const createRecordTimeLabelSyncEngine = ({
3226
3269
  return {state, revision, changeCursor: remote.changeCursor ?? null};
3227
3270
  };
3228
3271
 
3272
+ const requireBootstrapRevision = (baseline, minimumRevision) => {
3273
+ const minimum = Number(minimumRevision);
3274
+ if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
3275
+ const error = new Error('recordtimelabel_bootstrap_revision_behind_required');
3276
+ error.code = 'recordtimelabel_bootstrap_revision_behind_required';
3277
+ error.bootstrapRevision = Number(baseline?.revision);
3278
+ error.minimumRevision = minimum;
3279
+ throw error;
3280
+ }
3281
+ return baseline;
3282
+ };
3283
+
3284
+ // Optional bounded catch-up port. `cloud.catchUp(context, {fromRevision,
3285
+ // targetRevision, attemptId, mode})` resolves a FULL baseline object
3286
+ // (`{success: true, state, revision, changeCursor?}`, the same shape
3287
+ // normalizeBootstrapResponse accepts) or `{success: false,
3288
+ // bootstrapRequired: true}`; it may throw on transport errors. Gap
3289
+ // recovery prefers it over `cloud.bootstrap` and falls back to a fresh
3290
+ // bootstrap walk whenever it fails or is unavailable.
3291
+ const recoverBaseline = async (context, targetRevision, mode) => {
3292
+ if (typeof cloud?.catchUp === 'function') {
3293
+ try {
3294
+ const caught = await cloud.catchUp(context, {
3295
+ fromRevision: workspace.remoteBaseline.revision,
3296
+ targetRevision,
3297
+ ...bootstrapAttemptOptions(`${mode}-catchup`)
3298
+ });
3299
+ if (rtlEnvelopeSuccess(caught)) {
3300
+ return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
3301
+ }
3302
+ } catch (error) {
3303
+ logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
3304
+ }
3305
+ }
3306
+ return requireBootstrapRevision(
3307
+ normalizeBootstrapResponse(await cloud.bootstrap(
3308
+ context,
3309
+ bootstrapAttemptOptions(mode)
3310
+ )),
3311
+ targetRevision
3312
+ );
3313
+ };
3314
+
3229
3315
  const normalizeLoadedWorkspace = (loaded, captured) => {
3230
3316
  const source = loaded && typeof loaded === 'object' ? loaded : {};
3231
3317
  const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
@@ -3302,6 +3388,10 @@ export const createRecordTimeLabelSyncEngine = ({
3302
3388
  );
3303
3389
  });
3304
3390
  });
3391
+ const migrationBatchId = rtlStableSyncBatchId(migratedOperations);
3392
+ migratedOperations.forEach((operation) => {
3393
+ if (!operation.syncBatchId) operation.syncBatchId = migrationBatchId;
3394
+ });
3305
3395
  loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
3306
3396
  loadedWorkspace.pendingOperations = [
3307
3397
  ...migratedOperations,
@@ -3405,10 +3495,7 @@ export const createRecordTimeLabelSyncEngine = ({
3405
3495
  return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3406
3496
  }
3407
3497
  const context = rtlSessionContext(captured, candidate, client);
3408
- baselineValue = normalizeBootstrapResponse(await cloud.bootstrap(
3409
- context,
3410
- bootstrapAttemptOptions('gap-recovery')
3411
- ));
3498
+ baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
3412
3499
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3413
3500
  }
3414
3501
  const applied = applyRemoteBaseline(candidate, baselineValue);
@@ -3447,6 +3534,12 @@ export const createRecordTimeLabelSyncEngine = ({
3447
3534
  const captured = capture();
3448
3535
  const loaded = await storage.load();
3449
3536
  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).
3541
+ const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
3542
+ const committedRevision = Number(loaded?.remoteBaseline?.revision || 0);
3450
3543
  let candidate = normalizeLoadedWorkspace(loaded, captured);
3451
3544
  const identityChecked = rtlQuarantineOperations(
3452
3545
  candidate,
@@ -3457,7 +3550,10 @@ export const createRecordTimeLabelSyncEngine = ({
3457
3550
  candidate.rejectedOperations = identityChecked.rejected;
3458
3551
  const context = rtlSessionContext(captured, candidate, client);
3459
3552
 
3460
- if (captured?.uid && typeof cloud?.bootstrap === 'function') {
3553
+ const canReuseCommitted = initialBaseline === 'reuse-committed' &&
3554
+ captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
3555
+ committedRevision > 0;
3556
+ if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
3461
3557
  let bootstrap;
3462
3558
  try {
3463
3559
  bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
@@ -3727,10 +3823,12 @@ export const createRecordTimeLabelSyncEngine = ({
3727
3823
  return {success: false, error, protocolError: true};
3728
3824
  }
3729
3825
  }
3826
+ const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3730
3827
  const candidate = clone(workspace);
3731
3828
  const resultById = new Map(parsed.results.map((result) => [result.id, result]));
3732
3829
  const readyIds = new Set(ready.map((operation) => operation.id));
3733
3830
  const appliedOperations = [];
3831
+ const completedOperations = [];
3734
3832
  const nextPending = [];
3735
3833
  const nextRejected = {...candidate.rejectedOperations};
3736
3834
  for (const operation of candidate.pendingOperations) {
@@ -3741,12 +3839,15 @@ export const createRecordTimeLabelSyncEngine = ({
3741
3839
  const result = resultById.get(operation.id);
3742
3840
  if (result.status === 'applied') {
3743
3841
  appliedOperations.push(operation);
3842
+ completedOperations.push({operation, status: result.status});
3744
3843
  } else if (result.status === 'noop') {
3745
3844
  // A noop is acknowledged but intentionally not promoted onto the
3746
3845
  // baseline: the server says the operation had no effect.
3846
+ completedOperations.push({operation, status: result.status});
3747
3847
  } else if (result.status === 'retryable') {
3748
3848
  nextPending.push(makeRetryOperation(operation, result, null, timestamp));
3749
3849
  } else if (result.status === 'rejected') {
3850
+ completedOperations.push({operation, status: result.status});
3750
3851
  nextRejected[operation.id] = {
3751
3852
  id: operation.id,
3752
3853
  operation: clone(operation),
@@ -3763,13 +3864,19 @@ export const createRecordTimeLabelSyncEngine = ({
3763
3864
  );
3764
3865
  });
3765
3866
  const responseRevision = Number(response.revision ?? response.remoteRevision);
3766
- if (Number.isFinite(responseRevision) && responseRevision >= candidate.remoteBaseline.revision) {
3767
- candidate.remoteBaseline.revision = responseRevision;
3768
- }
3769
- if (Object.prototype.hasOwnProperty.call(response || {}, 'changeCursor')) {
3770
- candidate.remoteBaseline.changeCursor = response.changeCursor === null || response.changeCursor === undefined
3771
- ? null
3772
- : String(response.changeCursor);
3867
+ const responseBaseline = rtlEnvelopePayload(response);
3868
+ const responseState = responseBaseline?.state ?? responseBaseline?.data;
3869
+ if (rtlDurableIsObject(responseState)) {
3870
+ applyRemoteBaseline(candidate, responseBaseline);
3871
+ } else if (
3872
+ rejectedCount === 0 &&
3873
+ Number.isFinite(responseRevision) &&
3874
+ responseRevision > candidate.remoteBaseline.revision + 1 &&
3875
+ typeof cloud?.bootstrap === 'function'
3876
+ ) {
3877
+ const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
3878
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3879
+ applyRemoteBaseline(candidate, freshBaseline);
3773
3880
  }
3774
3881
  candidate.pendingOperations = nextPending;
3775
3882
  candidate.rejectedOperations = nextRejected;
@@ -3778,16 +3885,26 @@ export const createRecordTimeLabelSyncEngine = ({
3778
3885
  lastSyncedAt: timestamp,
3779
3886
  lastSyncAttemptAt: timestamp,
3780
3887
  lastSyncReason: reason ?? null,
3781
- lastSyncError: null
3888
+ lastSyncError: null,
3889
+ ...(Number.isFinite(responseRevision) ? {
3890
+ lastAcknowledgedRevision: Math.max(
3891
+ rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
3892
+ candidate.remoteBaseline.revision,
3893
+ responseRevision
3894
+ )
3895
+ } : {})
3782
3896
  };
3783
- const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3897
+ rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
3784
3898
  if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
3785
3899
  let freshBaseline;
3786
3900
  try {
3787
- freshBaseline = normalizeBootstrapResponse(await cloud.bootstrap(
3788
- context,
3789
- bootstrapAttemptOptions('rejection-rebase')
3790
- ));
3901
+ freshBaseline = requireBootstrapRevision(
3902
+ normalizeBootstrapResponse(await cloud.bootstrap(
3903
+ context,
3904
+ bootstrapAttemptOptions('rejection-rebase')
3905
+ )),
3906
+ responseRevision
3907
+ );
3791
3908
  } catch (error) {
3792
3909
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3793
3910
  throw error;
package/src/protocol.js CHANGED
@@ -59,6 +59,37 @@ const fnv1a = (value) => {
59
59
  return (hash >>> 0).toString(16).padStart(8, '0');
60
60
  };
61
61
 
62
+ const djb2 = (value) => {
63
+ let hash = 5381;
64
+ for (let index = 0; index < value.length; index += 1) {
65
+ hash = Math.imul(hash, 33) ^ value.charCodeAt(index);
66
+ }
67
+ return (hash >>> 0).toString(16).padStart(8, '0');
68
+ };
69
+
70
+ const canonicalizeRequestValue = (value, seen = new WeakSet()) => {
71
+ if (value === null || value === undefined || typeof value !== 'object') return value;
72
+ if (seen.has(value)) throw new TypeError('operation_request_circular_value');
73
+ seen.add(value);
74
+ try {
75
+ if (Array.isArray(value)) {
76
+ return value.map((entry) => canonicalizeRequestValue(entry, seen));
77
+ }
78
+ if (typeof value.toJSON === 'function') {
79
+ return canonicalizeRequestValue(value.toJSON(), seen);
80
+ }
81
+ return Object.fromEntries(
82
+ Object.keys(value)
83
+ .sort()
84
+ .map((key) => [key, canonicalizeRequestValue(value[key], seen)])
85
+ );
86
+ } finally {
87
+ seen.delete(value);
88
+ }
89
+ };
90
+
91
+ const requestDigest = (value) => `${fnv1a(value)}${djb2(value)}`;
92
+
62
93
  export const toRecordTimeLabelWireOperation = (operation = {}) => {
63
94
  const source = asObject(operation);
64
95
  return {
@@ -73,14 +104,17 @@ export const toRecordTimeLabelWireOperation = (operation = {}) => {
73
104
  export const buildRecordTimeLabelRequestId = (namespace, operations = []) => {
74
105
  const safeNamespace = normalizeId(namespace)
75
106
  .replace(/[^A-Za-z0-9._:-]+/g, '-')
76
- .slice(0, 64) || 'recordtimelabel';
77
- const orderedIds = Array.isArray(operations)
78
- ? operations.map((operation) => normalizeId(asObject(operation).id))
107
+ .slice(0, 48) || 'recordtimelabel';
108
+ const wireOperations = Array.isArray(operations)
109
+ ? operations.map((operation) => toRecordTimeLabelWireOperation(operation))
79
110
  : [];
111
+ const orderedIds = wireOperations.map((operation) => normalizeId(operation.id));
80
112
  const firstOperationId = (orderedIds[0] || 'empty')
81
113
  .replace(/[^A-Za-z0-9._:-]+/g, '-')
82
- .slice(0, 72) || 'empty';
83
- return `rtl:${safeNamespace}:${firstOperationId}:${fnv1a(orderedIds.join('\u001f'))}`.slice(0, 160);
114
+ .slice(0, 56) || 'empty';
115
+ const orderedIdDigest = requestDigest(orderedIds.join('\u001f'));
116
+ const wireDigest = requestDigest(JSON.stringify(canonicalizeRequestValue(wireOperations)));
117
+ return `rtl:${safeNamespace}:${firstOperationId}:${orderedIdDigest}:${wireDigest}`.slice(0, 160);
84
118
  };
85
119
 
86
120
  const normalizeStatus = (result) => {