@recordtimelabel/core 0.4.3 → 0.4.4

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.4",
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.4';
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);
@@ -3226,6 +3265,18 @@ export const createRecordTimeLabelSyncEngine = ({
3226
3265
  return {state, revision, changeCursor: remote.changeCursor ?? null};
3227
3266
  };
3228
3267
 
3268
+ const requireBootstrapRevision = (baseline, minimumRevision) => {
3269
+ const minimum = Number(minimumRevision);
3270
+ if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
3271
+ const error = new Error('recordtimelabel_bootstrap_revision_behind_required');
3272
+ error.code = 'recordtimelabel_bootstrap_revision_behind_required';
3273
+ error.bootstrapRevision = Number(baseline?.revision);
3274
+ error.minimumRevision = minimum;
3275
+ throw error;
3276
+ }
3277
+ return baseline;
3278
+ };
3279
+
3229
3280
  const normalizeLoadedWorkspace = (loaded, captured) => {
3230
3281
  const source = loaded && typeof loaded === 'object' ? loaded : {};
3231
3282
  const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
@@ -3302,6 +3353,10 @@ export const createRecordTimeLabelSyncEngine = ({
3302
3353
  );
3303
3354
  });
3304
3355
  });
3356
+ const migrationBatchId = rtlStableSyncBatchId(migratedOperations);
3357
+ migratedOperations.forEach((operation) => {
3358
+ if (!operation.syncBatchId) operation.syncBatchId = migrationBatchId;
3359
+ });
3305
3360
  loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
3306
3361
  loadedWorkspace.pendingOperations = [
3307
3362
  ...migratedOperations,
@@ -3405,10 +3460,13 @@ export const createRecordTimeLabelSyncEngine = ({
3405
3460
  return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3406
3461
  }
3407
3462
  const context = rtlSessionContext(captured, candidate, client);
3408
- baselineValue = normalizeBootstrapResponse(await cloud.bootstrap(
3409
- context,
3410
- bootstrapAttemptOptions('gap-recovery')
3411
- ));
3463
+ baselineValue = requireBootstrapRevision(
3464
+ normalizeBootstrapResponse(await cloud.bootstrap(
3465
+ context,
3466
+ bootstrapAttemptOptions('gap-recovery')
3467
+ )),
3468
+ remoteRevision
3469
+ );
3412
3470
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3413
3471
  }
3414
3472
  const applied = applyRemoteBaseline(candidate, baselineValue);
@@ -3727,10 +3785,12 @@ export const createRecordTimeLabelSyncEngine = ({
3727
3785
  return {success: false, error, protocolError: true};
3728
3786
  }
3729
3787
  }
3788
+ const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3730
3789
  const candidate = clone(workspace);
3731
3790
  const resultById = new Map(parsed.results.map((result) => [result.id, result]));
3732
3791
  const readyIds = new Set(ready.map((operation) => operation.id));
3733
3792
  const appliedOperations = [];
3793
+ const completedOperations = [];
3734
3794
  const nextPending = [];
3735
3795
  const nextRejected = {...candidate.rejectedOperations};
3736
3796
  for (const operation of candidate.pendingOperations) {
@@ -3741,12 +3801,15 @@ export const createRecordTimeLabelSyncEngine = ({
3741
3801
  const result = resultById.get(operation.id);
3742
3802
  if (result.status === 'applied') {
3743
3803
  appliedOperations.push(operation);
3804
+ completedOperations.push({operation, status: result.status});
3744
3805
  } else if (result.status === 'noop') {
3745
3806
  // A noop is acknowledged but intentionally not promoted onto the
3746
3807
  // baseline: the server says the operation had no effect.
3808
+ completedOperations.push({operation, status: result.status});
3747
3809
  } else if (result.status === 'retryable') {
3748
3810
  nextPending.push(makeRetryOperation(operation, result, null, timestamp));
3749
3811
  } else if (result.status === 'rejected') {
3812
+ completedOperations.push({operation, status: result.status});
3750
3813
  nextRejected[operation.id] = {
3751
3814
  id: operation.id,
3752
3815
  operation: clone(operation),
@@ -3763,13 +3826,25 @@ export const createRecordTimeLabelSyncEngine = ({
3763
3826
  );
3764
3827
  });
3765
3828
  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);
3829
+ const responseBaseline = rtlEnvelopePayload(response);
3830
+ const responseState = responseBaseline?.state ?? responseBaseline?.data;
3831
+ if (rtlDurableIsObject(responseState)) {
3832
+ applyRemoteBaseline(candidate, responseBaseline);
3833
+ } else if (
3834
+ rejectedCount === 0 &&
3835
+ Number.isFinite(responseRevision) &&
3836
+ responseRevision > candidate.remoteBaseline.revision + 1 &&
3837
+ typeof cloud?.bootstrap === 'function'
3838
+ ) {
3839
+ const freshBaseline = requireBootstrapRevision(
3840
+ normalizeBootstrapResponse(await cloud.bootstrap(
3841
+ context,
3842
+ bootstrapAttemptOptions('gap-recovery')
3843
+ )),
3844
+ responseRevision
3845
+ );
3846
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3847
+ applyRemoteBaseline(candidate, freshBaseline);
3773
3848
  }
3774
3849
  candidate.pendingOperations = nextPending;
3775
3850
  candidate.rejectedOperations = nextRejected;
@@ -3778,16 +3853,26 @@ export const createRecordTimeLabelSyncEngine = ({
3778
3853
  lastSyncedAt: timestamp,
3779
3854
  lastSyncAttemptAt: timestamp,
3780
3855
  lastSyncReason: reason ?? null,
3781
- lastSyncError: null
3856
+ lastSyncError: null,
3857
+ ...(Number.isFinite(responseRevision) ? {
3858
+ lastAcknowledgedRevision: Math.max(
3859
+ rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
3860
+ candidate.remoteBaseline.revision,
3861
+ responseRevision
3862
+ )
3863
+ } : {})
3782
3864
  };
3783
- const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3865
+ rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
3784
3866
  if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
3785
3867
  let freshBaseline;
3786
3868
  try {
3787
- freshBaseline = normalizeBootstrapResponse(await cloud.bootstrap(
3788
- context,
3789
- bootstrapAttemptOptions('rejection-rebase')
3790
- ));
3869
+ freshBaseline = requireBootstrapRevision(
3870
+ normalizeBootstrapResponse(await cloud.bootstrap(
3871
+ context,
3872
+ bootstrapAttemptOptions('rejection-rebase')
3873
+ )),
3874
+ responseRevision
3875
+ );
3791
3876
  } catch (error) {
3792
3877
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3793
3878
  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) => {