@recordtimelabel/core 0.4.1 → 0.4.3

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.1` (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 strict durable transport contract is prepared in package version `0.4.3` (publish it before updating consumers):
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.4.1"
23
+ "@recordtimelabel/core": "0.4.3"
24
24
  ```
25
25
 
26
26
  If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
@@ -33,8 +33,13 @@ If this checkout's `package.json` is ahead of the published version, publish the
33
33
  - `applyOperation(state, operation)`
34
34
  - `applyRecordTimeLabelOperation(state, operation)`
35
35
  - `createRecordTimeLabelSyncEngine({ storage, cloud, session, client, clock, logger })`
36
- - `normalizeRecordTimeLabelOperationResults(operations, results)`
37
- - `normalizeRecordTimeLabelEnvelopeResponse(operations, response)`
36
+ - `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
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)`
38
43
  - `createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })`
39
44
  - `createRecordTimeLabelController({ storageAdapter, cloudAdapter, clientId, settingKeys, clock, logger })`
40
45
  - `buildRecordTimeLabelSyncPayload({ data, previousCloudData, pendingOps, syncMode, clientId, now, groupOrderNormalizer })`
@@ -52,7 +57,7 @@ If this checkout's `package.json` is ahead of the published version, publish the
52
57
  - `buildFirestoreV2OperationReadPlan(operations)`
53
58
  - `extendFirestoreV2OperationReadPlanWithRecords(readPlan, records)`
54
59
  - `extendFirestoreV2OperationReadPlanWithTrash(readPlan, trashEntries)`
55
- - `planFirestoreV2OperationChanges({ documents, operations, localState, now })`
60
+ - `planFirestoreV2OperationChanges({ documents, operations, localState, now, requireLifecycleGeneration })`
56
61
  - `estimateFirestoreV2WriteUnits(changes, overhead)`
57
62
  - `buildOperationsFromSnapshotDiff({ previousState, nextState, now, operationIdPrefix, batchSize })`(一般操作在 `batches`,`folder.delete` 在 `bulkOperations`)
58
63
  - `RTL_SYNC_PROTOCOL_VERSION`
@@ -78,7 +83,7 @@ and operation planners, and `/compat` contains the legacy `createSyncEngine` and
78
83
 
79
84
  `createRecordTimeLabelSyncEngine` is the platform-neutral durable workspace API. Its storage port
80
85
  has `load()` and `save(workspace)` methods (adapters may consume the optional second
81
- `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)`,
82
87
  `applyOperations(envelope, context)`, and `subscribe(listener, context)` methods. The session port
83
88
  provides `current()`, `subscribe(listener)`, and `isCurrent(sessionToken, uid, workspaceEpoch)`.
84
89
 
@@ -102,6 +107,19 @@ operations missing them inherit the migrated workspace identity. An operation wh
102
107
  explicit identity does not match the current workspace is moved to
103
108
  `rejectedOperations` with a stable identity-mismatch reason before any cloud apply.
104
109
 
110
+ Pending operations also carry an internal `syncBatchId`. Operations dispatched in
111
+ one call share a batch boundary, while legacy pending operations are assigned one
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 operation IDs. `syncBatchId`, identity fences, and retry scheduling fields stay
115
+ outside canonical wire operations.
116
+
117
+ Bootstrap attempts use `initial-hydration`, `rejection-rebase`, or `gap-recovery`
118
+ modes with a unique `attemptId`; all require a fresh logical attempt. Structured
119
+ failures, missing state, and missing/invalid revisions fail closed. UID or workspace
120
+ 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.
122
+
105
123
  The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
106
124
  `subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
107
125
  operations over `remoteBaseline.state`; rejected operations are kept in diagnostics and are not
@@ -117,21 +135,26 @@ so older clients can continue reading the view until they migrate.
117
135
  ### Operation-result protocol
118
136
 
119
137
  The root package and `@recordtimelabel/core/protocol` export the shared acknowledgement
120
- normalizers. `normalizeRecordTimeLabelOperationResults(operations, results)` always returns one
121
- positional result per submitted operation. The only normalized statuses are `applied`, `noop`,
138
+ normalizers. `normalizeRecordTimeLabelOperationResults(operations, results)` verifies count,
139
+ request membership, unique IDs, and completeness before returning results in request order.
140
+ The only normalized statuses are `applied`, `noop`,
122
141
  `rejected`, and `retryable`; `retryable` is derived from that status, while the legacy `id` and
123
142
  `applied` fields remain available alongside `operationId`, `reason`, and `retryAfterMs`.
124
143
 
125
- `normalizeRecordTimeLabelEnvelopeResponse(operations, response)` prefers an explicit
126
- `operationResults` array. An explicitly provided non-array or mismatched array, and a failed
127
- envelope without results, throw an error whose `code` is `operation_result_count_mismatch`, so
128
- callers can reject the entire acknowledgement before changing durable state.
129
-
130
- During the compatibility window, a successful legacy envelope that omits the
131
- `operationResults` property acknowledges every submitted operation as `applied`. Failed envelopes
132
- and envelopes that explicitly provide a malformed result value never receive that fallback. Keep
133
- the fallback until all deployed gateways and clients send and consume the explicit array; removing
134
- it requires a coordinated breaking release.
144
+ `normalizeRecordTimeLabelEnvelopeResponse(operations, response)` requires an explicit
145
+ `operationResults` array by default. Malformed, duplicate, missing, unknown, or ambiguous IDs
146
+ throw stable protocol errors so callers can reject the entire acknowledgement before changing
147
+ durable state.
148
+
149
+ Gateway-only compatibility code may opt into successful legacy envelopes with
150
+ `{allowLegacySuccessWithoutResults: true}`. New durable clients never enable that fallback.
151
+ Capabilities `operation-conflict-quarantine`, `strict-operation-results`, and
152
+ `lifecycle-generation-fence` gate the corresponding 0.4.3 behavior. Single
153
+ `record.restore`, `folder.restore`, and `trash.purge` operations require a positive
154
+ `expectedGeneration`; batch lifecycle operations retain their existing contract. The gateway
155
+ validator derives enforcement from `client.capabilities`. The planner is strict by default;
156
+ the time-limited legacy gateway path must explicitly pass `requireLifecycleGeneration: false`
157
+ for clients that did not declare the capability. A supplied but stale generation is always rejected.
135
158
 
136
159
  ## Firestore v1 Compatibility
137
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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,8 +31,16 @@ const REQUIRED_FOLDERS = [
17
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
18
32
  ];
19
33
 
20
- export const RECORD_TIMELABEL_CORE_VERSION = '0.4.1';
34
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.4.3';
21
35
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
36
+ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
37
+ 'fifo-retry-fence',
38
+ 'baseline-refresh-after-rejection',
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
43
+ ]);
22
44
  export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
23
45
  export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
24
46
  export const RTL_MAX_TARGET_WRITES = 100;
@@ -52,6 +74,11 @@ const ORDER_OPERATION_TYPES = new Set([
52
74
  OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
53
75
  ]);
54
76
 
77
+ const normalizeExpectedGeneration = (value) => {
78
+ const generation = Number(value);
79
+ return Number.isSafeInteger(generation) && generation > 0 ? generation : null;
80
+ };
81
+
55
82
  export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
56
83
  FULL: 'full',
57
84
  MERGE: 'merge'
@@ -66,7 +93,10 @@ export const FIRESTORE_V2_SETTINGS_DOC_ID = 'main';
66
93
 
67
94
  export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
68
95
  OWNER_MISMATCH: 'operation_owner_mismatch',
69
- 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'
70
100
  });
71
101
 
72
102
  const toArray = (value) => (Array.isArray(value) ? value : []);
@@ -946,8 +976,9 @@ export const applyOperation = (state = {}, operation = {}) => {
946
976
  const trashEntry = nextState.trashEntries[trashEntryId];
947
977
  if (!trashEntry || trashEntry.kind !== 'record') return normalized;
948
978
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
949
- if (payload.expectedGeneration &&
950
- 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;
951
982
  const recordId = normalizeId(trashEntry.entityId);
952
983
  if (!recordId || findRecordEntry(nextState.records, recordId)) return normalized;
953
984
  const recordSnapshot = trashEntry.payload?.record;
@@ -1075,8 +1106,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1075
1106
  const trashEntry = nextState.trashEntries[trashEntryId];
1076
1107
  if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
1077
1108
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
1078
- if (payload.expectedGeneration &&
1079
- 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;
1080
1112
  const folderId = normalizeId(trashEntry.entityId);
1081
1113
  if (!folderId || nextState.folders.some((folder) => folder.id === folderId)) return normalized;
1082
1114
  const folderSnapshot = trashEntry.payload?.folder;
@@ -1121,8 +1153,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1121
1153
  const trashEntryId = normalizeId(payload.trashEntryId || payload.id);
1122
1154
  const trashEntry = nextState.trashEntries[trashEntryId];
1123
1155
  if (!trashEntry) return normalized;
1124
- if (payload.expectedGeneration &&
1125
- 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;
1126
1159
  delete nextState.trashEntries[trashEntryId];
1127
1160
  break;
1128
1161
  }
@@ -2729,6 +2762,16 @@ const rtlNormalizePendingOperation = (operation, options = {}) => {
2729
2762
  return normalized;
2730
2763
  };
2731
2764
 
2765
+ 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')}`;
2773
+ };
2774
+
2732
2775
  const rtlNormalizeRemoteBaseline = (value = {}) => {
2733
2776
  const baseline = rtlDurableIsObject(value) ? value : {};
2734
2777
  return {
@@ -2772,6 +2815,10 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
2772
2815
  const pendingOperations = toArray(pendingSource)
2773
2816
  .filter(Boolean)
2774
2817
  .map((operation) => rtlNormalizePendingOperation(operation, options));
2818
+ const legacyBatchId = rtlStableSyncBatchId(pendingOperations);
2819
+ pendingOperations.forEach((operation) => {
2820
+ if (!operation.syncBatchId) operation.syncBatchId = legacyBatchId;
2821
+ });
2775
2822
  const legacyExpandedGroups = Array.isArray(source.syncMeta?.legacyExpandedGroups)
2776
2823
  ? normalizeOrder(source.syncMeta.legacyExpandedGroups)
2777
2824
  : Array.isArray(source.state?.expandedGroups)
@@ -2822,23 +2869,74 @@ const rtlOperationIdentityReason = (operation, workspace) => {
2822
2869
  if (operationEpoch !== workspaceEpoch) {
2823
2870
  return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.WORKSPACE_EPOCH_MISMATCH;
2824
2871
  }
2872
+ if ([
2873
+ OPERATION_TYPES.RECORD_RESTORE,
2874
+ OPERATION_TYPES.FOLDER_RESTORE,
2875
+ OPERATION_TYPES.TRASH_PURGE
2876
+ ].includes(operation?.type) && normalizeExpectedGeneration(operation?.payload?.expectedGeneration) === null) {
2877
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_GENERATION_REQUIRED;
2878
+ }
2879
+ return null;
2880
+ };
2881
+
2882
+ const rtlOperationWireFingerprint = (operation) => JSON.stringify(
2883
+ canonicalizeFirestoreV2DocumentValue(toRecordTimeLabelWireOperation(operation))
2884
+ );
2885
+
2886
+ const rtlLifecycleOperationReason = (operation, state) => {
2887
+ if (![
2888
+ OPERATION_TYPES.RECORD_RESTORE,
2889
+ OPERATION_TYPES.FOLDER_RESTORE,
2890
+ OPERATION_TYPES.TRASH_PURGE
2891
+ ].includes(operation?.type)) return null;
2892
+ const expectedGeneration = normalizeExpectedGeneration(operation?.payload?.expectedGeneration);
2893
+ if (expectedGeneration === null) {
2894
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_GENERATION_REQUIRED;
2895
+ }
2896
+ const trashEntryId = normalizeId(operation?.payload?.trashEntryId || operation?.payload?.id);
2897
+ const trashEntry = state?.trashEntries?.[trashEntryId];
2898
+ if (trashEntry && Number(trashEntry.lifecycleGeneration) !== expectedGeneration) {
2899
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_CONFLICT;
2900
+ }
2825
2901
  return null;
2826
2902
  };
2827
2903
 
2828
2904
  const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now()) => {
2829
2905
  const accepted = [];
2830
2906
  const rejected = {...(workspace?.rejectedOperations || {})};
2907
+ const knownById = new Map();
2908
+ let lifecycleState = operations === workspace?.pendingOperations
2909
+ ? normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {})
2910
+ : rtlDeriveDurableVisibleState(workspace);
2911
+ if (operations !== workspace?.pendingOperations) {
2912
+ toArray(workspace?.pendingOperations).forEach((operation) => {
2913
+ const id = normalizeId(operation?.id);
2914
+ if (id && !knownById.has(id)) knownById.set(id, rtlOperationWireFingerprint(operation));
2915
+ });
2916
+ }
2831
2917
  let rejectedCount = 0;
2832
2918
  toArray(operations).forEach((operation, index) => {
2833
- const reason = rtlOperationIdentityReason(operation, workspace);
2919
+ const id = normalizeId(operation?.id);
2920
+ const fingerprint = rtlOperationWireFingerprint(operation);
2921
+ const knownFingerprint = id ? knownById.get(id) : null;
2922
+ let reason = rtlOperationIdentityReason(operation, workspace);
2923
+ if (!reason) reason = rtlLifecycleOperationReason(operation, lifecycleState);
2924
+ if (!reason && knownFingerprint) {
2925
+ if (knownFingerprint === fingerprint) return;
2926
+ reason = RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.ID_CONFLICT;
2927
+ }
2834
2928
  if (!reason) {
2835
2929
  accepted.push(operation);
2930
+ if (id) knownById.set(id, fingerprint);
2931
+ lifecycleState = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
2932
+ ? lifecycleState
2933
+ : normalizeRecordTimeLabelDomainState(applyRecordTimeLabelOperation(lifecycleState, operation));
2836
2934
  return;
2837
2935
  }
2838
2936
  rejectedCount += 1;
2839
- const id = normalizeId(operation?.id) || `identity-rejected:${timestamp}:${index}`;
2840
- rejected[id] = {
2841
- id,
2937
+ const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
2938
+ rejected[rejectedId] = {
2939
+ id: rejectedId,
2842
2940
  operation: clone(operation),
2843
2941
  status: 'rejected',
2844
2942
  reason,
@@ -3005,10 +3103,12 @@ export const createRecordTimeLabelSyncEngine = ({
3005
3103
 
3006
3104
  let workspace = rtlEmptyDurableWorkspace();
3007
3105
  let initialized = false;
3106
+ let hydrationRequired = true;
3008
3107
  let destroyed = false;
3009
3108
  let unsubscribeCloud = null;
3010
3109
  let unsubscribeSession = null;
3011
3110
  let subscriptionContext = null;
3111
+ let bootstrapAttemptSequence = 0;
3012
3112
  const listeners = new Set();
3013
3113
  let queue = Promise.resolve();
3014
3114
 
@@ -3101,6 +3201,31 @@ export const createRecordTimeLabelSyncEngine = ({
3101
3201
  return {changed, stale: false};
3102
3202
  };
3103
3203
 
3204
+ const bootstrapAttemptOptions = (mode) => ({
3205
+ mode,
3206
+ attemptId: `bootstrap:${mode}:${now()}:${++bootstrapAttemptSequence}:${
3207
+ globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2, 14)
3208
+ }`,
3209
+ requireFresh: true
3210
+ });
3211
+
3212
+ const normalizeBootstrapResponse = (response) => {
3213
+ if (!rtlEnvelopeSuccess(response)) {
3214
+ const error = new Error(response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed');
3215
+ error.code = response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed';
3216
+ throw error;
3217
+ }
3218
+ const remote = rtlEnvelopePayload(response);
3219
+ const revision = Number(remote?.revision);
3220
+ const state = remote?.state ?? remote?.data;
3221
+ if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
3222
+ const error = new Error('recordtimelabel_invalid_bootstrap_response');
3223
+ error.code = 'recordtimelabel_invalid_bootstrap_response';
3224
+ throw error;
3225
+ }
3226
+ return {state, revision, changeCursor: remote.changeCursor ?? null};
3227
+ };
3228
+
3104
3229
  const normalizeLoadedWorkspace = (loaded, captured) => {
3105
3230
  const source = loaded && typeof loaded === 'object' ? loaded : {};
3106
3231
  const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
@@ -3238,43 +3363,11 @@ export const createRecordTimeLabelSyncEngine = ({
3238
3363
  const normalizeOperationResults = (response, sentOperations) => {
3239
3364
  let results;
3240
3365
  try {
3241
- // Keep the Plan 017 engine's private input aliases compatible while the
3242
- // public shared contract remains strict about operationResults arrays.
3243
- const legacyOperationResults = response?.operationResults;
3244
- const compatibleResponse = rtlDurableIsObject(legacyOperationResults)
3245
- ? {
3246
- ...response,
3247
- operationResults: Object.entries(legacyOperationResults).map(([id, result]) => ({
3248
- ...(result || {}),
3249
- id: result?.id || id
3250
- }))
3251
- }
3252
- : (
3253
- !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults') &&
3254
- Array.isArray(response?.results)
3255
- ? {...response, operationResults: response.results}
3256
- : response
3257
- );
3258
- results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, compatibleResponse);
3366
+ results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, response);
3259
3367
  } catch (error) {
3260
3368
  return {error, results: null};
3261
3369
  }
3262
- const byId = new Map(sentOperations.map((operation) => [operation.id, operation]));
3263
- const normalized = [];
3264
- const seen = new Set();
3265
- for (let index = 0; index < results.length; index += 1) {
3266
- const result = results[index] || {};
3267
- const resultId = rtlResultOperationId(result) || sentOperations[index]?.id;
3268
- if (!resultId || !byId.has(resultId) || seen.has(resultId)) {
3269
- return {error: new Error('sync_protocol_invalid_operation_result'), results: null};
3270
- }
3271
- seen.add(resultId);
3272
- normalized.push({...clone(result), id: resultId});
3273
- }
3274
- if (seen.size !== sentOperations.length) {
3275
- return {error: new Error('sync_protocol_operation_result_count_mismatch'), results: null};
3276
- }
3277
- return {results: normalized};
3370
+ return {results: results.map((result) => ({...clone(result), id: rtlResultOperationId(result)}))};
3278
3371
  };
3279
3372
 
3280
3373
  const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
@@ -3298,7 +3391,27 @@ export const createRecordTimeLabelSyncEngine = ({
3298
3391
  const processRemote = async (remoteValue, captured) => {
3299
3392
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3300
3393
  const candidate = clone(workspace);
3301
- const applied = applyRemoteBaseline(candidate, remoteValue);
3394
+ const remote = rtlEnvelopePayload(remoteValue);
3395
+ const remoteRevision = Number(remote?.revision);
3396
+ if (!Number.isFinite(remoteRevision)) {
3397
+ return {success: false, protocolError: true, reason: 'invalid_remote_revision'};
3398
+ }
3399
+ if (remoteRevision <= candidate.remoteBaseline.revision) {
3400
+ return {success: true, ignored: true};
3401
+ }
3402
+ let baselineValue = remoteValue;
3403
+ if (remoteRevision > candidate.remoteBaseline.revision + 1) {
3404
+ if (typeof cloud?.bootstrap !== 'function') {
3405
+ return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3406
+ }
3407
+ const context = rtlSessionContext(captured, candidate, client);
3408
+ baselineValue = normalizeBootstrapResponse(await cloud.bootstrap(
3409
+ context,
3410
+ bootstrapAttemptOptions('gap-recovery')
3411
+ ));
3412
+ if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3413
+ }
3414
+ const applied = applyRemoteBaseline(candidate, baselineValue);
3302
3415
  if (applied.stale || !applied.changed) return {success: true, ignored: true};
3303
3416
  if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
3304
3417
  workspace = candidate;
@@ -3306,6 +3419,30 @@ export const createRecordTimeLabelSyncEngine = ({
3306
3419
  return getSnapshot();
3307
3420
  };
3308
3421
 
3422
+ const stopCloudSubscription = () => {
3423
+ if (typeof unsubscribeCloud === 'function') {
3424
+ try { unsubscribeCloud(); } catch (error) {
3425
+ logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
3426
+ }
3427
+ }
3428
+ unsubscribeCloud = null;
3429
+ subscriptionContext = null;
3430
+ };
3431
+
3432
+ const startCloudSubscription = (captured, candidate) => {
3433
+ stopCloudSubscription();
3434
+ if (typeof cloud?.subscribe !== 'function' || !initialized || hydrationRequired) return;
3435
+ const context = rtlSessionContext(captured, candidate, client);
3436
+ subscriptionContext = context;
3437
+ unsubscribeCloud = cloud.subscribe((remoteValue) => {
3438
+ if (destroyed) return;
3439
+ return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3440
+ logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3441
+ return {success: false, error};
3442
+ });
3443
+ }, context);
3444
+ };
3445
+
3309
3446
  const initialize = async () => {
3310
3447
  const captured = capture();
3311
3448
  const loaded = await storage.load();
@@ -3323,7 +3460,10 @@ export const createRecordTimeLabelSyncEngine = ({
3323
3460
  if (captured?.uid && typeof cloud?.bootstrap === 'function') {
3324
3461
  let bootstrap;
3325
3462
  try {
3326
- bootstrap = await cloud.bootstrap(context);
3463
+ bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
3464
+ context,
3465
+ bootstrapAttemptOptions('initial-hydration')
3466
+ ));
3327
3467
  } catch (error) {
3328
3468
  if (!(await isCurrent(captured))) return getSnapshot();
3329
3469
  throw error;
@@ -3337,25 +3477,41 @@ export const createRecordTimeLabelSyncEngine = ({
3337
3477
  if (!(await persist(candidate, captured))) return getSnapshot();
3338
3478
  workspace = candidate;
3339
3479
  initialized = true;
3340
-
3341
- if (typeof cloud?.subscribe === 'function') {
3342
- subscriptionContext = context;
3343
- unsubscribeCloud = cloud.subscribe((remoteValue) => {
3480
+ hydrationRequired = false;
3481
+ startCloudSubscription(captured, candidate);
3482
+ if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3483
+ unsubscribeSession = session.subscribe(() => {
3344
3484
  if (destroyed) return;
3345
- return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3346
- logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3347
- return {success: false, error};
3485
+ enqueue(async () => {
3486
+ const current = capture();
3487
+ const sameIdentity = current.uid === workspace.ownerUid &&
3488
+ Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
3489
+ if (sameIdentity) {
3490
+ startCloudSubscription(current, workspace);
3491
+ return {success: true, tokenRefreshed: true};
3492
+ }
3493
+ stopCloudSubscription();
3494
+ workspace = rtlEmptyDurableWorkspace({
3495
+ ownerUid: current.uid,
3496
+ workspaceEpoch: current.workspaceEpoch
3497
+ });
3498
+ initialized = false;
3499
+ hydrationRequired = true;
3500
+ notify({type: 'session_changed'});
3501
+ return {success: true, sessionChanged: true};
3502
+ }).catch((error) => {
3503
+ logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
3348
3504
  });
3349
- }, context);
3350
- }
3351
- if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3352
- unsubscribeSession = session.subscribe(() => {});
3505
+ });
3353
3506
  }
3354
3507
  notify({type: 'initialized'});
3355
3508
  return getSnapshot();
3356
3509
  };
3357
3510
 
3358
3511
  const dispatchInternal = async (operations) => {
3512
+ if (!initialized || hydrationRequired) {
3513
+ return {success: false, code: 'recordtimelabel_hydration_required'};
3514
+ }
3359
3515
  const captured = capture();
3360
3516
  if (!(await isCurrent(captured))) return getSnapshot();
3361
3517
  const input = Array.isArray(operations)
@@ -3374,6 +3530,10 @@ export const createRecordTimeLabelSyncEngine = ({
3374
3530
  ownerUid: captured?.uid ?? candidate.ownerUid,
3375
3531
  workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
3376
3532
  }));
3533
+ const syncBatchId = rtlStableSyncBatchId(normalized);
3534
+ normalized.forEach((operation) => {
3535
+ if (!operation.syncBatchId) operation.syncBatchId = syncBatchId;
3536
+ });
3377
3537
  const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
3378
3538
  const viewOperations = identityChecked.accepted.filter((operation) => (
3379
3539
  operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
@@ -3424,6 +3584,9 @@ export const createRecordTimeLabelSyncEngine = ({
3424
3584
  };
3425
3585
 
3426
3586
  const syncInternal = async (reason) => {
3587
+ if (!initialized || hydrationRequired) {
3588
+ return {success: false, code: 'recordtimelabel_hydration_required'};
3589
+ }
3427
3590
  const captured = capture();
3428
3591
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3429
3592
  const timestamp = now();
@@ -3446,9 +3609,18 @@ export const createRecordTimeLabelSyncEngine = ({
3446
3609
  workspace = candidate;
3447
3610
  notify({type: 'operations_rejected', rejectedCount: identityRejectedCount});
3448
3611
  }
3449
- const ready = workspace.pendingOperations.filter((operation) => (
3450
- !Number.isFinite(Number(operation.nextRetryAt)) || Number(operation.nextRetryAt) <= timestamp
3451
- ));
3612
+ // Pending operations are a FIFO log. A retry deadline on an earlier entry
3613
+ // is a head-of-line barrier; dependent operations behind it must not
3614
+ // overtake it merely because they do not have their own deadline yet.
3615
+ const ready = [];
3616
+ const firstBatchId = workspace.pendingOperations[0]?.syncBatchId || null;
3617
+ for (const operation of workspace.pendingOperations) {
3618
+ if (ready.length >= RTL_MAX_OPERATIONS_PER_REQUEST) break;
3619
+ if (ready.length > 0 && operation.syncBatchId !== firstBatchId) break;
3620
+ if (Number.isFinite(Number(operation.nextRetryAt)) &&
3621
+ Number(operation.nextRetryAt) > timestamp) break;
3622
+ ready.push(operation);
3623
+ }
3452
3624
  if (ready.length === 0) {
3453
3625
  return {
3454
3626
  success: true,
@@ -3471,15 +3643,22 @@ export const createRecordTimeLabelSyncEngine = ({
3471
3643
  return {success: false, reason: 'missing_cloud_apply_operations'};
3472
3644
  }
3473
3645
  const context = rtlSessionContext(captured, workspace, client);
3646
+ const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
3647
+ const requestId = buildRecordTimeLabelRequestId(
3648
+ `${typeof client === 'string' ? client : client?.app || client?.id || 'client'}:${firstBatchId || 'batch'}`,
3649
+ wireOperations
3650
+ );
3474
3651
  let response;
3475
3652
  try {
3476
3653
  response = await cloud.applyOperations({
3477
3654
  schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
3655
+ requestId,
3656
+ syncBatchId: firstBatchId,
3478
3657
  ownerUid: workspace.ownerUid,
3479
3658
  workspaceEpoch: workspace.workspaceEpoch,
3480
3659
  client: clone(client),
3481
3660
  reason: reason ?? null,
3482
- operations: clone(ready)
3661
+ operations: clone(wireOperations)
3483
3662
  }, context);
3484
3663
  } catch (error) {
3485
3664
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
@@ -3532,11 +3711,22 @@ export const createRecordTimeLabelSyncEngine = ({
3532
3711
  };
3533
3712
  }
3534
3713
 
3535
- const parsed = normalizeOperationResults(response, ready);
3714
+ const parsed = normalizeOperationResults(response, wireOperations);
3536
3715
  if (parsed.error) {
3537
3716
  logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
3538
3717
  return {success: false, error: parsed.error, protocolError: true};
3539
3718
  }
3719
+ let sawRetryable = false;
3720
+ for (const result of parsed.results) {
3721
+ if (result.status === 'retryable') {
3722
+ sawRetryable = true;
3723
+ } else if (sawRetryable && (result.status === 'applied' || result.status === 'noop')) {
3724
+ const error = new Error('sync_protocol_fifo_retry_barrier_violation');
3725
+ error.code = 'sync_protocol_fifo_retry_barrier_violation';
3726
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
3727
+ return {success: false, error, protocolError: true};
3728
+ }
3729
+ }
3540
3730
  const candidate = clone(workspace);
3541
3731
  const resultById = new Map(parsed.results.map((result) => [result.id, result]));
3542
3732
  const readyIds = new Set(ready.map((operation) => operation.id));
@@ -3590,6 +3780,21 @@ export const createRecordTimeLabelSyncEngine = ({
3590
3780
  lastSyncReason: reason ?? null,
3591
3781
  lastSyncError: null
3592
3782
  };
3783
+ const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
3784
+ if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
3785
+ let freshBaseline;
3786
+ try {
3787
+ freshBaseline = normalizeBootstrapResponse(await cloud.bootstrap(
3788
+ context,
3789
+ bootstrapAttemptOptions('rejection-rebase')
3790
+ ));
3791
+ } catch (error) {
3792
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3793
+ throw error;
3794
+ }
3795
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3796
+ applyRemoteBaseline(candidate, freshBaseline);
3797
+ }
3593
3798
  if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
3594
3799
  workspace = candidate;
3595
3800
  notify({type: 'synced', operations: clone(parsed.results)});
@@ -3600,7 +3805,7 @@ export const createRecordTimeLabelSyncEngine = ({
3600
3805
  success: true,
3601
3806
  appliedCount: appliedOperations.length,
3602
3807
  syncedCount: appliedOperations.length + parsed.results.filter((result) => result.status === 'noop').length,
3603
- rejectedCount: parsed.results.filter((result) => result.status === 'rejected').length,
3808
+ rejectedCount,
3604
3809
  retryCount: parsed.results.filter((result) => result.status === 'retryable').length,
3605
3810
  retryAt: Number.isFinite(retryAt) ? retryAt : null,
3606
3811
  pendingCount: workspace.pendingOperations.length,
@@ -3611,7 +3816,7 @@ export const createRecordTimeLabelSyncEngine = ({
3611
3816
  const engine = {
3612
3817
  init() {
3613
3818
  return enqueue(async () => {
3614
- if (initialized) return getSnapshot();
3819
+ if (initialized && !hydrationRequired) return getSnapshot();
3615
3820
  return initialize();
3616
3821
  });
3617
3822
  },
@@ -3635,15 +3840,12 @@ export const createRecordTimeLabelSyncEngine = ({
3635
3840
  destroy() {
3636
3841
  destroyed = true;
3637
3842
  initialized = false;
3638
- if (typeof unsubscribeCloud === 'function') {
3639
- try { unsubscribeCloud(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error); }
3640
- }
3843
+ hydrationRequired = true;
3844
+ stopCloudSubscription();
3641
3845
  if (typeof unsubscribeSession === 'function') {
3642
3846
  try { unsubscribeSession(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable session unsubscribe failed', error); }
3643
3847
  }
3644
- unsubscribeCloud = null;
3645
3848
  unsubscribeSession = null;
3646
- subscriptionContext = null;
3647
3849
  listeners.clear();
3648
3850
  }
3649
3851
  };
@@ -3878,7 +4080,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
3878
4080
  errors.push(`operation_${index}_invalid_${field}`);
3879
4081
  }
3880
4082
  };
3881
- const rtlValidateOperationPayload = (operation, index, errors) => {
4083
+ const rtlValidateOperationPayload = (operation, index, errors, options = {}) => {
3882
4084
  const payload = operation.payload || {};
3883
4085
  const recordId = rtlOperationRecordId(operation);
3884
4086
  const folderId = rtlOperationFolderId(operation);
@@ -3903,6 +4105,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3903
4105
  break;
3904
4106
  case OPERATION_TYPES.RECORD_RESTORE:
3905
4107
  if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
4108
+ if (options.requireLifecycleGeneration === true &&
4109
+ normalizeExpectedGeneration(payload.expectedGeneration) === null) {
4110
+ errors.push(`operation_${index}_invalid_expected_generation`);
4111
+ }
3906
4112
  break;
3907
4113
  case OPERATION_TYPES.RECORD_REORDER:
3908
4114
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
@@ -3923,6 +4129,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3923
4129
  case OPERATION_TYPES.FOLDER_RESTORE:
3924
4130
  case OPERATION_TYPES.TRASH_PURGE:
3925
4131
  if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
4132
+ if (options.requireLifecycleGeneration === true &&
4133
+ normalizeExpectedGeneration(payload.expectedGeneration) === null) {
4134
+ errors.push(`operation_${index}_invalid_expected_generation`);
4135
+ }
3926
4136
  break;
3927
4137
  case OPERATION_TYPES.TRASH_RESTORE_BATCH:
3928
4138
  case OPERATION_TYPES.TRASH_PURGE_BATCH:
@@ -3958,8 +4168,12 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3958
4168
  /**
3959
4169
  * Validates one complete v2 gateway request without reading Firebase state.
3960
4170
  */
3961
- export const validateRecordTimeLabelOperationBatch = (body = {}) => {
4171
+ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
3962
4172
  const errors = [];
4173
+ const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
4174
+ toArray(body?.client?.capabilities).includes(
4175
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
4176
+ );
3963
4177
  let requestBytes = 0;
3964
4178
  try {
3965
4179
  requestBytes = rtlByteLength(JSON.stringify(body));
@@ -4006,7 +4220,7 @@ export const validateRecordTimeLabelOperationBatch = (body = {}) => {
4006
4220
  if (rtlHasInvalidPayloadValue(operation.payload) || operationBytes > 128 * 1024) {
4007
4221
  errors.push(`operation_${index}_payload_too_large`);
4008
4222
  }
4009
- rtlValidateOperationPayload(operation, index, errors);
4223
+ rtlValidateOperationPayload(operation, index, errors, {requireLifecycleGeneration});
4010
4224
  }
4011
4225
  if (!toFiniteTimestamp(operation?.createdAt)) {
4012
4226
  errors.push(`operation_${index}_invalid_created_at`);
@@ -4184,7 +4398,8 @@ export const planFirestoreV2OperationChanges = ({
4184
4398
  documents,
4185
4399
  operations,
4186
4400
  localState = {},
4187
- now = Date.now()
4401
+ now = Date.now(),
4402
+ requireLifecycleGeneration = true
4188
4403
  } = {}) => {
4189
4404
  const previousDocuments = rtlCloneDocuments(documents);
4190
4405
  const nextDocuments = rtlCloneDocuments(documents);
@@ -4340,6 +4555,15 @@ export const planFirestoreV2OperationChanges = ({
4340
4555
  const trashEntryId = rtlOperationTrashEntryId(operation);
4341
4556
  const trashEntry = nextDocuments.trash[trashEntryId];
4342
4557
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
4558
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
4559
+ if (requireLifecycleGeneration !== false && expectedGeneration === null) {
4560
+ reason = 'lifecycle_generation_required';
4561
+ break;
4562
+ }
4563
+ if (expectedGeneration !== null && expectedGeneration !== Number(trashEntry.lifecycleGeneration)) {
4564
+ reason = 'lifecycle_conflict';
4565
+ break;
4566
+ }
4343
4567
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationNow) {
4344
4568
  reason = 'trash_entry_expired';
4345
4569
  break;
@@ -4350,7 +4574,16 @@ export const planFirestoreV2OperationChanges = ({
4350
4574
  break;
4351
4575
  }
4352
4576
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
4353
- applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
4577
+ // Explicit legacy compatibility is planner-local. It does not change the
4578
+ // original operation, its wire bytes, or the strict durable reducer contract.
4579
+ const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
4580
+ ? {...operation, payload: {...payload, expectedGeneration: trashEntry.lifecycleGeneration}}
4581
+ : operation;
4582
+ applied = rtlApplyOperationToPartialDocuments({
4583
+ ...nextDocuments,
4584
+ operation: reducerOperation,
4585
+ now: operationNow
4586
+ });
4354
4587
  const restoredRecord = applied.documents.records[restoredRecordId];
4355
4588
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
4356
4589
  const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
@@ -4374,8 +4607,12 @@ export const planFirestoreV2OperationChanges = ({
4374
4607
  const trashEntryId = rtlOperationTrashEntryId(operation);
4375
4608
  const trashEntry = nextDocuments.trash[trashEntryId];
4376
4609
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
4377
- if (payload.expectedGeneration &&
4378
- Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) {
4610
+ const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
4611
+ if (requireLifecycleGeneration !== false && expectedGeneration === null) {
4612
+ reason = 'lifecycle_generation_required';
4613
+ break;
4614
+ }
4615
+ if (expectedGeneration !== null && expectedGeneration !== Number(trashEntry.lifecycleGeneration)) {
4379
4616
  reason = 'lifecycle_conflict';
4380
4617
  break;
4381
4618
  }
@@ -4634,7 +4871,12 @@ export const buildOperationsFromSnapshotDiff = ({
4634
4871
 
4635
4872
  export default {
4636
4873
  RECORD_TIMELABEL_CORE_VERSION,
4874
+ RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
4875
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
4876
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
4877
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
4637
4878
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
4879
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
4638
4880
  RTL_SYNC_PROTOCOL_VERSION,
4639
4881
  RTL_MAX_OPERATIONS_PER_REQUEST,
4640
4882
  RTL_MAX_REQUEST_BYTES,
@@ -4676,6 +4918,9 @@ export default {
4676
4918
  estimateFirestoreV2WriteUnits,
4677
4919
  normalizeRecordTimeLabelOperationResults,
4678
4920
  normalizeRecordTimeLabelEnvelopeResponse,
4921
+ toRecordTimeLabelWireOperation,
4922
+ buildRecordTimeLabelRequestId,
4923
+ createRecordTimeLabelTransportFailureResults,
4679
4924
  buildOperationsFromSnapshotDiff,
4680
4925
  flushPendingOperations,
4681
4926
  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,50 @@ 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
+ export const toRecordTimeLabelWireOperation = (operation = {}) => {
63
+ const source = asObject(operation);
64
+ return {
65
+ id: source.id ?? null,
66
+ type: source.type ?? null,
67
+ payload: asObject(source.payload),
68
+ clientId: source.clientId ?? null,
69
+ createdAt: source.createdAt ?? null
70
+ };
71
+ };
72
+
73
+ export const buildRecordTimeLabelRequestId = (namespace, operations = []) => {
74
+ const safeNamespace = normalizeId(namespace)
75
+ .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))
79
+ : [];
80
+ const firstOperationId = (orderedIds[0] || 'empty')
81
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
82
+ .slice(0, 72) || 'empty';
83
+ return `rtl:${safeNamespace}:${firstOperationId}:${fnv1a(orderedIds.join('\u001f'))}`.slice(0, 160);
84
+ };
85
+
30
86
  const normalizeStatus = (result) => {
31
87
  if (ALLOWED_OPERATION_RESULT_STATUSES.has(result.status)) {
32
88
  return result.status;
@@ -59,24 +115,46 @@ const isSuccessfulEnvelope = (response) => {
59
115
  * Unknown result properties are retained for forward compatibility, while
60
116
  * all contract fields below are replaced with their normalized values.
61
117
  */
62
- export const normalizeRecordTimeLabelOperationResults = (operations, results) => {
118
+ export const normalizeRecordTimeLabelOperationResults = (operations, results, options = {}) => {
63
119
  if (!Array.isArray(operations) || !Array.isArray(results) || operations.length !== results.length) {
64
120
  throw createCountMismatchError(operations, results);
65
121
  }
122
+ const strict = options.strict !== false;
123
+ const operationIds = operations.map((operation) => normalizeId(asObject(operation).id));
124
+ if (strict && (operationIds.some((id) => !id) || new Set(operationIds).size !== operationIds.length)) {
125
+ throw createProtocolError('operation_request_invalid_ids');
126
+ }
66
127
 
67
- return operations.map((rawOperation, index) => {
68
- const operation = asObject(rawOperation);
69
- const result = asObject(results[index]);
128
+ const normalizedById = new Map();
129
+ const normalized = results.map((rawResult, index) => {
130
+ const operation = asObject(operations[index]);
131
+ const result = asObject(rawResult);
70
132
  const status = normalizeStatus(result);
71
- const operationId = result.operationId ?? result.id ?? operation.id ?? null;
72
- const id = result.id ?? operationId;
133
+ const explicitOperationId = normalizeId(result.operationId);
134
+ const explicitId = normalizeId(result.id);
135
+ if (strict && explicitOperationId && explicitId && explicitOperationId !== explicitId) {
136
+ throw createProtocolError('operation_result_ambiguous_id', {resultIndex: index});
137
+ }
138
+ const operationId = explicitOperationId || explicitId || (strict ? '' : normalizeId(operation.id));
139
+ if (strict && !operationId) {
140
+ throw createProtocolError('operation_result_missing_id', {resultIndex: index});
141
+ }
142
+ if (strict && !operationIds.includes(operationId)) {
143
+ throw createProtocolError('operation_result_unknown_id', {resultIndex: index, operationId});
144
+ }
145
+ if (strict && normalizedById.has(operationId)) {
146
+ throw createProtocolError('operation_result_duplicate_id', {resultIndex: index, operationId});
147
+ }
148
+ const matchedOperation = strict
149
+ ? asObject(operations[operationIds.indexOf(operationId)])
150
+ : operation;
151
+ const id = strict ? operationId : (result.id ?? operationId ?? null);
73
152
  const retryAfterMs = result.retryAfterMs;
74
-
75
- return {
153
+ const value = {
76
154
  ...result,
77
- operationId,
155
+ operationId: operationId || null,
78
156
  id,
79
- type: result.type ?? operation.type ?? null,
157
+ type: result.type ?? matchedOperation.type ?? null,
80
158
  applied: result.applied === true,
81
159
  status,
82
160
  retryable: status === RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.RETRYABLE,
@@ -85,15 +163,21 @@ export const normalizeRecordTimeLabelOperationResults = (operations, results) =>
85
163
  ? retryAfterMs
86
164
  : null
87
165
  };
166
+ if (strict) normalizedById.set(operationId, value);
167
+ return value;
88
168
  });
169
+ if (!strict) return normalized;
170
+ if (normalizedById.size !== operations.length) {
171
+ throw createProtocolError('operation_result_incomplete');
172
+ }
173
+ return operationIds.map((id) => normalizedById.get(id));
89
174
  };
90
175
 
91
176
  /**
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.
177
+ * Normalize a successful operation envelope. Legacy success without explicit
178
+ * results is available only when the caller opts into that compatibility path.
95
179
  */
96
- export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response) => {
180
+ export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response, options = {}) => {
97
181
  if (!Array.isArray(operations)) {
98
182
  throw createCountMismatchError(operations, response?.operationResults);
99
183
  }
@@ -104,9 +188,9 @@ export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response) =
104
188
  Object.prototype.hasOwnProperty.call(response, 'operationResults')
105
189
  );
106
190
  if (hasExplicitResults) {
107
- return normalizeRecordTimeLabelOperationResults(operations, response.operationResults);
191
+ return normalizeRecordTimeLabelOperationResults(operations, response.operationResults, options);
108
192
  }
109
- if (!isSuccessfulEnvelope(response)) {
193
+ if (!isSuccessfulEnvelope(response) || options.allowLegacySuccessWithoutResults !== true) {
110
194
  throw createCountMismatchError(operations, null);
111
195
  }
112
196
 
@@ -117,12 +201,44 @@ export const normalizeRecordTimeLabelEnvelopeResponse = (operations, response) =
117
201
  id: asObject(operation).id ?? null,
118
202
  applied: true,
119
203
  status: RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.APPLIED
120
- }))
204
+ })),
205
+ options
121
206
  );
122
207
  };
123
208
 
209
+ export const createRecordTimeLabelTransportFailureResults = (
210
+ operations,
211
+ failure,
212
+ options = {}
213
+ ) => {
214
+ const retryable = options.retryable !== false;
215
+ const retryAfterMs = Number(options.retryAfterMs ?? failure?.retryAfterMs);
216
+ const reason = typeof failure === 'string'
217
+ ? failure
218
+ : failure?.code || failure?.reason || failure?.message || 'transport_failure';
219
+ return (Array.isArray(operations) ? operations : []).map((operation) => ({
220
+ operationId: normalizeId(asObject(operation).id) || null,
221
+ id: normalizeId(asObject(operation).id) || null,
222
+ type: asObject(operation).type ?? null,
223
+ applied: false,
224
+ status: retryable
225
+ ? RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.RETRYABLE
226
+ : RECORD_TIMELABEL_OPERATION_RESULT_STATUSES.REJECTED,
227
+ retryable,
228
+ reason,
229
+ retryAfterMs: Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? retryAfterMs : null
230
+ }));
231
+ };
232
+
124
233
  export default {
125
234
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
235
+ RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
236
+ RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
237
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
238
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
239
+ toRecordTimeLabelWireOperation,
240
+ buildRecordTimeLabelRequestId,
241
+ createRecordTimeLabelTransportFailureResults,
126
242
  normalizeRecordTimeLabelOperationResults,
127
243
  normalizeRecordTimeLabelEnvelopeResponse
128
244
  };