@recordtimelabel/core 0.4.2 → 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 durable identity and explicit entrypoint contract is prepared in package version `0.4.2` (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.2"
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.
@@ -34,8 +34,12 @@ If this checkout's `package.json` is ahead of the published version, publish the
34
34
  - `applyRecordTimeLabelOperation(state, operation)`
35
35
  - `createRecordTimeLabelSyncEngine({ storage, cloud, session, client, clock, logger })`
36
36
  - `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
37
- - `normalizeRecordTimeLabelOperationResults(operations, results)`
38
- - `normalizeRecordTimeLabelEnvelopeResponse(operations, response)`
37
+ - `RECORD_TIMELABEL_PROTOCOL_CAPABILITIES`
38
+ - `toRecordTimeLabelWireOperation(operation)`
39
+ - `buildRecordTimeLabelRequestId(namespace, operations)`
40
+ - `createRecordTimeLabelTransportFailureResults(operations, failure, options)`
41
+ - `normalizeRecordTimeLabelOperationResults(operations, results, options)`
42
+ - `normalizeRecordTimeLabelEnvelopeResponse(operations, response, options)`
39
43
  - `createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })`
40
44
  - `createRecordTimeLabelController({ storageAdapter, cloudAdapter, clientId, settingKeys, clock, logger })`
41
45
  - `buildRecordTimeLabelSyncPayload({ data, previousCloudData, pendingOps, syncMode, clientId, now, groupOrderNormalizer })`
@@ -53,7 +57,7 @@ If this checkout's `package.json` is ahead of the published version, publish the
53
57
  - `buildFirestoreV2OperationReadPlan(operations)`
54
58
  - `extendFirestoreV2OperationReadPlanWithRecords(readPlan, records)`
55
59
  - `extendFirestoreV2OperationReadPlanWithTrash(readPlan, trashEntries)`
56
- - `planFirestoreV2OperationChanges({ documents, operations, localState, now })`
60
+ - `planFirestoreV2OperationChanges({ documents, operations, localState, now, requireLifecycleGeneration })`
57
61
  - `estimateFirestoreV2WriteUnits(changes, overhead)`
58
62
  - `buildOperationsFromSnapshotDiff({ previousState, nextState, now, operationIdPrefix, batchSize })`(一般操作在 `batches`,`folder.delete` 在 `bulkOperations`)
59
63
  - `RTL_SYNC_PROTOCOL_VERSION`
@@ -79,7 +83,7 @@ and operation planners, and `/compat` contains the legacy `createSyncEngine` and
79
83
 
80
84
  `createRecordTimeLabelSyncEngine` is the platform-neutral durable workspace API. Its storage port
81
85
  has `load()` and `save(workspace)` methods (adapters may consume the optional second
82
- `save(workspace, fenceContext)` argument to enforce the session/epoch atomically), and its cloud port has `bootstrap(context)`,
86
+ `save(workspace, fenceContext)` argument to enforce the session/epoch atomically), and its cloud port has `bootstrap(context, attemptOptions)`,
83
87
  `applyOperations(envelope, context)`, and `subscribe(listener, context)` methods. The session port
84
88
  provides `current()`, `subscribe(listener)`, and `isCurrent(sessionToken, uid, workspaceEpoch)`.
85
89
 
@@ -105,8 +109,20 @@ explicit identity does not match the current workspace is moved to
105
109
 
106
110
  Pending operations also carry an internal `syncBatchId`. Operations dispatched in
107
111
  one call share a batch boundary, while legacy pending operations are assigned one
108
- stable boundary during migration. Adapters may use this field to preserve immutable
109
- request envelopes across worker restarts; it must not be sent to the gateway.
112
+ stable boundary during migration. The engine sends only the first FIFO batch boundary,
113
+ capped at 20 operations, and derives a stable request ID from that boundary and the
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.
118
+
119
+ Bootstrap attempts use `initial-hydration`, `rejection-rebase`, or `gap-recovery`
120
+ modes with a unique `attemptId`; all require a fresh logical attempt. Structured
121
+ failures, missing state, and missing/invalid revisions fail closed. UID or workspace
122
+ epoch changes clear the visible workspace and require `init()` before another dispatch
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.
110
126
 
111
127
  The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
112
128
  `subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
@@ -123,21 +139,26 @@ so older clients can continue reading the view until they migrate.
123
139
  ### Operation-result protocol
124
140
 
125
141
  The root package and `@recordtimelabel/core/protocol` export the shared acknowledgement
126
- normalizers. `normalizeRecordTimeLabelOperationResults(operations, results)` always returns one
127
- positional result per submitted operation. The only normalized statuses are `applied`, `noop`,
142
+ normalizers. `normalizeRecordTimeLabelOperationResults(operations, results)` verifies count,
143
+ request membership, unique IDs, and completeness before returning results in request order.
144
+ The only normalized statuses are `applied`, `noop`,
128
145
  `rejected`, and `retryable`; `retryable` is derived from that status, while the legacy `id` and
129
146
  `applied` fields remain available alongside `operationId`, `reason`, and `retryAfterMs`.
130
147
 
131
- `normalizeRecordTimeLabelEnvelopeResponse(operations, response)` prefers an explicit
132
- `operationResults` array. An explicitly provided non-array or mismatched array, and a failed
133
- envelope without results, throw an error whose `code` is `operation_result_count_mismatch`, so
134
- callers can reject the entire acknowledgement before changing durable state.
135
-
136
- During the compatibility window, a successful legacy envelope that omits the
137
- `operationResults` property acknowledges every submitted operation as `applied`. Failed envelopes
138
- and envelopes that explicitly provide a malformed result value never receive that fallback. Keep
139
- the fallback until all deployed gateways and clients send and consume the explicit array; removing
140
- it requires a coordinated breaking release.
148
+ `normalizeRecordTimeLabelEnvelopeResponse(operations, response)` requires an explicit
149
+ `operationResults` array by default. Malformed, duplicate, missing, unknown, or ambiguous IDs
150
+ throw stable protocol errors so callers can reject the entire acknowledgement before changing
151
+ durable state.
152
+
153
+ Gateway-only compatibility code may opt into successful legacy envelopes with
154
+ `{allowLegacySuccessWithoutResults: true}`. New durable clients never enable that fallback.
155
+ Capabilities `operation-conflict-quarantine`, `strict-operation-results`, and
156
+ `lifecycle-generation-fence` gate the corresponding 0.4.4 behavior. Single
157
+ `record.restore`, `folder.restore`, and `trash.purge` operations require a positive
158
+ `expectedGeneration`; batch lifecycle operations retain their existing contract. The gateway
159
+ validator derives enforcement from `client.capabilities`. The planner is strict by default;
160
+ the time-limited legacy gateway path must explicitly pass `requireLifecycleGeneration: false`
161
+ for clients that did not declare the capability. A supplied but stale generation is always rejected.
141
162
 
142
163
  ## Firestore v1 Compatibility
143
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.2",
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",
@@ -4,8 +4,12 @@
4
4
  export {
5
5
  FIRESTORE_V2_SETTINGS_DOC_ID,
6
6
  OPERATION_TYPES,
7
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
8
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
9
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
7
10
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
8
11
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
12
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
9
13
  RTL_MAX_OPERATIONS_PER_REQUEST,
10
14
  RTL_MAX_REQUEST_BYTES,
11
15
  RTL_MAX_TARGET_WRITES,
@@ -14,14 +18,17 @@ export {
14
18
  buildFirestoreV2DocumentsFromState,
15
19
  buildFirestoreV2LogicalPaths,
16
20
  buildFirestoreV2OperationReadPlan,
21
+ buildRecordTimeLabelRequestId,
17
22
  buildOperationsFromSnapshotDiff,
18
23
  buildStateFromFirestoreV2Documents,
19
24
  estimateFirestoreV2WriteUnits,
25
+ createRecordTimeLabelTransportFailureResults,
20
26
  extendFirestoreV2OperationReadPlanWithRecords,
21
27
  extendFirestoreV2OperationReadPlanWithTrash,
22
28
  normalizeRecordTimeLabelEnvelopeResponse,
23
29
  normalizeRecordTimeLabelOperationResults,
24
30
  planFirestoreV2OperationChanges,
31
+ toRecordTimeLabelWireOperation,
25
32
  normalizeRecordTimeLabelDomainState,
26
33
  validateRecordTimeLabelOperationBatch
27
34
  } from './index.js';
package/src/index.js CHANGED
@@ -1,13 +1,27 @@
1
1
  import {
2
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
3
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
4
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
2
5
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
6
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
7
+ buildRecordTimeLabelRequestId,
8
+ createRecordTimeLabelTransportFailureResults,
3
9
  normalizeRecordTimeLabelEnvelopeResponse,
4
- normalizeRecordTimeLabelOperationResults
10
+ normalizeRecordTimeLabelOperationResults,
11
+ toRecordTimeLabelWireOperation
5
12
  } from './protocol.js';
6
13
 
7
14
  export {
15
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
16
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
17
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
8
18
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
19
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
20
+ buildRecordTimeLabelRequestId,
21
+ createRecordTimeLabelTransportFailureResults,
9
22
  normalizeRecordTimeLabelEnvelopeResponse,
10
- normalizeRecordTimeLabelOperationResults
23
+ normalizeRecordTimeLabelOperationResults,
24
+ toRecordTimeLabelWireOperation
11
25
  } from './protocol.js';
12
26
 
13
27
  const DEFAULT_FOLDER_ID = 'uncategorized';
@@ -17,12 +31,15 @@ const REQUIRED_FOLDERS = [
17
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
18
32
  ];
19
33
 
20
- export const RECORD_TIMELABEL_CORE_VERSION = '0.4.2';
34
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.4.4';
21
35
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
22
36
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
23
37
  'fifo-retry-fence',
24
38
  'baseline-refresh-after-rejection',
25
- 'sync-batch-boundary'
39
+ 'sync-batch-boundary',
40
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
41
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
42
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
26
43
  ]);
27
44
  export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
28
45
  export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
@@ -57,6 +74,11 @@ const ORDER_OPERATION_TYPES = new Set([
57
74
  OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
58
75
  ]);
59
76
 
77
+ const normalizeExpectedGeneration = (value) => {
78
+ const generation = Number(value);
79
+ return Number.isSafeInteger(generation) && generation > 0 ? generation : null;
80
+ };
81
+
60
82
  export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
61
83
  FULL: 'full',
62
84
  MERGE: 'merge'
@@ -71,7 +93,10 @@ export const FIRESTORE_V2_SETTINGS_DOC_ID = 'main';
71
93
 
72
94
  export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
73
95
  OWNER_MISMATCH: 'operation_owner_mismatch',
74
- WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch'
96
+ WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
97
+ ID_CONFLICT: 'operation_id_conflict',
98
+ LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
99
+ LIFECYCLE_CONFLICT: 'lifecycle_conflict'
75
100
  });
76
101
 
77
102
  const toArray = (value) => (Array.isArray(value) ? value : []);
@@ -951,8 +976,9 @@ export const applyOperation = (state = {}, operation = {}) => {
951
976
  const trashEntry = nextState.trashEntries[trashEntryId];
952
977
  if (!trashEntry || trashEntry.kind !== 'record') return normalized;
953
978
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
954
- if (payload.expectedGeneration &&
955
- Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
979
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
980
+ if (expectedGeneration === null ||
981
+ expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
956
982
  const recordId = normalizeId(trashEntry.entityId);
957
983
  if (!recordId || findRecordEntry(nextState.records, recordId)) return normalized;
958
984
  const recordSnapshot = trashEntry.payload?.record;
@@ -1080,8 +1106,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1080
1106
  const trashEntry = nextState.trashEntries[trashEntryId];
1081
1107
  if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
1082
1108
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
1083
- if (payload.expectedGeneration &&
1084
- Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
1109
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
1110
+ if (expectedGeneration === null ||
1111
+ expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
1085
1112
  const folderId = normalizeId(trashEntry.entityId);
1086
1113
  if (!folderId || nextState.folders.some((folder) => folder.id === folderId)) return normalized;
1087
1114
  const folderSnapshot = trashEntry.payload?.folder;
@@ -1126,8 +1153,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1126
1153
  const trashEntryId = normalizeId(payload.trashEntryId || payload.id);
1127
1154
  const trashEntry = nextState.trashEntries[trashEntryId];
1128
1155
  if (!trashEntry) return normalized;
1129
- if (payload.expectedGeneration &&
1130
- Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
1156
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
1157
+ if (expectedGeneration === null ||
1158
+ expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
1131
1159
  delete nextState.trashEntries[trashEntryId];
1132
1160
  break;
1133
1161
  }
@@ -2620,6 +2648,7 @@ export const createRecordTimeLabelController = ({
2620
2648
  const RTL_DURABLE_SCHEMA_VERSION = 1;
2621
2649
  const RTL_RETRY_BASE_MS = 1000;
2622
2650
  const RTL_RETRY_MAX_MS = 60 * 1000;
2651
+ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
2623
2652
 
2624
2653
  const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
2625
2654
 
@@ -2735,13 +2764,10 @@ const rtlNormalizePendingOperation = (operation, options = {}) => {
2735
2764
  };
2736
2765
 
2737
2766
  const rtlStableSyncBatchId = (operations = []) => {
2738
- const source = toArray(operations).map((operation) => normalizeId(operation?.id)).join('|');
2739
- let hash = 2166136261;
2740
- for (let index = 0; index < source.length; index += 1) {
2741
- hash ^= source.charCodeAt(index);
2742
- hash = Math.imul(hash, 16777619);
2743
- }
2744
- return `durable-batch:${(hash >>> 0).toString(16).padStart(8, '0')}`;
2767
+ return buildRecordTimeLabelRequestId(
2768
+ 'durable-batch',
2769
+ toArray(operations).map((operation) => toRecordTimeLabelWireOperation(operation))
2770
+ );
2745
2771
  };
2746
2772
 
2747
2773
  const rtlNormalizeRemoteBaseline = (value = {}) => {
@@ -2841,23 +2867,115 @@ const rtlOperationIdentityReason = (operation, workspace) => {
2841
2867
  if (operationEpoch !== workspaceEpoch) {
2842
2868
  return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.WORKSPACE_EPOCH_MISMATCH;
2843
2869
  }
2870
+ if ([
2871
+ OPERATION_TYPES.RECORD_RESTORE,
2872
+ OPERATION_TYPES.FOLDER_RESTORE,
2873
+ OPERATION_TYPES.TRASH_PURGE
2874
+ ].includes(operation?.type) && normalizeExpectedGeneration(operation?.payload?.expectedGeneration) === null) {
2875
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_GENERATION_REQUIRED;
2876
+ }
2877
+ return null;
2878
+ };
2879
+
2880
+ const rtlOperationWireFingerprint = (operation) => JSON.stringify(
2881
+ canonicalizeFirestoreV2DocumentValue(toRecordTimeLabelWireOperation(operation))
2882
+ );
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
+
2912
+ const rtlLifecycleOperationReason = (operation, state) => {
2913
+ if (![
2914
+ OPERATION_TYPES.RECORD_RESTORE,
2915
+ OPERATION_TYPES.FOLDER_RESTORE,
2916
+ OPERATION_TYPES.TRASH_PURGE
2917
+ ].includes(operation?.type)) return null;
2918
+ const expectedGeneration = normalizeExpectedGeneration(operation?.payload?.expectedGeneration);
2919
+ if (expectedGeneration === null) {
2920
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_GENERATION_REQUIRED;
2921
+ }
2922
+ const trashEntryId = normalizeId(operation?.payload?.trashEntryId || operation?.payload?.id);
2923
+ const trashEntry = state?.trashEntries?.[trashEntryId];
2924
+ if (trashEntry && Number(trashEntry.lifecycleGeneration) !== expectedGeneration) {
2925
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_CONFLICT;
2926
+ }
2844
2927
  return null;
2845
2928
  };
2846
2929
 
2847
2930
  const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now()) => {
2848
2931
  const accepted = [];
2849
2932
  const rejected = {...(workspace?.rejectedOperations || {})};
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
+ });
2947
+ let lifecycleState = operations === workspace?.pendingOperations
2948
+ ? normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {})
2949
+ : rtlDeriveDurableVisibleState(workspace);
2950
+ if (operations !== workspace?.pendingOperations) {
2951
+ toArray(workspace?.pendingOperations).forEach((operation) => {
2952
+ const id = normalizeId(operation?.id);
2953
+ if (id && !knownById.has(id)) knownById.set(id, rtlOperationWireFingerprint(operation));
2954
+ });
2955
+ }
2850
2956
  let rejectedCount = 0;
2851
2957
  toArray(operations).forEach((operation, index) => {
2852
- const reason = rtlOperationIdentityReason(operation, workspace);
2958
+ const id = normalizeId(operation?.id);
2959
+ const fingerprint = rtlOperationWireFingerprint(operation);
2960
+ const knownFingerprint = id ? knownById.get(id) : null;
2961
+ let reason = rtlOperationIdentityReason(operation, workspace);
2962
+ if (!reason) reason = rtlLifecycleOperationReason(operation, lifecycleState);
2963
+ if (!reason && knownFingerprint) {
2964
+ if (knownFingerprint === fingerprint) return;
2965
+ reason = RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.ID_CONFLICT;
2966
+ }
2853
2967
  if (!reason) {
2854
2968
  accepted.push(operation);
2969
+ if (id) knownById.set(id, fingerprint);
2970
+ lifecycleState = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
2971
+ ? lifecycleState
2972
+ : normalizeRecordTimeLabelDomainState(applyRecordTimeLabelOperation(lifecycleState, operation));
2855
2973
  return;
2856
2974
  }
2857
2975
  rejectedCount += 1;
2858
- const id = normalizeId(operation?.id) || `identity-rejected:${timestamp}:${index}`;
2859
- rejected[id] = {
2860
- id,
2976
+ const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
2977
+ rejected[rejectedId] = {
2978
+ id: rejectedId,
2861
2979
  operation: clone(operation),
2862
2980
  status: 'rejected',
2863
2981
  reason,
@@ -3024,10 +3142,12 @@ export const createRecordTimeLabelSyncEngine = ({
3024
3142
 
3025
3143
  let workspace = rtlEmptyDurableWorkspace();
3026
3144
  let initialized = false;
3145
+ let hydrationRequired = true;
3027
3146
  let destroyed = false;
3028
3147
  let unsubscribeCloud = null;
3029
3148
  let unsubscribeSession = null;
3030
3149
  let subscriptionContext = null;
3150
+ let bootstrapAttemptSequence = 0;
3031
3151
  const listeners = new Set();
3032
3152
  let queue = Promise.resolve();
3033
3153
 
@@ -3120,6 +3240,43 @@ export const createRecordTimeLabelSyncEngine = ({
3120
3240
  return {changed, stale: false};
3121
3241
  };
3122
3242
 
3243
+ const bootstrapAttemptOptions = (mode) => ({
3244
+ mode,
3245
+ attemptId: `bootstrap:${mode}:${now()}:${++bootstrapAttemptSequence}:${
3246
+ globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2, 14)
3247
+ }`,
3248
+ requireFresh: true
3249
+ });
3250
+
3251
+ const normalizeBootstrapResponse = (response) => {
3252
+ if (!rtlEnvelopeSuccess(response)) {
3253
+ const error = new Error(response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed');
3254
+ error.code = response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed';
3255
+ throw error;
3256
+ }
3257
+ const remote = rtlEnvelopePayload(response);
3258
+ const revision = Number(remote?.revision);
3259
+ const state = remote?.state ?? remote?.data;
3260
+ if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
3261
+ const error = new Error('recordtimelabel_invalid_bootstrap_response');
3262
+ error.code = 'recordtimelabel_invalid_bootstrap_response';
3263
+ throw error;
3264
+ }
3265
+ return {state, revision, changeCursor: remote.changeCursor ?? null};
3266
+ };
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
+
3123
3280
  const normalizeLoadedWorkspace = (loaded, captured) => {
3124
3281
  const source = loaded && typeof loaded === 'object' ? loaded : {};
3125
3282
  const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
@@ -3196,6 +3353,10 @@ export const createRecordTimeLabelSyncEngine = ({
3196
3353
  );
3197
3354
  });
3198
3355
  });
3356
+ const migrationBatchId = rtlStableSyncBatchId(migratedOperations);
3357
+ migratedOperations.forEach((operation) => {
3358
+ if (!operation.syncBatchId) operation.syncBatchId = migrationBatchId;
3359
+ });
3199
3360
  loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
3200
3361
  loadedWorkspace.pendingOperations = [
3201
3362
  ...migratedOperations,
@@ -3257,43 +3418,11 @@ export const createRecordTimeLabelSyncEngine = ({
3257
3418
  const normalizeOperationResults = (response, sentOperations) => {
3258
3419
  let results;
3259
3420
  try {
3260
- // Keep the Plan 017 engine's private input aliases compatible while the
3261
- // public shared contract remains strict about operationResults arrays.
3262
- const legacyOperationResults = response?.operationResults;
3263
- const compatibleResponse = rtlDurableIsObject(legacyOperationResults)
3264
- ? {
3265
- ...response,
3266
- operationResults: Object.entries(legacyOperationResults).map(([id, result]) => ({
3267
- ...(result || {}),
3268
- id: result?.id || id
3269
- }))
3270
- }
3271
- : (
3272
- !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults') &&
3273
- Array.isArray(response?.results)
3274
- ? {...response, operationResults: response.results}
3275
- : response
3276
- );
3277
- results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, compatibleResponse);
3421
+ results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, response);
3278
3422
  } catch (error) {
3279
3423
  return {error, results: null};
3280
3424
  }
3281
- const byId = new Map(sentOperations.map((operation) => [operation.id, operation]));
3282
- const normalized = [];
3283
- const seen = new Set();
3284
- for (let index = 0; index < results.length; index += 1) {
3285
- const result = results[index] || {};
3286
- const resultId = rtlResultOperationId(result) || sentOperations[index]?.id;
3287
- if (!resultId || !byId.has(resultId) || seen.has(resultId)) {
3288
- return {error: new Error('sync_protocol_invalid_operation_result'), results: null};
3289
- }
3290
- seen.add(resultId);
3291
- normalized.push({...clone(result), id: resultId});
3292
- }
3293
- if (seen.size !== sentOperations.length) {
3294
- return {error: new Error('sync_protocol_operation_result_count_mismatch'), results: null};
3295
- }
3296
- return {results: normalized};
3425
+ return {results: results.map((result) => ({...clone(result), id: rtlResultOperationId(result)}))};
3297
3426
  };
3298
3427
 
3299
3428
  const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
@@ -3317,7 +3446,30 @@ export const createRecordTimeLabelSyncEngine = ({
3317
3446
  const processRemote = async (remoteValue, captured) => {
3318
3447
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3319
3448
  const candidate = clone(workspace);
3320
- const applied = applyRemoteBaseline(candidate, remoteValue);
3449
+ const remote = rtlEnvelopePayload(remoteValue);
3450
+ const remoteRevision = Number(remote?.revision);
3451
+ if (!Number.isFinite(remoteRevision)) {
3452
+ return {success: false, protocolError: true, reason: 'invalid_remote_revision'};
3453
+ }
3454
+ if (remoteRevision <= candidate.remoteBaseline.revision) {
3455
+ return {success: true, ignored: true};
3456
+ }
3457
+ let baselineValue = remoteValue;
3458
+ if (remoteRevision > candidate.remoteBaseline.revision + 1) {
3459
+ if (typeof cloud?.bootstrap !== 'function') {
3460
+ return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3461
+ }
3462
+ const context = rtlSessionContext(captured, candidate, client);
3463
+ baselineValue = requireBootstrapRevision(
3464
+ normalizeBootstrapResponse(await cloud.bootstrap(
3465
+ context,
3466
+ bootstrapAttemptOptions('gap-recovery')
3467
+ )),
3468
+ remoteRevision
3469
+ );
3470
+ if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3471
+ }
3472
+ const applied = applyRemoteBaseline(candidate, baselineValue);
3321
3473
  if (applied.stale || !applied.changed) return {success: true, ignored: true};
3322
3474
  if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
3323
3475
  workspace = candidate;
@@ -3325,6 +3477,30 @@ export const createRecordTimeLabelSyncEngine = ({
3325
3477
  return getSnapshot();
3326
3478
  };
3327
3479
 
3480
+ const stopCloudSubscription = () => {
3481
+ if (typeof unsubscribeCloud === 'function') {
3482
+ try { unsubscribeCloud(); } catch (error) {
3483
+ logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
3484
+ }
3485
+ }
3486
+ unsubscribeCloud = null;
3487
+ subscriptionContext = null;
3488
+ };
3489
+
3490
+ const startCloudSubscription = (captured, candidate) => {
3491
+ stopCloudSubscription();
3492
+ if (typeof cloud?.subscribe !== 'function' || !initialized || hydrationRequired) return;
3493
+ const context = rtlSessionContext(captured, candidate, client);
3494
+ subscriptionContext = context;
3495
+ unsubscribeCloud = cloud.subscribe((remoteValue) => {
3496
+ if (destroyed) return;
3497
+ return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3498
+ logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3499
+ return {success: false, error};
3500
+ });
3501
+ }, context);
3502
+ };
3503
+
3328
3504
  const initialize = async () => {
3329
3505
  const captured = capture();
3330
3506
  const loaded = await storage.load();
@@ -3342,7 +3518,10 @@ export const createRecordTimeLabelSyncEngine = ({
3342
3518
  if (captured?.uid && typeof cloud?.bootstrap === 'function') {
3343
3519
  let bootstrap;
3344
3520
  try {
3345
- bootstrap = await cloud.bootstrap(context);
3521
+ bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
3522
+ context,
3523
+ bootstrapAttemptOptions('initial-hydration')
3524
+ ));
3346
3525
  } catch (error) {
3347
3526
  if (!(await isCurrent(captured))) return getSnapshot();
3348
3527
  throw error;
@@ -3356,25 +3535,41 @@ export const createRecordTimeLabelSyncEngine = ({
3356
3535
  if (!(await persist(candidate, captured))) return getSnapshot();
3357
3536
  workspace = candidate;
3358
3537
  initialized = true;
3359
-
3360
- if (typeof cloud?.subscribe === 'function') {
3361
- subscriptionContext = context;
3362
- unsubscribeCloud = cloud.subscribe((remoteValue) => {
3538
+ hydrationRequired = false;
3539
+ startCloudSubscription(captured, candidate);
3540
+ if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3541
+ unsubscribeSession = session.subscribe(() => {
3363
3542
  if (destroyed) return;
3364
- return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3365
- logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3366
- return {success: false, error};
3543
+ enqueue(async () => {
3544
+ const current = capture();
3545
+ const sameIdentity = current.uid === workspace.ownerUid &&
3546
+ Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
3547
+ if (sameIdentity) {
3548
+ startCloudSubscription(current, workspace);
3549
+ return {success: true, tokenRefreshed: true};
3550
+ }
3551
+ stopCloudSubscription();
3552
+ workspace = rtlEmptyDurableWorkspace({
3553
+ ownerUid: current.uid,
3554
+ workspaceEpoch: current.workspaceEpoch
3555
+ });
3556
+ initialized = false;
3557
+ hydrationRequired = true;
3558
+ notify({type: 'session_changed'});
3559
+ return {success: true, sessionChanged: true};
3560
+ }).catch((error) => {
3561
+ logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
3367
3562
  });
3368
- }, context);
3369
- }
3370
- if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3371
- unsubscribeSession = session.subscribe(() => {});
3563
+ });
3372
3564
  }
3373
3565
  notify({type: 'initialized'});
3374
3566
  return getSnapshot();
3375
3567
  };
3376
3568
 
3377
3569
  const dispatchInternal = async (operations) => {
3570
+ if (!initialized || hydrationRequired) {
3571
+ return {success: false, code: 'recordtimelabel_hydration_required'};
3572
+ }
3378
3573
  const captured = capture();
3379
3574
  if (!(await isCurrent(captured))) return getSnapshot();
3380
3575
  const input = Array.isArray(operations)
@@ -3447,6 +3642,9 @@ export const createRecordTimeLabelSyncEngine = ({
3447
3642
  };
3448
3643
 
3449
3644
  const syncInternal = async (reason) => {
3645
+ if (!initialized || hydrationRequired) {
3646
+ return {success: false, code: 'recordtimelabel_hydration_required'};
3647
+ }
3450
3648
  const captured = capture();
3451
3649
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3452
3650
  const timestamp = now();
@@ -3473,7 +3671,10 @@ export const createRecordTimeLabelSyncEngine = ({
3473
3671
  // is a head-of-line barrier; dependent operations behind it must not
3474
3672
  // overtake it merely because they do not have their own deadline yet.
3475
3673
  const ready = [];
3674
+ const firstBatchId = workspace.pendingOperations[0]?.syncBatchId || null;
3476
3675
  for (const operation of workspace.pendingOperations) {
3676
+ if (ready.length >= RTL_MAX_OPERATIONS_PER_REQUEST) break;
3677
+ if (ready.length > 0 && operation.syncBatchId !== firstBatchId) break;
3477
3678
  if (Number.isFinite(Number(operation.nextRetryAt)) &&
3478
3679
  Number(operation.nextRetryAt) > timestamp) break;
3479
3680
  ready.push(operation);
@@ -3500,15 +3701,22 @@ export const createRecordTimeLabelSyncEngine = ({
3500
3701
  return {success: false, reason: 'missing_cloud_apply_operations'};
3501
3702
  }
3502
3703
  const context = rtlSessionContext(captured, workspace, client);
3704
+ const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
3705
+ const requestId = buildRecordTimeLabelRequestId(
3706
+ `${typeof client === 'string' ? client : client?.app || client?.id || 'client'}:${firstBatchId || 'batch'}`,
3707
+ wireOperations
3708
+ );
3503
3709
  let response;
3504
3710
  try {
3505
3711
  response = await cloud.applyOperations({
3506
3712
  schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
3713
+ requestId,
3714
+ syncBatchId: firstBatchId,
3507
3715
  ownerUid: workspace.ownerUid,
3508
3716
  workspaceEpoch: workspace.workspaceEpoch,
3509
3717
  client: clone(client),
3510
3718
  reason: reason ?? null,
3511
- operations: clone(ready)
3719
+ operations: clone(wireOperations)
3512
3720
  }, context);
3513
3721
  } catch (error) {
3514
3722
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
@@ -3561,15 +3769,28 @@ export const createRecordTimeLabelSyncEngine = ({
3561
3769
  };
3562
3770
  }
3563
3771
 
3564
- const parsed = normalizeOperationResults(response, ready);
3772
+ const parsed = normalizeOperationResults(response, wireOperations);
3565
3773
  if (parsed.error) {
3566
3774
  logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
3567
3775
  return {success: false, error: parsed.error, protocolError: true};
3568
3776
  }
3777
+ let sawRetryable = false;
3778
+ for (const result of parsed.results) {
3779
+ if (result.status === 'retryable') {
3780
+ sawRetryable = true;
3781
+ } else if (sawRetryable && (result.status === 'applied' || result.status === 'noop')) {
3782
+ const error = new Error('sync_protocol_fifo_retry_barrier_violation');
3783
+ error.code = 'sync_protocol_fifo_retry_barrier_violation';
3784
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
3785
+ return {success: false, error, protocolError: true};
3786
+ }
3787
+ }
3788
+ const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3569
3789
  const candidate = clone(workspace);
3570
3790
  const resultById = new Map(parsed.results.map((result) => [result.id, result]));
3571
3791
  const readyIds = new Set(ready.map((operation) => operation.id));
3572
3792
  const appliedOperations = [];
3793
+ const completedOperations = [];
3573
3794
  const nextPending = [];
3574
3795
  const nextRejected = {...candidate.rejectedOperations};
3575
3796
  for (const operation of candidate.pendingOperations) {
@@ -3580,12 +3801,15 @@ export const createRecordTimeLabelSyncEngine = ({
3580
3801
  const result = resultById.get(operation.id);
3581
3802
  if (result.status === 'applied') {
3582
3803
  appliedOperations.push(operation);
3804
+ completedOperations.push({operation, status: result.status});
3583
3805
  } else if (result.status === 'noop') {
3584
3806
  // A noop is acknowledged but intentionally not promoted onto the
3585
3807
  // baseline: the server says the operation had no effect.
3808
+ completedOperations.push({operation, status: result.status});
3586
3809
  } else if (result.status === 'retryable') {
3587
3810
  nextPending.push(makeRetryOperation(operation, result, null, timestamp));
3588
3811
  } else if (result.status === 'rejected') {
3812
+ completedOperations.push({operation, status: result.status});
3589
3813
  nextRejected[operation.id] = {
3590
3814
  id: operation.id,
3591
3815
  operation: clone(operation),
@@ -3602,13 +3826,25 @@ export const createRecordTimeLabelSyncEngine = ({
3602
3826
  );
3603
3827
  });
3604
3828
  const responseRevision = Number(response.revision ?? response.remoteRevision);
3605
- if (Number.isFinite(responseRevision) && responseRevision >= candidate.remoteBaseline.revision) {
3606
- candidate.remoteBaseline.revision = responseRevision;
3607
- }
3608
- if (Object.prototype.hasOwnProperty.call(response || {}, 'changeCursor')) {
3609
- candidate.remoteBaseline.changeCursor = response.changeCursor === null || response.changeCursor === undefined
3610
- ? null
3611
- : 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);
3612
3848
  }
3613
3849
  candidate.pendingOperations = nextPending;
3614
3850
  candidate.rejectedOperations = nextRejected;
@@ -3617,13 +3853,26 @@ export const createRecordTimeLabelSyncEngine = ({
3617
3853
  lastSyncedAt: timestamp,
3618
3854
  lastSyncAttemptAt: timestamp,
3619
3855
  lastSyncReason: reason ?? null,
3620
- 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
+ } : {})
3621
3864
  };
3622
- const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3865
+ rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
3623
3866
  if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
3624
3867
  let freshBaseline;
3625
3868
  try {
3626
- freshBaseline = await cloud.bootstrap(context);
3869
+ freshBaseline = requireBootstrapRevision(
3870
+ normalizeBootstrapResponse(await cloud.bootstrap(
3871
+ context,
3872
+ bootstrapAttemptOptions('rejection-rebase')
3873
+ )),
3874
+ responseRevision
3875
+ );
3627
3876
  } catch (error) {
3628
3877
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3629
3878
  throw error;
@@ -3652,7 +3901,7 @@ export const createRecordTimeLabelSyncEngine = ({
3652
3901
  const engine = {
3653
3902
  init() {
3654
3903
  return enqueue(async () => {
3655
- if (initialized) return getSnapshot();
3904
+ if (initialized && !hydrationRequired) return getSnapshot();
3656
3905
  return initialize();
3657
3906
  });
3658
3907
  },
@@ -3676,15 +3925,12 @@ export const createRecordTimeLabelSyncEngine = ({
3676
3925
  destroy() {
3677
3926
  destroyed = true;
3678
3927
  initialized = false;
3679
- if (typeof unsubscribeCloud === 'function') {
3680
- try { unsubscribeCloud(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error); }
3681
- }
3928
+ hydrationRequired = true;
3929
+ stopCloudSubscription();
3682
3930
  if (typeof unsubscribeSession === 'function') {
3683
3931
  try { unsubscribeSession(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable session unsubscribe failed', error); }
3684
3932
  }
3685
- unsubscribeCloud = null;
3686
3933
  unsubscribeSession = null;
3687
- subscriptionContext = null;
3688
3934
  listeners.clear();
3689
3935
  }
3690
3936
  };
@@ -3919,7 +4165,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
3919
4165
  errors.push(`operation_${index}_invalid_${field}`);
3920
4166
  }
3921
4167
  };
3922
- const rtlValidateOperationPayload = (operation, index, errors) => {
4168
+ const rtlValidateOperationPayload = (operation, index, errors, options = {}) => {
3923
4169
  const payload = operation.payload || {};
3924
4170
  const recordId = rtlOperationRecordId(operation);
3925
4171
  const folderId = rtlOperationFolderId(operation);
@@ -3944,6 +4190,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3944
4190
  break;
3945
4191
  case OPERATION_TYPES.RECORD_RESTORE:
3946
4192
  if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
4193
+ if (options.requireLifecycleGeneration === true &&
4194
+ normalizeExpectedGeneration(payload.expectedGeneration) === null) {
4195
+ errors.push(`operation_${index}_invalid_expected_generation`);
4196
+ }
3947
4197
  break;
3948
4198
  case OPERATION_TYPES.RECORD_REORDER:
3949
4199
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
@@ -3964,6 +4214,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3964
4214
  case OPERATION_TYPES.FOLDER_RESTORE:
3965
4215
  case OPERATION_TYPES.TRASH_PURGE:
3966
4216
  if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
4217
+ if (options.requireLifecycleGeneration === true &&
4218
+ normalizeExpectedGeneration(payload.expectedGeneration) === null) {
4219
+ errors.push(`operation_${index}_invalid_expected_generation`);
4220
+ }
3967
4221
  break;
3968
4222
  case OPERATION_TYPES.TRASH_RESTORE_BATCH:
3969
4223
  case OPERATION_TYPES.TRASH_PURGE_BATCH:
@@ -3999,8 +4253,12 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3999
4253
  /**
4000
4254
  * Validates one complete v2 gateway request without reading Firebase state.
4001
4255
  */
4002
- export const validateRecordTimeLabelOperationBatch = (body = {}) => {
4256
+ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
4003
4257
  const errors = [];
4258
+ const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
4259
+ toArray(body?.client?.capabilities).includes(
4260
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
4261
+ );
4004
4262
  let requestBytes = 0;
4005
4263
  try {
4006
4264
  requestBytes = rtlByteLength(JSON.stringify(body));
@@ -4047,7 +4305,7 @@ export const validateRecordTimeLabelOperationBatch = (body = {}) => {
4047
4305
  if (rtlHasInvalidPayloadValue(operation.payload) || operationBytes > 128 * 1024) {
4048
4306
  errors.push(`operation_${index}_payload_too_large`);
4049
4307
  }
4050
- rtlValidateOperationPayload(operation, index, errors);
4308
+ rtlValidateOperationPayload(operation, index, errors, {requireLifecycleGeneration});
4051
4309
  }
4052
4310
  if (!toFiniteTimestamp(operation?.createdAt)) {
4053
4311
  errors.push(`operation_${index}_invalid_created_at`);
@@ -4225,7 +4483,8 @@ export const planFirestoreV2OperationChanges = ({
4225
4483
  documents,
4226
4484
  operations,
4227
4485
  localState = {},
4228
- now = Date.now()
4486
+ now = Date.now(),
4487
+ requireLifecycleGeneration = true
4229
4488
  } = {}) => {
4230
4489
  const previousDocuments = rtlCloneDocuments(documents);
4231
4490
  const nextDocuments = rtlCloneDocuments(documents);
@@ -4381,6 +4640,15 @@ export const planFirestoreV2OperationChanges = ({
4381
4640
  const trashEntryId = rtlOperationTrashEntryId(operation);
4382
4641
  const trashEntry = nextDocuments.trash[trashEntryId];
4383
4642
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
4643
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
4644
+ if (requireLifecycleGeneration !== false && expectedGeneration === null) {
4645
+ reason = 'lifecycle_generation_required';
4646
+ break;
4647
+ }
4648
+ if (expectedGeneration !== null && expectedGeneration !== Number(trashEntry.lifecycleGeneration)) {
4649
+ reason = 'lifecycle_conflict';
4650
+ break;
4651
+ }
4384
4652
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationNow) {
4385
4653
  reason = 'trash_entry_expired';
4386
4654
  break;
@@ -4391,7 +4659,16 @@ export const planFirestoreV2OperationChanges = ({
4391
4659
  break;
4392
4660
  }
4393
4661
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
4394
- applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
4662
+ // Explicit legacy compatibility is planner-local. It does not change the
4663
+ // original operation, its wire bytes, or the strict durable reducer contract.
4664
+ const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
4665
+ ? {...operation, payload: {...payload, expectedGeneration: trashEntry.lifecycleGeneration}}
4666
+ : operation;
4667
+ applied = rtlApplyOperationToPartialDocuments({
4668
+ ...nextDocuments,
4669
+ operation: reducerOperation,
4670
+ now: operationNow
4671
+ });
4395
4672
  const restoredRecord = applied.documents.records[restoredRecordId];
4396
4673
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
4397
4674
  const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
@@ -4415,8 +4692,12 @@ export const planFirestoreV2OperationChanges = ({
4415
4692
  const trashEntryId = rtlOperationTrashEntryId(operation);
4416
4693
  const trashEntry = nextDocuments.trash[trashEntryId];
4417
4694
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
4418
- if (payload.expectedGeneration &&
4419
- Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) {
4695
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
4696
+ if (requireLifecycleGeneration !== false && expectedGeneration === null) {
4697
+ reason = 'lifecycle_generation_required';
4698
+ break;
4699
+ }
4700
+ if (expectedGeneration !== null && expectedGeneration !== Number(trashEntry.lifecycleGeneration)) {
4420
4701
  reason = 'lifecycle_conflict';
4421
4702
  break;
4422
4703
  }
@@ -4676,7 +4957,11 @@ export const buildOperationsFromSnapshotDiff = ({
4676
4957
  export default {
4677
4958
  RECORD_TIMELABEL_CORE_VERSION,
4678
4959
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
4960
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
4961
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
4962
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
4679
4963
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
4964
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
4680
4965
  RTL_SYNC_PROTOCOL_VERSION,
4681
4966
  RTL_MAX_OPERATIONS_PER_REQUEST,
4682
4967
  RTL_MAX_REQUEST_BYTES,
@@ -4718,6 +5003,9 @@ export default {
4718
5003
  estimateFirestoreV2WriteUnits,
4719
5004
  normalizeRecordTimeLabelOperationResults,
4720
5005
  normalizeRecordTimeLabelEnvelopeResponse,
5006
+ toRecordTimeLabelWireOperation,
5007
+ buildRecordTimeLabelRequestId,
5008
+ createRecordTimeLabelTransportFailureResults,
4721
5009
  buildOperationsFromSnapshotDiff,
4722
5010
  flushPendingOperations,
4723
5011
  mergeRemoteStateIntoLocal,
package/src/protocol.js CHANGED
@@ -11,6 +11,18 @@ export const RECORD_TIMELABEL_OPERATION_RESULT_STATUSES = Object.freeze({
11
11
  RETRYABLE: 'retryable'
12
12
  });
13
13
 
14
+ export const RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE =
15
+ 'operation-conflict-quarantine';
16
+ export const RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS =
17
+ 'strict-operation-results';
18
+ export const RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE =
19
+ 'lifecycle-generation-fence';
20
+ export const RECORD_TIMELABEL_PROTOCOL_CAPABILITIES = Object.freeze([
21
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
22
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
23
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
24
+ ]);
25
+
14
26
  const ALLOWED_OPERATION_RESULT_STATUSES = new Set(
15
27
  Object.values(RECORD_TIMELABEL_OPERATION_RESULT_STATUSES)
16
28
  );
@@ -27,6 +39,84 @@ const asObject = (value) => (
27
39
  value && typeof value === 'object' && !Array.isArray(value) ? value : {}
28
40
  );
29
41
 
42
+ const createProtocolError = (code, details = {}) => {
43
+ const error = new Error(code);
44
+ error.code = code;
45
+ Object.assign(error, details);
46
+ return error;
47
+ };
48
+
49
+ const normalizeId = (value) => (
50
+ typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
51
+ );
52
+
53
+ const fnv1a = (value) => {
54
+ let hash = 2166136261;
55
+ for (let index = 0; index < value.length; index += 1) {
56
+ hash ^= value.charCodeAt(index);
57
+ hash = Math.imul(hash, 16777619);
58
+ }
59
+ return (hash >>> 0).toString(16).padStart(8, '0');
60
+ };
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
+
93
+ export const toRecordTimeLabelWireOperation = (operation = {}) => {
94
+ const source = asObject(operation);
95
+ return {
96
+ id: source.id ?? null,
97
+ type: source.type ?? null,
98
+ payload: asObject(source.payload),
99
+ clientId: source.clientId ?? null,
100
+ createdAt: source.createdAt ?? null
101
+ };
102
+ };
103
+
104
+ export const buildRecordTimeLabelRequestId = (namespace, operations = []) => {
105
+ const safeNamespace = normalizeId(namespace)
106
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
107
+ .slice(0, 48) || 'recordtimelabel';
108
+ const wireOperations = Array.isArray(operations)
109
+ ? operations.map((operation) => toRecordTimeLabelWireOperation(operation))
110
+ : [];
111
+ const orderedIds = wireOperations.map((operation) => normalizeId(operation.id));
112
+ const firstOperationId = (orderedIds[0] || 'empty')
113
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
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);
118
+ };
119
+
30
120
  const normalizeStatus = (result) => {
31
121
  if (ALLOWED_OPERATION_RESULT_STATUSES.has(result.status)) {
32
122
  return result.status;
@@ -59,24 +149,46 @@ const isSuccessfulEnvelope = (response) => {
59
149
  * Unknown result properties are retained for forward compatibility, while
60
150
  * all contract fields below are replaced with their normalized values.
61
151
  */
62
- export const normalizeRecordTimeLabelOperationResults = (operations, results) => {
152
+ export const normalizeRecordTimeLabelOperationResults = (operations, results, options = {}) => {
63
153
  if (!Array.isArray(operations) || !Array.isArray(results) || operations.length !== results.length) {
64
154
  throw createCountMismatchError(operations, results);
65
155
  }
156
+ const strict = options.strict !== false;
157
+ const operationIds = operations.map((operation) => normalizeId(asObject(operation).id));
158
+ if (strict && (operationIds.some((id) => !id) || new Set(operationIds).size !== operationIds.length)) {
159
+ throw createProtocolError('operation_request_invalid_ids');
160
+ }
66
161
 
67
- return operations.map((rawOperation, index) => {
68
- const operation = asObject(rawOperation);
69
- const result = asObject(results[index]);
162
+ const normalizedById = new Map();
163
+ const normalized = results.map((rawResult, index) => {
164
+ const operation = asObject(operations[index]);
165
+ const result = asObject(rawResult);
70
166
  const status = normalizeStatus(result);
71
- const operationId = result.operationId ?? result.id ?? operation.id ?? null;
72
- const id = result.id ?? operationId;
167
+ const explicitOperationId = normalizeId(result.operationId);
168
+ const explicitId = normalizeId(result.id);
169
+ if (strict && explicitOperationId && explicitId && explicitOperationId !== explicitId) {
170
+ throw createProtocolError('operation_result_ambiguous_id', {resultIndex: index});
171
+ }
172
+ const operationId = explicitOperationId || explicitId || (strict ? '' : normalizeId(operation.id));
173
+ if (strict && !operationId) {
174
+ throw createProtocolError('operation_result_missing_id', {resultIndex: index});
175
+ }
176
+ if (strict && !operationIds.includes(operationId)) {
177
+ throw createProtocolError('operation_result_unknown_id', {resultIndex: index, operationId});
178
+ }
179
+ if (strict && normalizedById.has(operationId)) {
180
+ throw createProtocolError('operation_result_duplicate_id', {resultIndex: index, operationId});
181
+ }
182
+ const matchedOperation = strict
183
+ ? asObject(operations[operationIds.indexOf(operationId)])
184
+ : operation;
185
+ const id = strict ? operationId : (result.id ?? operationId ?? null);
73
186
  const retryAfterMs = result.retryAfterMs;
74
-
75
- return {
187
+ const value = {
76
188
  ...result,
77
- operationId,
189
+ operationId: operationId || null,
78
190
  id,
79
- type: result.type ?? operation.type ?? null,
191
+ type: result.type ?? matchedOperation.type ?? null,
80
192
  applied: result.applied === true,
81
193
  status,
82
194
  retryable: status === RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.RETRYABLE,
@@ -85,15 +197,21 @@ export const normalizeRecordTimeLabelOperationResults = (operations, results) =>
85
197
  ? retryAfterMs
86
198
  : null
87
199
  };
200
+ if (strict) normalizedById.set(operationId, value);
201
+ return value;
88
202
  });
203
+ if (!strict) return normalized;
204
+ if (normalizedById.size !== operations.length) {
205
+ throw createProtocolError('operation_result_incomplete');
206
+ }
207
+ return operationIds.map((id) => normalizedById.get(id));
89
208
  };
90
209
 
91
210
  /**
92
- * Normalize a successful operation envelope. During the compatibility
93
- * window, a successful legacy envelope with no operationResults property is
94
- * treated as acknowledging every submitted operation.
211
+ * Normalize a successful operation envelope. Legacy success without explicit
212
+ * results is available only when the caller opts into that compatibility path.
95
213
  */
96
- export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response) => {
214
+ export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response, options = {}) => {
97
215
  if (!Array.isArray(operations)) {
98
216
  throw createCountMismatchError(operations, response?.operationResults);
99
217
  }
@@ -104,9 +222,9 @@ export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response) =
104
222
  Object.prototype.hasOwnProperty.call(response, 'operationResults')
105
223
  );
106
224
  if (hasExplicitResults) {
107
- return normalizeRecordTimeLabelOperationResults(operations, response.operationResults);
225
+ return normalizeRecordTimeLabelOperationResults(operations, response.operationResults, options);
108
226
  }
109
- if (!isSuccessfulEnvelope(response)) {
227
+ if (!isSuccessfulEnvelope(response) || options.allowLegacySuccessWithoutResults !== true) {
110
228
  throw createCountMismatchError(operations, null);
111
229
  }
112
230
 
@@ -117,12 +235,44 @@ export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response) =
117
235
  id: asObject(operation).id ?? null,
118
236
  applied: true,
119
237
  status: RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.APPLIED
120
- }))
238
+ })),
239
+ options
121
240
  );
122
241
  };
123
242
 
243
+ export const createRecordTimeLabelTransportFailureResults = (
244
+ operations,
245
+ failure,
246
+ options = {}
247
+ ) => {
248
+ const retryable = options.retryable !== false;
249
+ const retryAfterMs = Number(options.retryAfterMs ?? failure?.retryAfterMs);
250
+ const reason = typeof failure === 'string'
251
+ ? failure
252
+ : failure?.code || failure?.reason || failure?.message || 'transport_failure';
253
+ return (Array.isArray(operations) ? operations : []).map((operation) => ({
254
+ operationId: normalizeId(asObject(operation).id) || null,
255
+ id: normalizeId(asObject(operation).id) || null,
256
+ type: asObject(operation).type ?? null,
257
+ applied: false,
258
+ status: retryable
259
+ ? RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.RETRYABLE
260
+ : RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.REJECTED,
261
+ retryable,
262
+ reason,
263
+ retryAfterMs: Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? retryAfterMs : null
264
+ }));
265
+ };
266
+
124
267
  export default {
125
268
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
269
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
270
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
271
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
272
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
273
+ toRecordTimeLabelWireOperation,
274
+ buildRecordTimeLabelRequestId,
275
+ createRecordTimeLabelTransportFailureResults,
126
276
  normalizeRecordTimeLabelOperationResults,
127
277
  normalizeRecordTimeLabelEnvelopeResponse
128
278
  };