@recordtimelabel/core 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -20
- package/package.json +1 -1
- package/src/firestore-v2.js +7 -0
- package/src/index.js +281 -78
- 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.
|
|
@@ -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,16 @@ explicit identity does not match the current workspace is moved to
|
|
|
105
109
|
|
|
106
110
|
Pending operations also carry an internal `syncBatchId`. Operations dispatched in
|
|
107
111
|
one call share a batch boundary, while legacy pending operations are assigned one
|
|
108
|
-
stable boundary during migration.
|
|
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 operation IDs. `syncBatchId`, identity fences, and retry scheduling fields stay
|
|
115
|
+
outside canonical wire operations.
|
|
116
|
+
|
|
117
|
+
Bootstrap attempts use `initial-hydration`, `rejection-rebase`, or `gap-recovery`
|
|
118
|
+
modes with a unique `attemptId`; all require a fresh logical attempt. Structured
|
|
119
|
+
failures, missing state, and missing/invalid revisions fail closed. UID or workspace
|
|
120
|
+
epoch changes clear the visible workspace and require `init()` before another dispatch
|
|
121
|
+
or sync; token refresh for the same UID/epoch only refreshes the subscription fence.
|
|
110
122
|
|
|
111
123
|
The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
|
|
112
124
|
`subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
|
|
@@ -123,21 +135,26 @@ so older clients can continue reading the view until they migrate.
|
|
|
123
135
|
### Operation-result protocol
|
|
124
136
|
|
|
125
137
|
The root package and `@recordtimelabel/core/protocol` export the shared acknowledgement
|
|
126
|
-
normalizers. `normalizeRecordTimeLabelOperationResults(operations, results)`
|
|
127
|
-
|
|
138
|
+
normalizers. `normalizeRecordTimeLabelOperationResults(operations, results)` verifies count,
|
|
139
|
+
request membership, unique IDs, and completeness before returning results in request order.
|
|
140
|
+
The only normalized statuses are `applied`, `noop`,
|
|
128
141
|
`rejected`, and `retryable`; `retryable` is derived from that status, while the legacy `id` and
|
|
129
142
|
`applied` fields remain available alongside `operationId`, `reason`, and `retryAfterMs`.
|
|
130
143
|
|
|
131
|
-
`normalizeRecordTimeLabelEnvelopeResponse(operations, response)`
|
|
132
|
-
`operationResults` array
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
`
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
`normalizeRecordTimeLabelEnvelopeResponse(operations, response)` requires an explicit
|
|
145
|
+
`operationResults` array by default. Malformed, duplicate, missing, unknown, or ambiguous IDs
|
|
146
|
+
throw stable protocol errors so callers can reject the entire acknowledgement before changing
|
|
147
|
+
durable state.
|
|
148
|
+
|
|
149
|
+
Gateway-only compatibility code may opt into successful legacy envelopes with
|
|
150
|
+
`{allowLegacySuccessWithoutResults: true}`. New durable clients never enable that fallback.
|
|
151
|
+
Capabilities `operation-conflict-quarantine`, `strict-operation-results`, and
|
|
152
|
+
`lifecycle-generation-fence` gate the corresponding 0.4.3 behavior. Single
|
|
153
|
+
`record.restore`, `folder.restore`, and `trash.purge` operations require a positive
|
|
154
|
+
`expectedGeneration`; batch lifecycle operations retain their existing contract. The gateway
|
|
155
|
+
validator derives enforcement from `client.capabilities`. The planner is strict by default;
|
|
156
|
+
the time-limited legacy gateway path must explicitly pass `requireLifecycleGeneration: false`
|
|
157
|
+
for clients that did not declare the capability. A supplied but stale generation is always rejected.
|
|
141
158
|
|
|
142
159
|
## Firestore v1 Compatibility
|
|
143
160
|
|
package/package.json
CHANGED
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.3';
|
|
21
35
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
22
36
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
23
37
|
'fifo-retry-fence',
|
|
24
38
|
'baseline-refresh-after-rejection',
|
|
25
|
-
'sync-batch-boundary'
|
|
39
|
+
'sync-batch-boundary',
|
|
40
|
+
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
41
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
42
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
26
43
|
]);
|
|
27
44
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
28
45
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
@@ -57,6 +74,11 @@ const ORDER_OPERATION_TYPES = new Set([
|
|
|
57
74
|
OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
58
75
|
]);
|
|
59
76
|
|
|
77
|
+
const normalizeExpectedGeneration = (value) => {
|
|
78
|
+
const generation = Number(value);
|
|
79
|
+
return Number.isSafeInteger(generation) && generation > 0 ? generation : null;
|
|
80
|
+
};
|
|
81
|
+
|
|
60
82
|
export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
|
|
61
83
|
FULL: 'full',
|
|
62
84
|
MERGE: 'merge'
|
|
@@ -71,7 +93,10 @@ export const FIRESTORE_V2_SETTINGS_DOC_ID = 'main';
|
|
|
71
93
|
|
|
72
94
|
export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
|
|
73
95
|
OWNER_MISMATCH: 'operation_owner_mismatch',
|
|
74
|
-
WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch'
|
|
96
|
+
WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
|
|
97
|
+
ID_CONFLICT: 'operation_id_conflict',
|
|
98
|
+
LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
|
|
99
|
+
LIFECYCLE_CONFLICT: 'lifecycle_conflict'
|
|
75
100
|
});
|
|
76
101
|
|
|
77
102
|
const toArray = (value) => (Array.isArray(value) ? value : []);
|
|
@@ -951,8 +976,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
951
976
|
const trashEntry = nextState.trashEntries[trashEntryId];
|
|
952
977
|
if (!trashEntry || trashEntry.kind !== 'record') return normalized;
|
|
953
978
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
954
|
-
|
|
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
|
}
|
|
@@ -2841,23 +2869,74 @@ const rtlOperationIdentityReason = (operation, workspace) => {
|
|
|
2841
2869
|
if (operationEpoch !== workspaceEpoch) {
|
|
2842
2870
|
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.WORKSPACE_EPOCH_MISMATCH;
|
|
2843
2871
|
}
|
|
2872
|
+
if ([
|
|
2873
|
+
OPERATION_TYPES.RECORD_RESTORE,
|
|
2874
|
+
OPERATION_TYPES.FOLDER_RESTORE,
|
|
2875
|
+
OPERATION_TYPES.TRASH_PURGE
|
|
2876
|
+
].includes(operation?.type) && normalizeExpectedGeneration(operation?.payload?.expectedGeneration) === null) {
|
|
2877
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_GENERATION_REQUIRED;
|
|
2878
|
+
}
|
|
2879
|
+
return null;
|
|
2880
|
+
};
|
|
2881
|
+
|
|
2882
|
+
const rtlOperationWireFingerprint = (operation) => JSON.stringify(
|
|
2883
|
+
canonicalizeFirestoreV2DocumentValue(toRecordTimeLabelWireOperation(operation))
|
|
2884
|
+
);
|
|
2885
|
+
|
|
2886
|
+
const rtlLifecycleOperationReason = (operation, state) => {
|
|
2887
|
+
if (![
|
|
2888
|
+
OPERATION_TYPES.RECORD_RESTORE,
|
|
2889
|
+
OPERATION_TYPES.FOLDER_RESTORE,
|
|
2890
|
+
OPERATION_TYPES.TRASH_PURGE
|
|
2891
|
+
].includes(operation?.type)) return null;
|
|
2892
|
+
const expectedGeneration = normalizeExpectedGeneration(operation?.payload?.expectedGeneration);
|
|
2893
|
+
if (expectedGeneration === null) {
|
|
2894
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_GENERATION_REQUIRED;
|
|
2895
|
+
}
|
|
2896
|
+
const trashEntryId = normalizeId(operation?.payload?.trashEntryId || operation?.payload?.id);
|
|
2897
|
+
const trashEntry = state?.trashEntries?.[trashEntryId];
|
|
2898
|
+
if (trashEntry && Number(trashEntry.lifecycleGeneration) !== expectedGeneration) {
|
|
2899
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.LIFECYCLE_CONFLICT;
|
|
2900
|
+
}
|
|
2844
2901
|
return null;
|
|
2845
2902
|
};
|
|
2846
2903
|
|
|
2847
2904
|
const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now()) => {
|
|
2848
2905
|
const accepted = [];
|
|
2849
2906
|
const rejected = {...(workspace?.rejectedOperations || {})};
|
|
2907
|
+
const knownById = new Map();
|
|
2908
|
+
let lifecycleState = operations === workspace?.pendingOperations
|
|
2909
|
+
? normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {})
|
|
2910
|
+
: rtlDeriveDurableVisibleState(workspace);
|
|
2911
|
+
if (operations !== workspace?.pendingOperations) {
|
|
2912
|
+
toArray(workspace?.pendingOperations).forEach((operation) => {
|
|
2913
|
+
const id = normalizeId(operation?.id);
|
|
2914
|
+
if (id && !knownById.has(id)) knownById.set(id, rtlOperationWireFingerprint(operation));
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2850
2917
|
let rejectedCount = 0;
|
|
2851
2918
|
toArray(operations).forEach((operation, index) => {
|
|
2852
|
-
const
|
|
2919
|
+
const id = normalizeId(operation?.id);
|
|
2920
|
+
const fingerprint = rtlOperationWireFingerprint(operation);
|
|
2921
|
+
const knownFingerprint = id ? knownById.get(id) : null;
|
|
2922
|
+
let reason = rtlOperationIdentityReason(operation, workspace);
|
|
2923
|
+
if (!reason) reason = rtlLifecycleOperationReason(operation, lifecycleState);
|
|
2924
|
+
if (!reason && knownFingerprint) {
|
|
2925
|
+
if (knownFingerprint === fingerprint) return;
|
|
2926
|
+
reason = RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.ID_CONFLICT;
|
|
2927
|
+
}
|
|
2853
2928
|
if (!reason) {
|
|
2854
2929
|
accepted.push(operation);
|
|
2930
|
+
if (id) knownById.set(id, fingerprint);
|
|
2931
|
+
lifecycleState = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
2932
|
+
? lifecycleState
|
|
2933
|
+
: normalizeRecordTimeLabelDomainState(applyRecordTimeLabelOperation(lifecycleState, operation));
|
|
2855
2934
|
return;
|
|
2856
2935
|
}
|
|
2857
2936
|
rejectedCount += 1;
|
|
2858
|
-
const
|
|
2859
|
-
rejected[
|
|
2860
|
-
id,
|
|
2937
|
+
const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
|
|
2938
|
+
rejected[rejectedId] = {
|
|
2939
|
+
id: rejectedId,
|
|
2861
2940
|
operation: clone(operation),
|
|
2862
2941
|
status: 'rejected',
|
|
2863
2942
|
reason,
|
|
@@ -3024,10 +3103,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3024
3103
|
|
|
3025
3104
|
let workspace = rtlEmptyDurableWorkspace();
|
|
3026
3105
|
let initialized = false;
|
|
3106
|
+
let hydrationRequired = true;
|
|
3027
3107
|
let destroyed = false;
|
|
3028
3108
|
let unsubscribeCloud = null;
|
|
3029
3109
|
let unsubscribeSession = null;
|
|
3030
3110
|
let subscriptionContext = null;
|
|
3111
|
+
let bootstrapAttemptSequence = 0;
|
|
3031
3112
|
const listeners = new Set();
|
|
3032
3113
|
let queue = Promise.resolve();
|
|
3033
3114
|
|
|
@@ -3120,6 +3201,31 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3120
3201
|
return {changed, stale: false};
|
|
3121
3202
|
};
|
|
3122
3203
|
|
|
3204
|
+
const bootstrapAttemptOptions = (mode) => ({
|
|
3205
|
+
mode,
|
|
3206
|
+
attemptId: `bootstrap:${mode}:${now()}:${++bootstrapAttemptSequence}:${
|
|
3207
|
+
globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2, 14)
|
|
3208
|
+
}`,
|
|
3209
|
+
requireFresh: true
|
|
3210
|
+
});
|
|
3211
|
+
|
|
3212
|
+
const normalizeBootstrapResponse = (response) => {
|
|
3213
|
+
if (!rtlEnvelopeSuccess(response)) {
|
|
3214
|
+
const error = new Error(response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed');
|
|
3215
|
+
error.code = response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed';
|
|
3216
|
+
throw error;
|
|
3217
|
+
}
|
|
3218
|
+
const remote = rtlEnvelopePayload(response);
|
|
3219
|
+
const revision = Number(remote?.revision);
|
|
3220
|
+
const state = remote?.state ?? remote?.data;
|
|
3221
|
+
if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
|
|
3222
|
+
const error = new Error('recordtimelabel_invalid_bootstrap_response');
|
|
3223
|
+
error.code = 'recordtimelabel_invalid_bootstrap_response';
|
|
3224
|
+
throw error;
|
|
3225
|
+
}
|
|
3226
|
+
return {state, revision, changeCursor: remote.changeCursor ?? null};
|
|
3227
|
+
};
|
|
3228
|
+
|
|
3123
3229
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
3124
3230
|
const source = loaded && typeof loaded === 'object' ? loaded : {};
|
|
3125
3231
|
const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
|
|
@@ -3257,43 +3363,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3257
3363
|
const normalizeOperationResults = (response, sentOperations) => {
|
|
3258
3364
|
let results;
|
|
3259
3365
|
try {
|
|
3260
|
-
|
|
3261
|
-
// public shared contract remains strict about operationResults arrays.
|
|
3262
|
-
const legacyOperationResults = response?.operationResults;
|
|
3263
|
-
const compatibleResponse = rtlDurableIsObject(legacyOperationResults)
|
|
3264
|
-
? {
|
|
3265
|
-
...response,
|
|
3266
|
-
operationResults: Object.entries(legacyOperationResults).map(([id, result]) => ({
|
|
3267
|
-
...(result || {}),
|
|
3268
|
-
id: result?.id || id
|
|
3269
|
-
}))
|
|
3270
|
-
}
|
|
3271
|
-
: (
|
|
3272
|
-
!Object.prototype.hasOwnProperty.call(response || {}, 'operationResults') &&
|
|
3273
|
-
Array.isArray(response?.results)
|
|
3274
|
-
? {...response, operationResults: response.results}
|
|
3275
|
-
: response
|
|
3276
|
-
);
|
|
3277
|
-
results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, compatibleResponse);
|
|
3366
|
+
results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, response);
|
|
3278
3367
|
} catch (error) {
|
|
3279
3368
|
return {error, results: null};
|
|
3280
3369
|
}
|
|
3281
|
-
|
|
3282
|
-
const normalized = [];
|
|
3283
|
-
const seen = new Set();
|
|
3284
|
-
for (let index = 0; index < results.length; index += 1) {
|
|
3285
|
-
const result = results[index] || {};
|
|
3286
|
-
const resultId = rtlResultOperationId(result) || sentOperations[index]?.id;
|
|
3287
|
-
if (!resultId || !byId.has(resultId) || seen.has(resultId)) {
|
|
3288
|
-
return {error: new Error('sync_protocol_invalid_operation_result'), results: null};
|
|
3289
|
-
}
|
|
3290
|
-
seen.add(resultId);
|
|
3291
|
-
normalized.push({...clone(result), id: resultId});
|
|
3292
|
-
}
|
|
3293
|
-
if (seen.size !== sentOperations.length) {
|
|
3294
|
-
return {error: new Error('sync_protocol_operation_result_count_mismatch'), results: null};
|
|
3295
|
-
}
|
|
3296
|
-
return {results: normalized};
|
|
3370
|
+
return {results: results.map((result) => ({...clone(result), id: rtlResultOperationId(result)}))};
|
|
3297
3371
|
};
|
|
3298
3372
|
|
|
3299
3373
|
const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
|
|
@@ -3317,7 +3391,27 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3317
3391
|
const processRemote = async (remoteValue, captured) => {
|
|
3318
3392
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3319
3393
|
const candidate = clone(workspace);
|
|
3320
|
-
const
|
|
3394
|
+
const remote = rtlEnvelopePayload(remoteValue);
|
|
3395
|
+
const remoteRevision = Number(remote?.revision);
|
|
3396
|
+
if (!Number.isFinite(remoteRevision)) {
|
|
3397
|
+
return {success: false, protocolError: true, reason: 'invalid_remote_revision'};
|
|
3398
|
+
}
|
|
3399
|
+
if (remoteRevision <= candidate.remoteBaseline.revision) {
|
|
3400
|
+
return {success: true, ignored: true};
|
|
3401
|
+
}
|
|
3402
|
+
let baselineValue = remoteValue;
|
|
3403
|
+
if (remoteRevision > candidate.remoteBaseline.revision + 1) {
|
|
3404
|
+
if (typeof cloud?.bootstrap !== 'function') {
|
|
3405
|
+
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3406
|
+
}
|
|
3407
|
+
const context = rtlSessionContext(captured, candidate, client);
|
|
3408
|
+
baselineValue = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3409
|
+
context,
|
|
3410
|
+
bootstrapAttemptOptions('gap-recovery')
|
|
3411
|
+
));
|
|
3412
|
+
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3413
|
+
}
|
|
3414
|
+
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
3321
3415
|
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
3322
3416
|
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
3323
3417
|
workspace = candidate;
|
|
@@ -3325,6 +3419,30 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3325
3419
|
return getSnapshot();
|
|
3326
3420
|
};
|
|
3327
3421
|
|
|
3422
|
+
const stopCloudSubscription = () => {
|
|
3423
|
+
if (typeof unsubscribeCloud === 'function') {
|
|
3424
|
+
try { unsubscribeCloud(); } catch (error) {
|
|
3425
|
+
logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
unsubscribeCloud = null;
|
|
3429
|
+
subscriptionContext = null;
|
|
3430
|
+
};
|
|
3431
|
+
|
|
3432
|
+
const startCloudSubscription = (captured, candidate) => {
|
|
3433
|
+
stopCloudSubscription();
|
|
3434
|
+
if (typeof cloud?.subscribe !== 'function' || !initialized || hydrationRequired) return;
|
|
3435
|
+
const context = rtlSessionContext(captured, candidate, client);
|
|
3436
|
+
subscriptionContext = context;
|
|
3437
|
+
unsubscribeCloud = cloud.subscribe((remoteValue) => {
|
|
3438
|
+
if (destroyed) return;
|
|
3439
|
+
return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
|
|
3440
|
+
logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
|
|
3441
|
+
return {success: false, error};
|
|
3442
|
+
});
|
|
3443
|
+
}, context);
|
|
3444
|
+
};
|
|
3445
|
+
|
|
3328
3446
|
const initialize = async () => {
|
|
3329
3447
|
const captured = capture();
|
|
3330
3448
|
const loaded = await storage.load();
|
|
@@ -3342,7 +3460,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3342
3460
|
if (captured?.uid && typeof cloud?.bootstrap === 'function') {
|
|
3343
3461
|
let bootstrap;
|
|
3344
3462
|
try {
|
|
3345
|
-
bootstrap = await cloud.bootstrap(
|
|
3463
|
+
bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3464
|
+
context,
|
|
3465
|
+
bootstrapAttemptOptions('initial-hydration')
|
|
3466
|
+
));
|
|
3346
3467
|
} catch (error) {
|
|
3347
3468
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3348
3469
|
throw error;
|
|
@@ -3356,25 +3477,41 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3356
3477
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
3357
3478
|
workspace = candidate;
|
|
3358
3479
|
initialized = true;
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3480
|
+
hydrationRequired = false;
|
|
3481
|
+
startCloudSubscription(captured, candidate);
|
|
3482
|
+
if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
|
|
3483
|
+
unsubscribeSession = session.subscribe(() => {
|
|
3363
3484
|
if (destroyed) return;
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3485
|
+
enqueue(async () => {
|
|
3486
|
+
const current = capture();
|
|
3487
|
+
const sameIdentity = current.uid === workspace.ownerUid &&
|
|
3488
|
+
Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
|
|
3489
|
+
if (sameIdentity) {
|
|
3490
|
+
startCloudSubscription(current, workspace);
|
|
3491
|
+
return {success: true, tokenRefreshed: true};
|
|
3492
|
+
}
|
|
3493
|
+
stopCloudSubscription();
|
|
3494
|
+
workspace = rtlEmptyDurableWorkspace({
|
|
3495
|
+
ownerUid: current.uid,
|
|
3496
|
+
workspaceEpoch: current.workspaceEpoch
|
|
3497
|
+
});
|
|
3498
|
+
initialized = false;
|
|
3499
|
+
hydrationRequired = true;
|
|
3500
|
+
notify({type: 'session_changed'});
|
|
3501
|
+
return {success: true, sessionChanged: true};
|
|
3502
|
+
}).catch((error) => {
|
|
3503
|
+
logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
|
|
3367
3504
|
});
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
|
|
3371
|
-
unsubscribeSession = session.subscribe(() => {});
|
|
3505
|
+
});
|
|
3372
3506
|
}
|
|
3373
3507
|
notify({type: 'initialized'});
|
|
3374
3508
|
return getSnapshot();
|
|
3375
3509
|
};
|
|
3376
3510
|
|
|
3377
3511
|
const dispatchInternal = async (operations) => {
|
|
3512
|
+
if (!initialized || hydrationRequired) {
|
|
3513
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
3514
|
+
}
|
|
3378
3515
|
const captured = capture();
|
|
3379
3516
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3380
3517
|
const input = Array.isArray(operations)
|
|
@@ -3447,6 +3584,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3447
3584
|
};
|
|
3448
3585
|
|
|
3449
3586
|
const syncInternal = async (reason) => {
|
|
3587
|
+
if (!initialized || hydrationRequired) {
|
|
3588
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
3589
|
+
}
|
|
3450
3590
|
const captured = capture();
|
|
3451
3591
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3452
3592
|
const timestamp = now();
|
|
@@ -3473,7 +3613,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3473
3613
|
// is a head-of-line barrier; dependent operations behind it must not
|
|
3474
3614
|
// overtake it merely because they do not have their own deadline yet.
|
|
3475
3615
|
const ready = [];
|
|
3616
|
+
const firstBatchId = workspace.pendingOperations[0]?.syncBatchId || null;
|
|
3476
3617
|
for (const operation of workspace.pendingOperations) {
|
|
3618
|
+
if (ready.length >= RTL_MAX_OPERATIONS_PER_REQUEST) break;
|
|
3619
|
+
if (ready.length > 0 && operation.syncBatchId !== firstBatchId) break;
|
|
3477
3620
|
if (Number.isFinite(Number(operation.nextRetryAt)) &&
|
|
3478
3621
|
Number(operation.nextRetryAt) > timestamp) break;
|
|
3479
3622
|
ready.push(operation);
|
|
@@ -3500,15 +3643,22 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3500
3643
|
return {success: false, reason: 'missing_cloud_apply_operations'};
|
|
3501
3644
|
}
|
|
3502
3645
|
const context = rtlSessionContext(captured, workspace, client);
|
|
3646
|
+
const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
|
|
3647
|
+
const requestId = buildRecordTimeLabelRequestId(
|
|
3648
|
+
`${typeof client === 'string' ? client : client?.app || client?.id || 'client'}:${firstBatchId || 'batch'}`,
|
|
3649
|
+
wireOperations
|
|
3650
|
+
);
|
|
3503
3651
|
let response;
|
|
3504
3652
|
try {
|
|
3505
3653
|
response = await cloud.applyOperations({
|
|
3506
3654
|
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
3655
|
+
requestId,
|
|
3656
|
+
syncBatchId: firstBatchId,
|
|
3507
3657
|
ownerUid: workspace.ownerUid,
|
|
3508
3658
|
workspaceEpoch: workspace.workspaceEpoch,
|
|
3509
3659
|
client: clone(client),
|
|
3510
3660
|
reason: reason ?? null,
|
|
3511
|
-
operations: clone(
|
|
3661
|
+
operations: clone(wireOperations)
|
|
3512
3662
|
}, context);
|
|
3513
3663
|
} catch (error) {
|
|
3514
3664
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
@@ -3561,11 +3711,22 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3561
3711
|
};
|
|
3562
3712
|
}
|
|
3563
3713
|
|
|
3564
|
-
const parsed = normalizeOperationResults(response,
|
|
3714
|
+
const parsed = normalizeOperationResults(response, wireOperations);
|
|
3565
3715
|
if (parsed.error) {
|
|
3566
3716
|
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
|
|
3567
3717
|
return {success: false, error: parsed.error, protocolError: true};
|
|
3568
3718
|
}
|
|
3719
|
+
let sawRetryable = false;
|
|
3720
|
+
for (const result of parsed.results) {
|
|
3721
|
+
if (result.status === 'retryable') {
|
|
3722
|
+
sawRetryable = true;
|
|
3723
|
+
} else if (sawRetryable && (result.status === 'applied' || result.status === 'noop')) {
|
|
3724
|
+
const error = new Error('sync_protocol_fifo_retry_barrier_violation');
|
|
3725
|
+
error.code = 'sync_protocol_fifo_retry_barrier_violation';
|
|
3726
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
3727
|
+
return {success: false, error, protocolError: true};
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3569
3730
|
const candidate = clone(workspace);
|
|
3570
3731
|
const resultById = new Map(parsed.results.map((result) => [result.id, result]));
|
|
3571
3732
|
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
@@ -3623,7 +3784,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3623
3784
|
if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
|
|
3624
3785
|
let freshBaseline;
|
|
3625
3786
|
try {
|
|
3626
|
-
freshBaseline = await cloud.bootstrap(
|
|
3787
|
+
freshBaseline = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3788
|
+
context,
|
|
3789
|
+
bootstrapAttemptOptions('rejection-rebase')
|
|
3790
|
+
));
|
|
3627
3791
|
} catch (error) {
|
|
3628
3792
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3629
3793
|
throw error;
|
|
@@ -3652,7 +3816,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3652
3816
|
const engine = {
|
|
3653
3817
|
init() {
|
|
3654
3818
|
return enqueue(async () => {
|
|
3655
|
-
if (initialized) return getSnapshot();
|
|
3819
|
+
if (initialized && !hydrationRequired) return getSnapshot();
|
|
3656
3820
|
return initialize();
|
|
3657
3821
|
});
|
|
3658
3822
|
},
|
|
@@ -3676,15 +3840,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3676
3840
|
destroy() {
|
|
3677
3841
|
destroyed = true;
|
|
3678
3842
|
initialized = false;
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
}
|
|
3843
|
+
hydrationRequired = true;
|
|
3844
|
+
stopCloudSubscription();
|
|
3682
3845
|
if (typeof unsubscribeSession === 'function') {
|
|
3683
3846
|
try { unsubscribeSession(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable session unsubscribe failed', error); }
|
|
3684
3847
|
}
|
|
3685
|
-
unsubscribeCloud = null;
|
|
3686
3848
|
unsubscribeSession = null;
|
|
3687
|
-
subscriptionContext = null;
|
|
3688
3849
|
listeners.clear();
|
|
3689
3850
|
}
|
|
3690
3851
|
};
|
|
@@ -3919,7 +4080,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
|
|
|
3919
4080
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
3920
4081
|
}
|
|
3921
4082
|
};
|
|
3922
|
-
const rtlValidateOperationPayload = (operation, index, errors) => {
|
|
4083
|
+
const rtlValidateOperationPayload = (operation, index, errors, options = {}) => {
|
|
3923
4084
|
const payload = operation.payload || {};
|
|
3924
4085
|
const recordId = rtlOperationRecordId(operation);
|
|
3925
4086
|
const folderId = rtlOperationFolderId(operation);
|
|
@@ -3944,6 +4105,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
|
|
|
3944
4105
|
break;
|
|
3945
4106
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
3946
4107
|
if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
|
|
4108
|
+
if (options.requireLifecycleGeneration === true &&
|
|
4109
|
+
normalizeExpectedGeneration(payload.expectedGeneration) === null) {
|
|
4110
|
+
errors.push(`operation_${index}_invalid_expected_generation`);
|
|
4111
|
+
}
|
|
3947
4112
|
break;
|
|
3948
4113
|
case OPERATION_TYPES.RECORD_REORDER:
|
|
3949
4114
|
if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
|
|
@@ -3964,6 +4129,10 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
|
|
|
3964
4129
|
case OPERATION_TYPES.FOLDER_RESTORE:
|
|
3965
4130
|
case OPERATION_TYPES.TRASH_PURGE:
|
|
3966
4131
|
if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
|
|
4132
|
+
if (options.requireLifecycleGeneration === true &&
|
|
4133
|
+
normalizeExpectedGeneration(payload.expectedGeneration) === null) {
|
|
4134
|
+
errors.push(`operation_${index}_invalid_expected_generation`);
|
|
4135
|
+
}
|
|
3967
4136
|
break;
|
|
3968
4137
|
case OPERATION_TYPES.TRASH_RESTORE_BATCH:
|
|
3969
4138
|
case OPERATION_TYPES.TRASH_PURGE_BATCH:
|
|
@@ -3999,8 +4168,12 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
|
|
|
3999
4168
|
/**
|
|
4000
4169
|
* Validates one complete v2 gateway request without reading Firebase state.
|
|
4001
4170
|
*/
|
|
4002
|
-
export const validateRecordTimeLabelOperationBatch = (body = {}) => {
|
|
4171
|
+
export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
|
|
4003
4172
|
const errors = [];
|
|
4173
|
+
const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
|
|
4174
|
+
toArray(body?.client?.capabilities).includes(
|
|
4175
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
4176
|
+
);
|
|
4004
4177
|
let requestBytes = 0;
|
|
4005
4178
|
try {
|
|
4006
4179
|
requestBytes = rtlByteLength(JSON.stringify(body));
|
|
@@ -4047,7 +4220,7 @@ export const validateRecordTimeLabelOperationBatch = (body = {}) => {
|
|
|
4047
4220
|
if (rtlHasInvalidPayloadValue(operation.payload) || operationBytes > 128 * 1024) {
|
|
4048
4221
|
errors.push(`operation_${index}_payload_too_large`);
|
|
4049
4222
|
}
|
|
4050
|
-
rtlValidateOperationPayload(operation, index, errors);
|
|
4223
|
+
rtlValidateOperationPayload(operation, index, errors, {requireLifecycleGeneration});
|
|
4051
4224
|
}
|
|
4052
4225
|
if (!toFiniteTimestamp(operation?.createdAt)) {
|
|
4053
4226
|
errors.push(`operation_${index}_invalid_created_at`);
|
|
@@ -4225,7 +4398,8 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
4225
4398
|
documents,
|
|
4226
4399
|
operations,
|
|
4227
4400
|
localState = {},
|
|
4228
|
-
now = Date.now()
|
|
4401
|
+
now = Date.now(),
|
|
4402
|
+
requireLifecycleGeneration = true
|
|
4229
4403
|
} = {}) => {
|
|
4230
4404
|
const previousDocuments = rtlCloneDocuments(documents);
|
|
4231
4405
|
const nextDocuments = rtlCloneDocuments(documents);
|
|
@@ -4381,6 +4555,15 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
4381
4555
|
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
4382
4556
|
const trashEntry = nextDocuments.trash[trashEntryId];
|
|
4383
4557
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
4558
|
+
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
4559
|
+
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
4560
|
+
reason = 'lifecycle_generation_required';
|
|
4561
|
+
break;
|
|
4562
|
+
}
|
|
4563
|
+
if (expectedGeneration !== null && expectedGeneration !== Number(trashEntry.lifecycleGeneration)) {
|
|
4564
|
+
reason = 'lifecycle_conflict';
|
|
4565
|
+
break;
|
|
4566
|
+
}
|
|
4384
4567
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationNow) {
|
|
4385
4568
|
reason = 'trash_entry_expired';
|
|
4386
4569
|
break;
|
|
@@ -4391,7 +4574,16 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
4391
4574
|
break;
|
|
4392
4575
|
}
|
|
4393
4576
|
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
|
|
4394
|
-
|
|
4577
|
+
// Explicit legacy compatibility is planner-local. It does not change the
|
|
4578
|
+
// original operation, its wire bytes, or the strict durable reducer contract.
|
|
4579
|
+
const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
|
|
4580
|
+
? {...operation, payload: {...payload, expectedGeneration: trashEntry.lifecycleGeneration}}
|
|
4581
|
+
: operation;
|
|
4582
|
+
applied = rtlApplyOperationToPartialDocuments({
|
|
4583
|
+
...nextDocuments,
|
|
4584
|
+
operation: reducerOperation,
|
|
4585
|
+
now: operationNow
|
|
4586
|
+
});
|
|
4395
4587
|
const restoredRecord = applied.documents.records[restoredRecordId];
|
|
4396
4588
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
4397
4589
|
const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
|
|
@@ -4415,8 +4607,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
4415
4607
|
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
4416
4608
|
const trashEntry = nextDocuments.trash[trashEntryId];
|
|
4417
4609
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
4418
|
-
|
|
4419
|
-
|
|
4610
|
+
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
4611
|
+
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
4612
|
+
reason = 'lifecycle_generation_required';
|
|
4613
|
+
break;
|
|
4614
|
+
}
|
|
4615
|
+
if (expectedGeneration !== null && expectedGeneration !== Number(trashEntry.lifecycleGeneration)) {
|
|
4420
4616
|
reason = 'lifecycle_conflict';
|
|
4421
4617
|
break;
|
|
4422
4618
|
}
|
|
@@ -4676,7 +4872,11 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
4676
4872
|
export default {
|
|
4677
4873
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
4678
4874
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
4875
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
4876
|
+
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
4877
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
4679
4878
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
4879
|
+
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
4680
4880
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
4681
4881
|
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
4682
4882
|
RTL_MAX_REQUEST_BYTES,
|
|
@@ -4718,6 +4918,9 @@ export default {
|
|
|
4718
4918
|
estimateFirestoreV2WriteUnits,
|
|
4719
4919
|
normalizeRecordTimeLabelOperationResults,
|
|
4720
4920
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
4921
|
+
toRecordTimeLabelWireOperation,
|
|
4922
|
+
buildRecordTimeLabelRequestId,
|
|
4923
|
+
createRecordTimeLabelTransportFailureResults,
|
|
4721
4924
|
buildOperationsFromSnapshotDiff,
|
|
4722
4925
|
flushPendingOperations,
|
|
4723
4926
|
mergeRemoteStateIntoLocal,
|
package/src/protocol.js
CHANGED
|
@@ -11,6 +11,18 @@ export const RECORD_TIMELABEL_OPERATION_RESULT_STATUSES = Object.freeze({
|
|
|
11
11
|
RETRYABLE: 'retryable'
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
export const RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE =
|
|
15
|
+
'operation-conflict-quarantine';
|
|
16
|
+
export const RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS =
|
|
17
|
+
'strict-operation-results';
|
|
18
|
+
export const RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE =
|
|
19
|
+
'lifecycle-generation-fence';
|
|
20
|
+
export const RECORD_TIMELABEL_PROTOCOL_CAPABILITIES = Object.freeze([
|
|
21
|
+
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
22
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
23
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
24
|
+
]);
|
|
25
|
+
|
|
14
26
|
const ALLOWED_OPERATION_RESULT_STATUSES = new Set(
|
|
15
27
|
Object.values(RECORD_TIMELABEL_OPERATION_RESULT_STATUSES)
|
|
16
28
|
);
|
|
@@ -27,6 +39,50 @@ const asObject = (value) => (
|
|
|
27
39
|
value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
28
40
|
);
|
|
29
41
|
|
|
42
|
+
const createProtocolError = (code, details = {}) => {
|
|
43
|
+
const error = new Error(code);
|
|
44
|
+
error.code = code;
|
|
45
|
+
Object.assign(error, details);
|
|
46
|
+
return error;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const normalizeId = (value) => (
|
|
50
|
+
typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const fnv1a = (value) => {
|
|
54
|
+
let hash = 2166136261;
|
|
55
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
56
|
+
hash ^= value.charCodeAt(index);
|
|
57
|
+
hash = Math.imul(hash, 16777619);
|
|
58
|
+
}
|
|
59
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const toRecordTimeLabelWireOperation = (operation = {}) => {
|
|
63
|
+
const source = asObject(operation);
|
|
64
|
+
return {
|
|
65
|
+
id: source.id ?? null,
|
|
66
|
+
type: source.type ?? null,
|
|
67
|
+
payload: asObject(source.payload),
|
|
68
|
+
clientId: source.clientId ?? null,
|
|
69
|
+
createdAt: source.createdAt ?? null
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const buildRecordTimeLabelRequestId = (namespace, operations = []) => {
|
|
74
|
+
const safeNamespace = normalizeId(namespace)
|
|
75
|
+
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
76
|
+
.slice(0, 64) || 'recordtimelabel';
|
|
77
|
+
const orderedIds = Array.isArray(operations)
|
|
78
|
+
? operations.map((operation) => normalizeId(asObject(operation).id))
|
|
79
|
+
: [];
|
|
80
|
+
const firstOperationId = (orderedIds[0] || 'empty')
|
|
81
|
+
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
82
|
+
.slice(0, 72) || 'empty';
|
|
83
|
+
return `rtl:${safeNamespace}:${firstOperationId}:${fnv1a(orderedIds.join('\u001f'))}`.slice(0, 160);
|
|
84
|
+
};
|
|
85
|
+
|
|
30
86
|
const normalizeStatus = (result) => {
|
|
31
87
|
if (ALLOWED_OPERATION_RESULT_STATUSES.has(result.status)) {
|
|
32
88
|
return result.status;
|
|
@@ -59,24 +115,46 @@ const isSuccessfulEnvelope = (response) => {
|
|
|
59
115
|
* Unknown result properties are retained for forward compatibility, while
|
|
60
116
|
* all contract fields below are replaced with their normalized values.
|
|
61
117
|
*/
|
|
62
|
-
export const normalizeRecordTimeLabelOperationResults = (operations, results) => {
|
|
118
|
+
export const normalizeRecordTimeLabelOperationResults = (operations, results, options = {}) => {
|
|
63
119
|
if (!Array.isArray(operations) || !Array.isArray(results) || operations.length !== results.length) {
|
|
64
120
|
throw createCountMismatchError(operations, results);
|
|
65
121
|
}
|
|
122
|
+
const strict = options.strict !== false;
|
|
123
|
+
const operationIds = operations.map((operation) => normalizeId(asObject(operation).id));
|
|
124
|
+
if (strict && (operationIds.some((id) => !id) || new Set(operationIds).size !== operationIds.length)) {
|
|
125
|
+
throw createProtocolError('operation_request_invalid_ids');
|
|
126
|
+
}
|
|
66
127
|
|
|
67
|
-
|
|
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
|
};
|