@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 +41 -18
- package/package.json +1 -1
- package/src/firestore-v2.js +7 -0
- package/src/index.js +325 -80
- package/src/protocol.js +133 -17
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
|
|
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.
|
|
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
|
-
- `
|
|
37
|
-
- `
|
|
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)`
|
|
121
|
-
|
|
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)`
|
|
126
|
-
`operationResults` array
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
package/src/firestore-v2.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
950
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
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
|
|
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
|
|
2840
|
-
rejected[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3480
|
+
hydrationRequired = false;
|
|
3481
|
+
startCloudSubscription(captured, candidate);
|
|
3482
|
+
if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
|
|
3483
|
+
unsubscribeSession = session.subscribe(() => {
|
|
3344
3484
|
if (destroyed) return;
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
3450
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
3639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4378
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
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
|
|
72
|
-
const
|
|
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 ??
|
|
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.
|
|
93
|
-
*
|
|
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
|
};
|