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