@recordtimelabel/core 0.4.2 → 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.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 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.2"
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.
@@ -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,16 @@ 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 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.
110
122
 
111
123
  The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
112
124
  `subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
@@ -123,21 +135,26 @@ so older clients can continue reading the view until they migrate.
123
135
  ### Operation-result protocol
124
136
 
125
137
  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`,
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`,
128
141
  `rejected`, and `retryable`; `retryable` is derived from that status, while the legacy `id` and
129
142
  `applied` fields remain available alongside `operationId`, `reason`, and `retryAfterMs`.
130
143
 
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.
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.
141
158
 
142
159
  ## Firestore v1 Compatibility
143
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.2",
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,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.3';
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
  }
@@ -2841,23 +2869,74 @@ const rtlOperationIdentityReason = (operation, workspace) => {
2841
2869
  if (operationEpoch !== workspaceEpoch) {
2842
2870
  return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.WORKSPACE_EPOCH_MISMATCH;
2843
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
+ }
2844
2901
  return null;
2845
2902
  };
2846
2903
 
2847
2904
  const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now()) => {
2848
2905
  const accepted = [];
2849
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
+ }
2850
2917
  let rejectedCount = 0;
2851
2918
  toArray(operations).forEach((operation, index) => {
2852
- 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
+ }
2853
2928
  if (!reason) {
2854
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));
2855
2934
  return;
2856
2935
  }
2857
2936
  rejectedCount += 1;
2858
- const id = normalizeId(operation?.id) || `identity-rejected:${timestamp}:${index}`;
2859
- rejected[id] = {
2860
- id,
2937
+ const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
2938
+ rejected[rejectedId] = {
2939
+ id: rejectedId,
2861
2940
  operation: clone(operation),
2862
2941
  status: 'rejected',
2863
2942
  reason,
@@ -3024,10 +3103,12 @@ export const createRecordTimeLabelSyncEngine = ({
3024
3103
 
3025
3104
  let workspace = rtlEmptyDurableWorkspace();
3026
3105
  let initialized = false;
3106
+ let hydrationRequired = true;
3027
3107
  let destroyed = false;
3028
3108
  let unsubscribeCloud = null;
3029
3109
  let unsubscribeSession = null;
3030
3110
  let subscriptionContext = null;
3111
+ let bootstrapAttemptSequence = 0;
3031
3112
  const listeners = new Set();
3032
3113
  let queue = Promise.resolve();
3033
3114
 
@@ -3120,6 +3201,31 @@ export const createRecordTimeLabelSyncEngine = ({
3120
3201
  return {changed, stale: false};
3121
3202
  };
3122
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
+
3123
3229
  const normalizeLoadedWorkspace = (loaded, captured) => {
3124
3230
  const source = loaded && typeof loaded === 'object' ? loaded : {};
3125
3231
  const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
@@ -3257,43 +3363,11 @@ export const createRecordTimeLabelSyncEngine = ({
3257
3363
  const normalizeOperationResults = (response, sentOperations) => {
3258
3364
  let results;
3259
3365
  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);
3366
+ results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, response);
3278
3367
  } catch (error) {
3279
3368
  return {error, results: null};
3280
3369
  }
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};
3370
+ return {results: results.map((result) => ({...clone(result), id: rtlResultOperationId(result)}))};
3297
3371
  };
3298
3372
 
3299
3373
  const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
@@ -3317,7 +3391,27 @@ export const createRecordTimeLabelSyncEngine = ({
3317
3391
  const processRemote = async (remoteValue, captured) => {
3318
3392
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3319
3393
  const candidate = clone(workspace);
3320
- 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);
3321
3415
  if (applied.stale || !applied.changed) return {success: true, ignored: true};
3322
3416
  if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
3323
3417
  workspace = candidate;
@@ -3325,6 +3419,30 @@ export const createRecordTimeLabelSyncEngine = ({
3325
3419
  return getSnapshot();
3326
3420
  };
3327
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
+
3328
3446
  const initialize = async () => {
3329
3447
  const captured = capture();
3330
3448
  const loaded = await storage.load();
@@ -3342,7 +3460,10 @@ export const createRecordTimeLabelSyncEngine = ({
3342
3460
  if (captured?.uid && typeof cloud?.bootstrap === 'function') {
3343
3461
  let bootstrap;
3344
3462
  try {
3345
- bootstrap = await cloud.bootstrap(context);
3463
+ bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
3464
+ context,
3465
+ bootstrapAttemptOptions('initial-hydration')
3466
+ ));
3346
3467
  } catch (error) {
3347
3468
  if (!(await isCurrent(captured))) return getSnapshot();
3348
3469
  throw error;
@@ -3356,25 +3477,41 @@ export const createRecordTimeLabelSyncEngine = ({
3356
3477
  if (!(await persist(candidate, captured))) return getSnapshot();
3357
3478
  workspace = candidate;
3358
3479
  initialized = true;
3359
-
3360
- if (typeof cloud?.subscribe === 'function') {
3361
- subscriptionContext = context;
3362
- unsubscribeCloud = cloud.subscribe((remoteValue) => {
3480
+ hydrationRequired = false;
3481
+ startCloudSubscription(captured, candidate);
3482
+ if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3483
+ unsubscribeSession = session.subscribe(() => {
3363
3484
  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};
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);
3367
3504
  });
3368
- }, context);
3369
- }
3370
- if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3371
- unsubscribeSession = session.subscribe(() => {});
3505
+ });
3372
3506
  }
3373
3507
  notify({type: 'initialized'});
3374
3508
  return getSnapshot();
3375
3509
  };
3376
3510
 
3377
3511
  const dispatchInternal = async (operations) => {
3512
+ if (!initialized || hydrationRequired) {
3513
+ return {success: false, code: 'recordtimelabel_hydration_required'};
3514
+ }
3378
3515
  const captured = capture();
3379
3516
  if (!(await isCurrent(captured))) return getSnapshot();
3380
3517
  const input = Array.isArray(operations)
@@ -3447,6 +3584,9 @@ export const createRecordTimeLabelSyncEngine = ({
3447
3584
  };
3448
3585
 
3449
3586
  const syncInternal = async (reason) => {
3587
+ if (!initialized || hydrationRequired) {
3588
+ return {success: false, code: 'recordtimelabel_hydration_required'};
3589
+ }
3450
3590
  const captured = capture();
3451
3591
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3452
3592
  const timestamp = now();
@@ -3473,7 +3613,10 @@ export const createRecordTimeLabelSyncEngine = ({
3473
3613
  // is a head-of-line barrier; dependent operations behind it must not
3474
3614
  // overtake it merely because they do not have their own deadline yet.
3475
3615
  const ready = [];
3616
+ const firstBatchId = workspace.pendingOperations[0]?.syncBatchId || null;
3476
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;
3477
3620
  if (Number.isFinite(Number(operation.nextRetryAt)) &&
3478
3621
  Number(operation.nextRetryAt) > timestamp) break;
3479
3622
  ready.push(operation);
@@ -3500,15 +3643,22 @@ export const createRecordTimeLabelSyncEngine = ({
3500
3643
  return {success: false, reason: 'missing_cloud_apply_operations'};
3501
3644
  }
3502
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
+ );
3503
3651
  let response;
3504
3652
  try {
3505
3653
  response = await cloud.applyOperations({
3506
3654
  schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
3655
+ requestId,
3656
+ syncBatchId: firstBatchId,
3507
3657
  ownerUid: workspace.ownerUid,
3508
3658
  workspaceEpoch: workspace.workspaceEpoch,
3509
3659
  client: clone(client),
3510
3660
  reason: reason ?? null,
3511
- operations: clone(ready)
3661
+ operations: clone(wireOperations)
3512
3662
  }, context);
3513
3663
  } catch (error) {
3514
3664
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
@@ -3561,11 +3711,22 @@ export const createRecordTimeLabelSyncEngine = ({
3561
3711
  };
3562
3712
  }
3563
3713
 
3564
- const parsed = normalizeOperationResults(response, ready);
3714
+ const parsed = normalizeOperationResults(response, wireOperations);
3565
3715
  if (parsed.error) {
3566
3716
  logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
3567
3717
  return {success: false, error: parsed.error, protocolError: true};
3568
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
+ }
3569
3730
  const candidate = clone(workspace);
3570
3731
  const resultById = new Map(parsed.results.map((result) => [result.id, result]));
3571
3732
  const readyIds = new Set(ready.map((operation) => operation.id));
@@ -3623,7 +3784,10 @@ export const createRecordTimeLabelSyncEngine = ({
3623
3784
  if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
3624
3785
  let freshBaseline;
3625
3786
  try {
3626
- freshBaseline = await cloud.bootstrap(context);
3787
+ freshBaseline = normalizeBootstrapResponse(await cloud.bootstrap(
3788
+ context,
3789
+ bootstrapAttemptOptions('rejection-rebase')
3790
+ ));
3627
3791
  } catch (error) {
3628
3792
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3629
3793
  throw error;
@@ -3652,7 +3816,7 @@ export const createRecordTimeLabelSyncEngine = ({
3652
3816
  const engine = {
3653
3817
  init() {
3654
3818
  return enqueue(async () => {
3655
- if (initialized) return getSnapshot();
3819
+ if (initialized && !hydrationRequired) return getSnapshot();
3656
3820
  return initialize();
3657
3821
  });
3658
3822
  },
@@ -3676,15 +3840,12 @@ export const createRecordTimeLabelSyncEngine = ({
3676
3840
  destroy() {
3677
3841
  destroyed = true;
3678
3842
  initialized = false;
3679
- if (typeof unsubscribeCloud === 'function') {
3680
- try { unsubscribeCloud(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error); }
3681
- }
3843
+ hydrationRequired = true;
3844
+ stopCloudSubscription();
3682
3845
  if (typeof unsubscribeSession === 'function') {
3683
3846
  try { unsubscribeSession(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable session unsubscribe failed', error); }
3684
3847
  }
3685
- unsubscribeCloud = null;
3686
3848
  unsubscribeSession = null;
3687
- subscriptionContext = null;
3688
3849
  listeners.clear();
3689
3850
  }
3690
3851
  };
@@ -3919,7 +4080,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
3919
4080
  errors.push(`operation_${index}_invalid_${field}`);
3920
4081
  }
3921
4082
  };
3922
- const rtlValidateOperationPayload = (operation, index, errors) => {
4083
+ const rtlValidateOperationPayload = (operation, index, errors, options = {}) => {
3923
4084
  const payload = operation.payload || {};
3924
4085
  const recordId = rtlOperationRecordId(operation);
3925
4086
  const folderId = rtlOperationFolderId(operation);
@@ -3944,6 +4105,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3944
4105
  break;
3945
4106
  case OPERATION_TYPES.RECORD_RESTORE:
3946
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
+ }
3947
4112
  break;
3948
4113
  case OPERATION_TYPES.RECORD_REORDER:
3949
4114
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
@@ -3964,6 +4129,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3964
4129
  case OPERATION_TYPES.FOLDER_RESTORE:
3965
4130
  case OPERATION_TYPES.TRASH_PURGE:
3966
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
+ }
3967
4136
  break;
3968
4137
  case OPERATION_TYPES.TRASH_RESTORE_BATCH:
3969
4138
  case OPERATION_TYPES.TRASH_PURGE_BATCH:
@@ -3999,8 +4168,12 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
3999
4168
  /**
4000
4169
  * Validates one complete v2 gateway request without reading Firebase state.
4001
4170
  */
4002
- export const validateRecordTimeLabelOperationBatch = (body = {}) => {
4171
+ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
4003
4172
  const errors = [];
4173
+ const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
4174
+ toArray(body?.client?.capabilities).includes(
4175
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
4176
+ );
4004
4177
  let requestBytes = 0;
4005
4178
  try {
4006
4179
  requestBytes = rtlByteLength(JSON.stringify(body));
@@ -4047,7 +4220,7 @@ export const validateRecordTimeLabelOperationBatch = (body = {}) => {
4047
4220
  if (rtlHasInvalidPayloadValue(operation.payload) || operationBytes > 128 * 1024) {
4048
4221
  errors.push(`operation_${index}_payload_too_large`);
4049
4222
  }
4050
- rtlValidateOperationPayload(operation, index, errors);
4223
+ rtlValidateOperationPayload(operation, index, errors, {requireLifecycleGeneration});
4051
4224
  }
4052
4225
  if (!toFiniteTimestamp(operation?.createdAt)) {
4053
4226
  errors.push(`operation_${index}_invalid_created_at`);
@@ -4225,7 +4398,8 @@ export const planFirestoreV2OperationChanges = ({
4225
4398
  documents,
4226
4399
  operations,
4227
4400
  localState = {},
4228
- now = Date.now()
4401
+ now = Date.now(),
4402
+ requireLifecycleGeneration = true
4229
4403
  } = {}) => {
4230
4404
  const previousDocuments = rtlCloneDocuments(documents);
4231
4405
  const nextDocuments = rtlCloneDocuments(documents);
@@ -4381,6 +4555,15 @@ export const planFirestoreV2OperationChanges = ({
4381
4555
  const trashEntryId = rtlOperationTrashEntryId(operation);
4382
4556
  const trashEntry = nextDocuments.trash[trashEntryId];
4383
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
+ }
4384
4567
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationNow) {
4385
4568
  reason = 'trash_entry_expired';
4386
4569
  break;
@@ -4391,7 +4574,16 @@ export const planFirestoreV2OperationChanges = ({
4391
4574
  break;
4392
4575
  }
4393
4576
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
4394
- 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
+ });
4395
4587
  const restoredRecord = applied.documents.records[restoredRecordId];
4396
4588
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
4397
4589
  const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
@@ -4415,8 +4607,12 @@ export const planFirestoreV2OperationChanges = ({
4415
4607
  const trashEntryId = rtlOperationTrashEntryId(operation);
4416
4608
  const trashEntry = nextDocuments.trash[trashEntryId];
4417
4609
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
4418
- if (payload.expectedGeneration &&
4419
- 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)) {
4420
4616
  reason = 'lifecycle_conflict';
4421
4617
  break;
4422
4618
  }
@@ -4676,7 +4872,11 @@ export const buildOperationsFromSnapshotDiff = ({
4676
4872
  export default {
4677
4873
  RECORD_TIMELABEL_CORE_VERSION,
4678
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,
4679
4878
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
4879
+ RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
4680
4880
  RTL_SYNC_PROTOCOL_VERSION,
4681
4881
  RTL_MAX_OPERATIONS_PER_REQUEST,
4682
4882
  RTL_MAX_REQUEST_BYTES,
@@ -4718,6 +4918,9 @@ export default {
4718
4918
  estimateFirestoreV2WriteUnits,
4719
4919
  normalizeRecordTimeLabelOperationResults,
4720
4920
  normalizeRecordTimeLabelEnvelopeResponse,
4921
+ toRecordTimeLabelWireOperation,
4922
+ buildRecordTimeLabelRequestId,
4923
+ createRecordTimeLabelTransportFailureResults,
4721
4924
  buildOperationsFromSnapshotDiff,
4722
4925
  flushPendingOperations,
4723
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
  };