@recordtimelabel/core 0.6.2 → 0.6.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 +18 -10
- package/package.json +1 -1
- package/src/changefeed.js +67 -8
- package/src/firestore-v2.js +11 -0
- package/src/index.js +1562 -294
- package/src/protocol.js +429 -104
package/src/index.js
CHANGED
|
@@ -1,33 +1,47 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
3
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
3
4
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
4
6
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5
7
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
8
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
9
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
6
10
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
7
11
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
8
12
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
9
13
|
buildRecordTimeLabelRequestId,
|
|
10
14
|
createRecordTimeLabelTransportFailureResults,
|
|
15
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
11
16
|
normalizeRecordTimeLabelCloudFailure,
|
|
12
17
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
13
18
|
normalizeRecordTimeLabelOperationResults,
|
|
19
|
+
normalizeRecordTimeLabelImmutableId,
|
|
20
|
+
normalizeRecordTimeLabelPlannerId,
|
|
14
21
|
toRecordTimeLabelCloudFailureError,
|
|
15
22
|
toRecordTimeLabelWireOperation
|
|
16
23
|
} from './protocol.js';
|
|
17
24
|
|
|
18
25
|
export {
|
|
19
26
|
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
27
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
20
28
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
29
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
21
30
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
22
31
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
32
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
33
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
23
34
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
24
35
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
25
36
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
26
37
|
buildRecordTimeLabelRequestId,
|
|
27
38
|
createRecordTimeLabelTransportFailureResults,
|
|
39
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
28
40
|
normalizeRecordTimeLabelCloudFailure,
|
|
29
41
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
30
42
|
normalizeRecordTimeLabelOperationResults,
|
|
43
|
+
normalizeRecordTimeLabelImmutableId,
|
|
44
|
+
normalizeRecordTimeLabelPlannerId,
|
|
31
45
|
toRecordTimeLabelCloudFailureError,
|
|
32
46
|
toRecordTimeLabelWireOperation
|
|
33
47
|
} from './protocol.js';
|
|
@@ -127,7 +141,7 @@ export {
|
|
|
127
141
|
selectBestMatchingTwitchVod
|
|
128
142
|
};
|
|
129
143
|
|
|
130
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
144
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.4';
|
|
131
145
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
132
146
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
133
147
|
'fifo-retry-fence',
|
|
@@ -137,7 +151,10 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
137
151
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
138
152
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
139
153
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
140
|
-
|
|
154
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
155
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
156
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
157
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS
|
|
141
158
|
]);
|
|
142
159
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
143
160
|
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
@@ -145,6 +162,11 @@ export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
|
|
|
145
162
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
146
163
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
147
164
|
export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
165
|
+
export const RECORD_TIMELABEL_PLANNER_MODES = Object.freeze({
|
|
166
|
+
DETERMINISTIC: 'deterministic',
|
|
167
|
+
LEGACY_CONTEXT: 'legacy-context'
|
|
168
|
+
});
|
|
169
|
+
export const RECORD_TIMELABEL_SAFE_ID_MAX_BYTES = 512;
|
|
148
170
|
|
|
149
171
|
export const OPERATION_TYPES = Object.freeze({
|
|
150
172
|
RECORD_CREATE: 'record.create',
|
|
@@ -195,6 +217,7 @@ export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
|
|
|
195
217
|
OWNER_MISMATCH: 'operation_owner_mismatch',
|
|
196
218
|
WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
|
|
197
219
|
ID_CONFLICT: 'operation_id_conflict',
|
|
220
|
+
INVALID_DOCUMENT_ID: 'invalid_document_id',
|
|
198
221
|
LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
|
|
199
222
|
LIFECYCLE_CONFLICT: 'lifecycle_conflict'
|
|
200
223
|
});
|
|
@@ -211,7 +234,20 @@ const clone = (value, seen = new WeakMap()) => {
|
|
|
211
234
|
const copy = Array.isArray(value) ? [] : {};
|
|
212
235
|
seen.set(value, copy);
|
|
213
236
|
Reflect.ownKeys(value).forEach((key) => {
|
|
214
|
-
|
|
237
|
+
// Assignment to the magic `__proto__` key invokes the legacy setter on a
|
|
238
|
+
// normal object. Define the data property explicitly so cloning untrusted
|
|
239
|
+
// document maps cannot alter the clone's prototype (or any global
|
|
240
|
+
// prototype through a later merge).
|
|
241
|
+
if (Array.isArray(copy) && key === 'length') {
|
|
242
|
+
copy.length = value.length;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
Object.defineProperty(copy, key, {
|
|
246
|
+
value: clone(value[key], seen),
|
|
247
|
+
enumerable: true,
|
|
248
|
+
configurable: true,
|
|
249
|
+
writable: true
|
|
250
|
+
});
|
|
215
251
|
});
|
|
216
252
|
return copy;
|
|
217
253
|
};
|
|
@@ -284,9 +320,21 @@ const normalizeId = (value) => {
|
|
|
284
320
|
return String(value).trim();
|
|
285
321
|
};
|
|
286
322
|
|
|
323
|
+
const RTL_RESERVED_DOCUMENT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
324
|
+
const RTL_SAFE_DOCUMENT_ID_MAX_BYTES = 512;
|
|
325
|
+
const rtlIsSafeDocumentId = (value) => {
|
|
326
|
+
// Document identity is a wire-level string. Never coerce numbers (or trim
|
|
327
|
+
// any other value) before applying the shared immutable-ID contract.
|
|
328
|
+
if (typeof value !== 'string') return false;
|
|
329
|
+
const normalized = normalizeRecordTimeLabelImmutableId(value);
|
|
330
|
+
return normalized !== null && !RTL_RESERVED_DOCUMENT_KEYS.has(normalized);
|
|
331
|
+
};
|
|
332
|
+
|
|
287
333
|
const safeFolderId = (folderId) => {
|
|
288
334
|
const normalized = normalizeId(folderId);
|
|
289
|
-
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized))
|
|
335
|
+
if (!normalized || !rtlIsSafeDocumentId(normalized) || VIRTUAL_FOLDER_IDS.has(normalized)) {
|
|
336
|
+
return DEFAULT_FOLDER_ID;
|
|
337
|
+
}
|
|
290
338
|
return normalized;
|
|
291
339
|
};
|
|
292
340
|
|
|
@@ -316,7 +364,7 @@ const normalizeTombstones = (value) => {
|
|
|
316
364
|
|
|
317
365
|
return entries.reduce((result, [rawId, rawTombstone]) => {
|
|
318
366
|
const id = normalizeId(rawId);
|
|
319
|
-
if (!id) return result;
|
|
367
|
+
if (!id || !rtlIsSafeDocumentId(id)) return result;
|
|
320
368
|
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
321
369
|
? { ...rawTombstone }
|
|
322
370
|
: {};
|
|
@@ -341,7 +389,8 @@ const normalizeTrashEntries = (value) => {
|
|
|
341
389
|
const id = normalizeId(rawId || rawEntry.id);
|
|
342
390
|
const kind = normalizeId(rawEntry.kind);
|
|
343
391
|
const entityId = normalizeId(rawEntry.entityId);
|
|
344
|
-
if (!id || !entityId || !
|
|
392
|
+
if (!id || !entityId || !rtlIsSafeDocumentId(id) || !rtlIsSafeDocumentId(entityId) ||
|
|
393
|
+
!new Set(['record', 'folder']).has(kind)) return result;
|
|
345
394
|
const deletedAt = toFiniteTimestamp(rawEntry.deletedAt);
|
|
346
395
|
const purgeAt = toFiniteTimestamp(rawEntry.purgeAt) ||
|
|
347
396
|
((deletedAt || Date.now()) + RTL_TRASH_RETENTION_MS);
|
|
@@ -366,7 +415,7 @@ export const getActiveTrashEntries = (value, now = Date.now()) => Object.values(
|
|
|
366
415
|
const mergeTombstones = (left = {}, right = {}) => {
|
|
367
416
|
const merged = { ...left };
|
|
368
417
|
Object.entries(right).forEach(([id, tombstone]) => {
|
|
369
|
-
const current = merged[id];
|
|
418
|
+
const current = rtlOwn(merged, id) ? merged[id] : null;
|
|
370
419
|
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
371
420
|
const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
|
|
372
421
|
if (!current || nextGeneration > currentGeneration ||
|
|
@@ -381,7 +430,7 @@ const mergeTombstones = (left = {}, right = {}) => {
|
|
|
381
430
|
const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
|
|
382
431
|
const merged = normalizeTrashEntries(remoteEntries);
|
|
383
432
|
Object.entries(normalizeTrashEntries(localEntries)).forEach(([id, entry]) => {
|
|
384
|
-
const current = merged[id];
|
|
433
|
+
const current = rtlOwn(merged, id) ? merged[id] : null;
|
|
385
434
|
const currentVersion = Number(current?.lifecycleGeneration || 0);
|
|
386
435
|
const nextVersion = Number(entry.lifecycleGeneration || 0);
|
|
387
436
|
if (!current || nextVersion > currentVersion ||
|
|
@@ -517,10 +566,37 @@ export const mergeRecords = (left, right) => {
|
|
|
517
566
|
}
|
|
518
567
|
});
|
|
519
568
|
|
|
520
|
-
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
521
569
|
return merged;
|
|
522
570
|
};
|
|
523
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Apply a user-authored record patch without using enrichment/LWW merge
|
|
574
|
+
* semantics. The latter intentionally keeps rich fields from either side,
|
|
575
|
+
* which is correct for remote snapshot reconciliation but would make an
|
|
576
|
+
* explicit `{hasVod: false}` or `{title: ''}` impossible to persist. An
|
|
577
|
+
* explicit patch is presence-based: omitted keys are retained, while every
|
|
578
|
+
* own key (including `undefined`, `null`, `false`, and the empty string) is
|
|
579
|
+
* authoritative for this operation.
|
|
580
|
+
*/
|
|
581
|
+
export const applyExplicitRecordUpdate = (record = {}, patch = {}, operationTime = Date.now()) => {
|
|
582
|
+
const current = record && typeof record === 'object' ? record : {};
|
|
583
|
+
const source = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
584
|
+
const next = {...current};
|
|
585
|
+
Object.keys(source).forEach((key) => {
|
|
586
|
+
if (RTL_RESERVED_DOCUMENT_KEYS.has(key)) return;
|
|
587
|
+
Object.defineProperty(next, key, {
|
|
588
|
+
value: source[key],
|
|
589
|
+
enumerable: true,
|
|
590
|
+
configurable: true,
|
|
591
|
+
writable: true
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
if (!Object.prototype.hasOwnProperty.call(source, 'updatedAt')) {
|
|
595
|
+
next.updatedAt = operationTime;
|
|
596
|
+
}
|
|
597
|
+
return next;
|
|
598
|
+
};
|
|
599
|
+
|
|
524
600
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
525
601
|
const source = records && typeof records === 'object' ? records : {};
|
|
526
602
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
@@ -533,7 +609,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
533
609
|
safeList.forEach((record, index) => {
|
|
534
610
|
if (!record || typeof record !== 'object') return;
|
|
535
611
|
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
536
|
-
if (!id) return;
|
|
612
|
+
if (!id || !rtlIsSafeDocumentId(id)) return;
|
|
537
613
|
|
|
538
614
|
const targetFolderId = resolveRecordFolderId(folderId, record);
|
|
539
615
|
|
|
@@ -549,7 +625,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
549
625
|
const folderTombstone = folderTombstones[targetFolderId];
|
|
550
626
|
if (folderTombstone) return;
|
|
551
627
|
|
|
552
|
-
if (!normalized
|
|
628
|
+
if (!rtlOwn(normalized, targetFolderId)) normalized[targetFolderId] = [];
|
|
553
629
|
|
|
554
630
|
const existing = placements.get(id);
|
|
555
631
|
if (!existing) {
|
|
@@ -598,7 +674,7 @@ const ensureFolders = (folders = []) => {
|
|
|
598
674
|
toArray(folders).forEach((folder) => {
|
|
599
675
|
if (!folder || typeof folder !== 'object') return;
|
|
600
676
|
const id = normalizeId(folder.id);
|
|
601
|
-
if (!id || seen.has(id)) return;
|
|
677
|
+
if (!id || !rtlIsSafeDocumentId(id) || seen.has(id)) return;
|
|
602
678
|
seen.add(id);
|
|
603
679
|
nextFolders.push({ ...folder, id });
|
|
604
680
|
});
|
|
@@ -625,10 +701,10 @@ const ensureFolders = (folders = []) => {
|
|
|
625
701
|
const ensureRecordFolders = (records, folders) => {
|
|
626
702
|
const nextRecords = { ...records };
|
|
627
703
|
folders.forEach((folder) => {
|
|
628
|
-
if (!nextRecords
|
|
704
|
+
if (!rtlOwn(nextRecords, folder.id)) nextRecords[folder.id] = [];
|
|
629
705
|
});
|
|
630
|
-
if (!nextRecords
|
|
631
|
-
if (!nextRecords
|
|
706
|
+
if (!rtlOwn(nextRecords, 'all')) nextRecords.all = [];
|
|
707
|
+
if (!rtlOwn(nextRecords, DEFAULT_FOLDER_ID)) nextRecords[DEFAULT_FOLDER_ID] = [];
|
|
632
708
|
nextRecords.all = [];
|
|
633
709
|
return nextRecords;
|
|
634
710
|
};
|
|
@@ -924,8 +1000,12 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
924
1000
|
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
925
1001
|
if (!record) return normalized;
|
|
926
1002
|
const recordId = normalizeId(record.id || payload.recordId);
|
|
927
|
-
if (!recordId) return normalized;
|
|
928
|
-
const
|
|
1003
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1004
|
+
const rawFolderId = normalizeId(payload.folderId || record.folderId);
|
|
1005
|
+
if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
|
|
1006
|
+
const tombstone = rtlOwn(nextState.deletedRecordTombstones, recordId)
|
|
1007
|
+
? nextState.deletedRecordTombstones[recordId]
|
|
1008
|
+
: null;
|
|
929
1009
|
if (tombstone) return normalized;
|
|
930
1010
|
|
|
931
1011
|
const folderId = safeFolderId(payload.folderId || record.folderId);
|
|
@@ -946,24 +1026,25 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
946
1026
|
|
|
947
1027
|
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
948
1028
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
949
|
-
if (!recordId) return normalized;
|
|
1029
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
950
1030
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
951
1031
|
if (!entry) return normalized;
|
|
952
1032
|
const patch = payload.patch || payload.record || {};
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
});
|
|
1033
|
+
if (Object.keys(patch).some((key) => RTL_RESERVED_DOCUMENT_KEYS.has(key))) {
|
|
1034
|
+
return normalized;
|
|
1035
|
+
}
|
|
1036
|
+
const updatedRecord = applyExplicitRecordUpdate(entry.record, patch, operationTime);
|
|
1037
|
+
updatedRecord.id = recordId;
|
|
959
1038
|
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
960
1039
|
break;
|
|
961
1040
|
}
|
|
962
1041
|
|
|
963
1042
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
964
1043
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
965
|
-
const
|
|
966
|
-
if (!recordId)
|
|
1044
|
+
const rawTargetFolderId = normalizeId(payload.targetFolderId || payload.folderId);
|
|
1045
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId) ||
|
|
1046
|
+
(rawTargetFolderId && !rtlIsSafeDocumentId(rawTargetFolderId))) return normalized;
|
|
1047
|
+
const targetFolderId = safeFolderId(rawTargetFolderId);
|
|
967
1048
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
968
1049
|
if (!entry) return normalized;
|
|
969
1050
|
const movedRecord = {
|
|
@@ -981,7 +1062,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
981
1062
|
}
|
|
982
1063
|
|
|
983
1064
|
case OPERATION_TYPES.RECORD_REORDER: {
|
|
984
|
-
const
|
|
1065
|
+
const rawFolderId = normalizeId(payload.folderId);
|
|
1066
|
+
if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
|
|
1067
|
+
const folderId = safeFolderId(rawFolderId);
|
|
985
1068
|
const existingFolderRecords = toArray(nextState.records?.[folderId]);
|
|
986
1069
|
const payloadRecords = toArray(payload.records);
|
|
987
1070
|
const recordsById = new Map();
|
|
@@ -1007,6 +1090,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1007
1090
|
? payload.recordIds
|
|
1008
1091
|
: payloadRecords.map((record) => getRecordId(record))
|
|
1009
1092
|
);
|
|
1093
|
+
if (orderedIds.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
|
|
1010
1094
|
if (orderedIds.length === 0) return normalized;
|
|
1011
1095
|
|
|
1012
1096
|
const seen = new Set();
|
|
@@ -1030,13 +1114,21 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1030
1114
|
|
|
1031
1115
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
1032
1116
|
const recordId = normalizeId(payload.recordId || payload.id);
|
|
1033
|
-
if (!recordId) return normalized;
|
|
1117
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1034
1118
|
const existingEntry = findRecordEntry(nextState.records, recordId);
|
|
1035
1119
|
const recordSnapshot = payload.record && typeof payload.record === 'object'
|
|
1036
1120
|
? {...payload.record, id: recordId}
|
|
1037
1121
|
: existingEntry?.record;
|
|
1038
1122
|
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
1039
|
-
if (!
|
|
1123
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1124
|
+
const existingTombstone = nextState.deletedRecordTombstones[recordId];
|
|
1125
|
+
if (!recordSnapshot && (nextState.trashEntries[trashEntryId] || existingTombstone)) {
|
|
1126
|
+
return normalized;
|
|
1127
|
+
}
|
|
1128
|
+
if (recordSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1129
|
+
Number(recordSnapshot.lifecycleGeneration || 0)) {
|
|
1130
|
+
return normalized;
|
|
1131
|
+
}
|
|
1040
1132
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1041
1133
|
const previousGeneration = Math.max(
|
|
1042
1134
|
Number(recordSnapshot?.lifecycleGeneration || 0),
|
|
@@ -1071,16 +1163,29 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1071
1163
|
}
|
|
1072
1164
|
|
|
1073
1165
|
case OPERATION_TYPES.RECORD_RESTORE: {
|
|
1166
|
+
const restoreRecordId = normalizeId(payload.recordId || payload.id);
|
|
1074
1167
|
const trashEntryId = normalizeId(payload.trashEntryId) ||
|
|
1075
|
-
`record:${
|
|
1076
|
-
|
|
1168
|
+
`record:${restoreRecordId}`;
|
|
1169
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1170
|
+
const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
|
|
1171
|
+
? nextState.trashEntries[trashEntryId]
|
|
1172
|
+
: null;
|
|
1077
1173
|
if (!trashEntry || trashEntry.kind !== 'record') return normalized;
|
|
1078
1174
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
1079
1175
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
1080
1176
|
if (expectedGeneration === null ||
|
|
1081
1177
|
expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
|
|
1082
1178
|
const recordId = normalizeId(trashEntry.entityId);
|
|
1083
|
-
if (!recordId || findRecordEntry(nextState.records, recordId))
|
|
1179
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId) || findRecordEntry(nextState.records, recordId)) {
|
|
1180
|
+
return normalized;
|
|
1181
|
+
}
|
|
1182
|
+
const lifecycleTombstone = rtlOwn(nextState.deletedRecordTombstones, recordId)
|
|
1183
|
+
? nextState.deletedRecordTombstones[recordId]
|
|
1184
|
+
: null;
|
|
1185
|
+
if (!lifecycleTombstone ||
|
|
1186
|
+
Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
|
|
1187
|
+
return normalized;
|
|
1188
|
+
}
|
|
1084
1189
|
const recordSnapshot = trashEntry.payload?.record;
|
|
1085
1190
|
if (!recordSnapshot) return normalized;
|
|
1086
1191
|
const originalFolderId = safeFolderId(trashEntry.originalFolderId);
|
|
@@ -1109,8 +1214,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1109
1214
|
case OPERATION_TYPES.FOLDER_CREATE: {
|
|
1110
1215
|
const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
|
|
1111
1216
|
const folderId = normalizeId(folder?.id || payload.folderId);
|
|
1112
|
-
if (!folderId) return normalized;
|
|
1113
|
-
if (nextState.deletedFolderTombstones
|
|
1217
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1218
|
+
if (rtlOwn(nextState.deletedFolderTombstones, folderId) &&
|
|
1219
|
+
nextState.deletedFolderTombstones[folderId]) return normalized;
|
|
1114
1220
|
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
1115
1221
|
const nextFolder = {
|
|
1116
1222
|
...(folder || {}),
|
|
@@ -1128,7 +1234,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1128
1234
|
|
|
1129
1235
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
1130
1236
|
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
1131
|
-
if (!folderId) return normalized;
|
|
1237
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1132
1238
|
const patch = payload.patch || payload.folder || {};
|
|
1133
1239
|
nextState.folders = nextState.folders.map((folder) => (
|
|
1134
1240
|
folder.id === folderId
|
|
@@ -1140,12 +1246,24 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1140
1246
|
|
|
1141
1247
|
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
1142
1248
|
const folderId = normalizeId(payload.folderId || payload.id);
|
|
1143
|
-
if (!folderId || folderId
|
|
1249
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId) ||
|
|
1250
|
+
folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
|
|
1144
1251
|
const folderSnapshot = payload.folder && typeof payload.folder === 'object'
|
|
1145
1252
|
? {...payload.folder, id: folderId}
|
|
1146
1253
|
: nextState.folders.find((folder) => folder.id === folderId);
|
|
1147
1254
|
const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
|
|
1148
|
-
|
|
1255
|
+
const existingTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
|
|
1256
|
+
? nextState.deletedFolderTombstones[folderId]
|
|
1257
|
+
: null;
|
|
1258
|
+
if (!folderSnapshot &&
|
|
1259
|
+
((rtlOwn(nextState.trashEntries, `folder:${folderId}`) &&
|
|
1260
|
+
nextState.trashEntries[`folder:${folderId}`]) || existingTombstone)) {
|
|
1261
|
+
return normalized;
|
|
1262
|
+
}
|
|
1263
|
+
if (folderSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1264
|
+
Number(folderSnapshot.lifecycleGeneration || 0)) {
|
|
1265
|
+
return normalized;
|
|
1266
|
+
}
|
|
1149
1267
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1150
1268
|
const previousGeneration = Math.max(
|
|
1151
1269
|
Number(folderSnapshot?.lifecycleGeneration || 0),
|
|
@@ -1154,6 +1272,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1154
1272
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1155
1273
|
if (folderSnapshot) {
|
|
1156
1274
|
const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
|
|
1275
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1157
1276
|
nextState.trashEntries[trashEntryId] = {
|
|
1158
1277
|
id: trashEntryId,
|
|
1159
1278
|
kind: 'folder',
|
|
@@ -1187,7 +1306,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1187
1306
|
};
|
|
1188
1307
|
folderRecords.forEach((record) => {
|
|
1189
1308
|
const recordId = getRecordId(record);
|
|
1190
|
-
if (!recordId) return;
|
|
1309
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return;
|
|
1191
1310
|
nextState.deletedRecordTombstones[recordId] = {
|
|
1192
1311
|
id: recordId,
|
|
1193
1312
|
folderId,
|
|
@@ -1201,16 +1320,28 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1201
1320
|
}
|
|
1202
1321
|
|
|
1203
1322
|
case OPERATION_TYPES.FOLDER_RESTORE: {
|
|
1323
|
+
const restoreFolderId = normalizeId(payload.folderId || payload.id);
|
|
1204
1324
|
const trashEntryId = normalizeId(payload.trashEntryId) ||
|
|
1205
|
-
`folder:${
|
|
1206
|
-
|
|
1325
|
+
`folder:${restoreFolderId}`;
|
|
1326
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1327
|
+
const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
|
|
1328
|
+
? nextState.trashEntries[trashEntryId]
|
|
1329
|
+
: null;
|
|
1207
1330
|
if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
|
|
1208
1331
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
1209
1332
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
1210
1333
|
if (expectedGeneration === null ||
|
|
1211
1334
|
expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
|
|
1212
1335
|
const folderId = normalizeId(trashEntry.entityId);
|
|
1213
|
-
if (!folderId ||
|
|
1336
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId) ||
|
|
1337
|
+
nextState.folders.some((folder) => folder.id === folderId)) return normalized;
|
|
1338
|
+
const lifecycleTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
|
|
1339
|
+
? nextState.deletedFolderTombstones[folderId]
|
|
1340
|
+
: null;
|
|
1341
|
+
if (!lifecycleTombstone ||
|
|
1342
|
+
Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
|
|
1343
|
+
return normalized;
|
|
1344
|
+
}
|
|
1214
1345
|
const folderSnapshot = trashEntry.payload?.folder;
|
|
1215
1346
|
if (!folderSnapshot) return normalized;
|
|
1216
1347
|
const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
|
|
@@ -1299,6 +1430,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1299
1430
|
|
|
1300
1431
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
1301
1432
|
nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
|
|
1433
|
+
if (nextState.folderOrder.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
|
|
1302
1434
|
break;
|
|
1303
1435
|
|
|
1304
1436
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
@@ -1406,6 +1538,11 @@ export const mergeLocalRemote = ({
|
|
|
1406
1538
|
});
|
|
1407
1539
|
});
|
|
1408
1540
|
|
|
1541
|
+
// Pending operations are explicit local commands. Replay them against the
|
|
1542
|
+
// merged remote baseline in FIFO order; do not apply the remote document's
|
|
1543
|
+
// LWW timestamp as a second gate, or an offline update with an older
|
|
1544
|
+
// createdAt would be silently lost. `applyExplicitRecordUpdate` preserves
|
|
1545
|
+
// omitted properties while honoring present empty/false/null values.
|
|
1409
1546
|
toArray(pendingOps).forEach((operation) => {
|
|
1410
1547
|
mergedState = applyOperation(mergedState, operation);
|
|
1411
1548
|
});
|
|
@@ -1740,7 +1877,9 @@ const reorderV2RecordsByFolderRecordOrder = (records = {}, recordOrderByFolder =
|
|
|
1740
1877
|
|
|
1741
1878
|
const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
1742
1879
|
const recordId = normalizeId(record.id);
|
|
1743
|
-
if (!recordId) return null;
|
|
1880
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return null;
|
|
1881
|
+
const normalizedFolderId = normalizeId(folderId);
|
|
1882
|
+
if (normalizedFolderId && !rtlIsSafeDocumentId(normalizedFolderId)) return null;
|
|
1744
1883
|
const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
|
|
1745
1884
|
delete cleanRecord.pendingSync;
|
|
1746
1885
|
delete cleanRecord.syncAttempts;
|
|
@@ -1753,7 +1892,7 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
|
1753
1892
|
|
|
1754
1893
|
const cleanV2FolderDocument = (folder = {}, now) => {
|
|
1755
1894
|
const folderId = normalizeId(folder.id);
|
|
1756
|
-
if (!folderId) return null;
|
|
1895
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return null;
|
|
1757
1896
|
const {
|
|
1758
1897
|
schemaVersion,
|
|
1759
1898
|
recordOrder,
|
|
@@ -1771,7 +1910,7 @@ const cleanV2FolderDocument = (folder = {}, now) => {
|
|
|
1771
1910
|
|
|
1772
1911
|
const cleanV2OperationDocument = (operation = {}, now) => {
|
|
1773
1912
|
const operationId = normalizeId(operation.id);
|
|
1774
|
-
if (!operationId || !operation.type) return null;
|
|
1913
|
+
if (!operationId || !rtlIsSafeDocumentId(operationId) || !operation.type) return null;
|
|
1775
1914
|
return {
|
|
1776
1915
|
...operation,
|
|
1777
1916
|
id: operationId,
|
|
@@ -1783,7 +1922,8 @@ const cleanV2OperationDocument = (operation = {}, now) => {
|
|
|
1783
1922
|
const cleanV2TrashDocument = (entry = {}, now) => {
|
|
1784
1923
|
const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
|
|
1785
1924
|
const trashEntry = Object.values(normalized)[0];
|
|
1786
|
-
if (!trashEntry)
|
|
1925
|
+
if (!trashEntry || !rtlIsSafeDocumentId(trashEntry.id) ||
|
|
1926
|
+
!rtlIsSafeDocumentId(trashEntry.entityId)) return null;
|
|
1787
1927
|
return {
|
|
1788
1928
|
...trashEntry,
|
|
1789
1929
|
deletedAt: trashEntry.deletedAt || now,
|
|
@@ -1799,6 +1939,7 @@ const buildV2LifecycleTombstoneDocuments = (
|
|
|
1799
1939
|
const documents = {};
|
|
1800
1940
|
const append = (kind, tombstones) => {
|
|
1801
1941
|
Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
|
|
1942
|
+
if (!rtlIsSafeDocumentId(entityId)) return;
|
|
1802
1943
|
const id = `${kind}:${entityId}`;
|
|
1803
1944
|
documents[encodeURIComponent(id)] = {
|
|
1804
1945
|
id,
|
|
@@ -3010,6 +3151,168 @@ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
|
|
|
3010
3151
|
|
|
3011
3152
|
const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
3012
3153
|
|
|
3154
|
+
const rtlRawNonNegativeSafeInteger = (value) => (
|
|
3155
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
3156
|
+
);
|
|
3157
|
+
|
|
3158
|
+
const rtlRawSafeId = (value) => typeof value === 'string' && rtlIsSafeDocumentId(value);
|
|
3159
|
+
|
|
3160
|
+
const rtlRawFiniteTimestamp = (value) => (
|
|
3161
|
+
typeof value === 'number' && Number.isFinite(value)
|
|
3162
|
+
);
|
|
3163
|
+
|
|
3164
|
+
const rtlRawValidOpaqueGroupOrder = (value) => (
|
|
3165
|
+
typeof value === 'string' && value.length > 0 &&
|
|
3166
|
+
new TextEncoder().encode(value).byteLength <= RTL_SAFE_DOCUMENT_ID_MAX_BYTES &&
|
|
3167
|
+
!/[\u0000-\u001F\u007F-\u009F]/u.test(value)
|
|
3168
|
+
);
|
|
3169
|
+
|
|
3170
|
+
const rtlRawValidOrder = (value, {opaqueGroup = false} = {}) => (
|
|
3171
|
+
Array.isArray(value) && value.every((id) => (
|
|
3172
|
+
opaqueGroup ? rtlRawValidOpaqueGroupOrder(id) : rtlRawSafeId(id)
|
|
3173
|
+
))
|
|
3174
|
+
);
|
|
3175
|
+
|
|
3176
|
+
const rtlRawValidTombstoneTable = (value) => (
|
|
3177
|
+
rtlDurableIsObject(value) && Object.entries(value).every(([id, tombstone]) => (
|
|
3178
|
+
rtlRawSafeId(id) && rtlDurableIsObject(tombstone) &&
|
|
3179
|
+
(!Object.prototype.hasOwnProperty.call(tombstone, 'id') || rtlRawSafeId(tombstone.id)) &&
|
|
3180
|
+
Number.isSafeInteger(tombstone.lifecycleGeneration) && tombstone.lifecycleGeneration > 0 &&
|
|
3181
|
+
rtlRawFiniteTimestamp(tombstone.deletedAt)
|
|
3182
|
+
))
|
|
3183
|
+
);
|
|
3184
|
+
|
|
3185
|
+
const rtlRawValidTrashTable = (value) => (
|
|
3186
|
+
rtlDurableIsObject(value) && Object.entries(value).every(([id, entry]) => (
|
|
3187
|
+
rtlRawSafeId(id) && rtlDurableIsObject(entry) &&
|
|
3188
|
+
rtlRawSafeId(entry.id) && entry.id === id &&
|
|
3189
|
+
(entry.kind === 'record' || entry.kind === 'folder') &&
|
|
3190
|
+
rtlRawSafeId(entry.entityId) &&
|
|
3191
|
+
Number.isSafeInteger(entry.lifecycleGeneration) && entry.lifecycleGeneration > 0 &&
|
|
3192
|
+
rtlRawFiniteTimestamp(entry.deletedAt) && rtlRawFiniteTimestamp(entry.purgeAt) &&
|
|
3193
|
+
(!Object.prototype.hasOwnProperty.call(entry, 'payload') || rtlDurableIsObject(entry.payload))
|
|
3194
|
+
))
|
|
3195
|
+
);
|
|
3196
|
+
|
|
3197
|
+
const rtlHasCompleteBaselineState = (state) => {
|
|
3198
|
+
if (!rtlDurableIsObject(state) || ![
|
|
3199
|
+
'folders', 'records', 'groupOrder', 'folderOrder', 'settings', 'rtlSyncMeta',
|
|
3200
|
+
'deletedRecordTombstones', 'deletedFolderTombstones', 'trashEntries'
|
|
3201
|
+
].every((key) => Object.prototype.hasOwnProperty.call(state, key))) return false;
|
|
3202
|
+
if (!Array.isArray(state.folders) || !rtlDurableIsObject(state.records) ||
|
|
3203
|
+
!rtlRawValidOrder(state.groupOrder, {opaqueGroup: true}) || !rtlRawValidOrder(state.folderOrder) ||
|
|
3204
|
+
!rtlDurableIsObject(state.settings) || !rtlDurableIsObject(state.rtlSyncMeta) ||
|
|
3205
|
+
!rtlRawValidTombstoneTable(state.deletedRecordTombstones) ||
|
|
3206
|
+
!rtlRawValidTombstoneTable(state.deletedFolderTombstones) ||
|
|
3207
|
+
!rtlRawValidTrashTable(state.trashEntries)) return false;
|
|
3208
|
+
|
|
3209
|
+
const folderIds = new Set();
|
|
3210
|
+
for (const folder of state.folders) {
|
|
3211
|
+
if (!rtlDurableIsObject(folder) || !rtlRawSafeId(folder.id) || folderIds.has(folder.id) ||
|
|
3212
|
+
(Object.prototype.hasOwnProperty.call(folder, 'recordOrder') && !rtlRawValidOrder(folder.recordOrder))) {
|
|
3213
|
+
return false;
|
|
3214
|
+
}
|
|
3215
|
+
folderIds.add(folder.id);
|
|
3216
|
+
}
|
|
3217
|
+
const recordIds = new Set();
|
|
3218
|
+
for (const [folderId, records] of Object.entries(state.records)) {
|
|
3219
|
+
if (!rtlRawSafeId(folderId) || !Array.isArray(records)) return false;
|
|
3220
|
+
for (const record of records) {
|
|
3221
|
+
if (!rtlDurableIsObject(record) || !rtlRawSafeId(record.id) || recordIds.has(record.id)) {
|
|
3222
|
+
return false;
|
|
3223
|
+
}
|
|
3224
|
+
recordIds.add(record.id);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
return true;
|
|
3228
|
+
};
|
|
3229
|
+
|
|
3230
|
+
// Resume cursors and markers are opaque to Core, but any identity fields an
|
|
3231
|
+
// adapter persists alongside them are still part of the workspace fence. A
|
|
3232
|
+
// cursor from another UID/session/epoch must force a fresh bootstrap rather
|
|
3233
|
+
// than being silently adopted after normalization.
|
|
3234
|
+
const rtlResumeIdentityMatches = (value, captured, ownerUid, workspaceEpoch) => {
|
|
3235
|
+
if (value === null || value === undefined || !rtlDurableIsObject(value)) return false;
|
|
3236
|
+
const hasOwnerIdentity = ['ownerUid', 'uid', 'workspaceOwnerUid']
|
|
3237
|
+
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
3238
|
+
const hasEpochIdentity = ['workspaceEpoch', 'epoch']
|
|
3239
|
+
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
3240
|
+
const hasSessionIdentity = ['authSessionBinding']
|
|
3241
|
+
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
3242
|
+
if (!hasOwnerIdentity || !hasEpochIdentity || !hasSessionIdentity) return false;
|
|
3243
|
+
for (const key of ['ownerUid', 'uid', 'workspaceOwnerUid']) {
|
|
3244
|
+
if (Object.prototype.hasOwnProperty.call(value, key) &&
|
|
3245
|
+
rtlNormalizeUid(value[key]) !== rtlNormalizeUid(ownerUid)) {
|
|
3246
|
+
return false;
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
for (const key of ['workspaceEpoch', 'epoch']) {
|
|
3250
|
+
if (Object.prototype.hasOwnProperty.call(value, key) &&
|
|
3251
|
+
(!rtlRawNonNegativeSafeInteger(value[key]) ||
|
|
3252
|
+
value[key] !== workspaceEpoch)) {
|
|
3253
|
+
return false;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
// Legacy sessionToken/sessionTokenId markers are credentials or opaque
|
|
3257
|
+
// fences that cannot be safely persisted. They deliberately fail closed;
|
|
3258
|
+
// callers must bootstrap and receive a newly bound marker.
|
|
3259
|
+
if (Object.prototype.hasOwnProperty.call(value, 'sessionToken') ||
|
|
3260
|
+
Object.prototype.hasOwnProperty.call(value, 'sessionTokenId')) return false;
|
|
3261
|
+
for (const key of ['authSessionBinding']) {
|
|
3262
|
+
if (Object.prototype.hasOwnProperty.call(value, key) &&
|
|
3263
|
+
(captured?.authSessionBinding === undefined || captured?.authSessionBinding === null ||
|
|
3264
|
+
!Object.is(value[key], captured.authSessionBinding))) {
|
|
3265
|
+
return false;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
return true;
|
|
3269
|
+
};
|
|
3270
|
+
|
|
3271
|
+
const rtlResumeMarkerMatches = (value, captured, ownerUid, workspaceEpoch) =>
|
|
3272
|
+
rtlResumeIdentityMatches(value, captured, ownerUid, workspaceEpoch);
|
|
3273
|
+
|
|
3274
|
+
const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
|
|
3275
|
+
if (!rtlDurableIsObject(loaded) || loaded.schemaVersion !== RTL_DURABLE_SCHEMA_VERSION) {
|
|
3276
|
+
return false;
|
|
3277
|
+
}
|
|
3278
|
+
const ownerUid = rtlNormalizeUid(loaded.ownerUid);
|
|
3279
|
+
const expectedOwnerUid = rtlNormalizeUid(captured?.uid);
|
|
3280
|
+
if (!ownerUid || !expectedOwnerUid || ownerUid !== expectedOwnerUid) return false;
|
|
3281
|
+
if (!rtlRawNonNegativeSafeInteger(loaded.workspaceEpoch) ||
|
|
3282
|
+
(captured?.hasEpoch && loaded.workspaceEpoch !== captured.workspaceEpoch)) {
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
const baseline = loaded.remoteBaseline;
|
|
3286
|
+
if (!rtlDurableIsObject(baseline) ||
|
|
3287
|
+
!Object.prototype.hasOwnProperty.call(baseline, 'state') ||
|
|
3288
|
+
!Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
|
|
3289
|
+
!Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
|
|
3290
|
+
!rtlHasCompleteBaselineState(baseline.state) ||
|
|
3291
|
+
!rtlRawNonNegativeSafeInteger(baseline.revision) ||
|
|
3292
|
+
baseline.revision <= 0) {
|
|
3293
|
+
return false;
|
|
3294
|
+
}
|
|
3295
|
+
if (!rtlResumeIdentityMatches(
|
|
3296
|
+
baseline.changeCursor,
|
|
3297
|
+
captured,
|
|
3298
|
+
ownerUid,
|
|
3299
|
+
loaded.workspaceEpoch
|
|
3300
|
+
)) {
|
|
3301
|
+
return false;
|
|
3302
|
+
}
|
|
3303
|
+
if (loaded.syncMeta !== undefined &&
|
|
3304
|
+
!rtlDurableIsObject(loaded.syncMeta)) return false;
|
|
3305
|
+
const resumeMarker = loaded.syncMeta?.resumeMarker ??
|
|
3306
|
+
loaded.syncMeta?.resume ??
|
|
3307
|
+
loaded.syncMeta?.bootstrapMarker;
|
|
3308
|
+
return resumeMarker === undefined || rtlResumeMarkerMatches(
|
|
3309
|
+
resumeMarker,
|
|
3310
|
+
captured,
|
|
3311
|
+
ownerUid,
|
|
3312
|
+
loaded.workspaceEpoch
|
|
3313
|
+
);
|
|
3314
|
+
};
|
|
3315
|
+
|
|
3013
3316
|
const rtlToFiniteNumber = (value, fallback = 0) => {
|
|
3014
3317
|
const number = Number(value);
|
|
3015
3318
|
return Number.isFinite(number) ? number : fallback;
|
|
@@ -3035,12 +3338,26 @@ const rtlNormalizeWorkspaceEpoch = (value, fallback = 0) => {
|
|
|
3035
3338
|
|
|
3036
3339
|
const rtlCloneOperation = (operation) => clone(operation && typeof operation === 'object' ? operation : {});
|
|
3037
3340
|
|
|
3341
|
+
// Durable storage may contain adapter metadata at arbitrary nesting levels. Strip
|
|
3342
|
+
// credential-shaped keys case/separator-insensitively, while retaining the explicit
|
|
3343
|
+
// non-credential authSessionBinding used to bind a committed workspace to a session.
|
|
3344
|
+
const rtlIsCredentialKey = (key) => {
|
|
3345
|
+
const normalized = String(key).replace(/[\s_-]/g, '').toLowerCase();
|
|
3346
|
+
if (normalized === 'authsessionbinding') return false;
|
|
3347
|
+
return new Set([
|
|
3348
|
+
'sessiontoken', 'sessiontokenid', 'sessionid', 'authtoken', 'accesstoken',
|
|
3349
|
+
'refreshtoken', 'idtoken', 'authorization', 'credential', 'credentials',
|
|
3350
|
+
'firebasecredential', 'jwttoken', 'bearertoken', 'accesskey', 'refreshkey',
|
|
3351
|
+
'authsessionid', 'authsessionkey'
|
|
3352
|
+
]).has(normalized) || /(?:token|credential|authorization|secret)$/.test(normalized);
|
|
3353
|
+
};
|
|
3354
|
+
|
|
3038
3355
|
const rtlStripSessionTokens = (value) => {
|
|
3039
3356
|
if (Array.isArray(value)) return value.map((entry) => rtlStripSessionTokens(entry));
|
|
3040
3357
|
if (!rtlDurableIsObject(value)) return value;
|
|
3041
3358
|
const result = {};
|
|
3042
3359
|
Object.entries(value).forEach(([key, entry]) => {
|
|
3043
|
-
if (key
|
|
3360
|
+
if (rtlIsCredentialKey(key)) return;
|
|
3044
3361
|
result[key] = rtlStripSessionTokens(entry);
|
|
3045
3362
|
});
|
|
3046
3363
|
return result;
|
|
@@ -3056,35 +3373,6 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3056
3373
|
const input = operation && typeof operation === 'object' ? operation : {};
|
|
3057
3374
|
const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
|
|
3058
3375
|
const normalizedPayload = payload;
|
|
3059
|
-
const trimPayloadId = (key) => {
|
|
3060
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload, key)) {
|
|
3061
|
-
normalizedPayload[key] = normalizeId(normalizedPayload[key]) || normalizedPayload[key];
|
|
3062
|
-
}
|
|
3063
|
-
};
|
|
3064
|
-
[
|
|
3065
|
-
'recordId', 'folderId', 'targetFolderId', 'trashEntryId', 'batchId', 'id',
|
|
3066
|
-
'clientInstanceId', 'instanceId'
|
|
3067
|
-
].forEach(trimPayloadId);
|
|
3068
|
-
['recordIds', 'folderOrder', 'groupOrder', 'expandedGroups', 'groupIds', 'ids'].forEach((key) => {
|
|
3069
|
-
if (Array.isArray(normalizedPayload[key])) {
|
|
3070
|
-
normalizedPayload[key] = normalizeIdList(normalizedPayload[key]);
|
|
3071
|
-
}
|
|
3072
|
-
});
|
|
3073
|
-
if (rtlDurableIsObject(normalizedPayload.record)) {
|
|
3074
|
-
normalizedPayload.record = clone(normalizedPayload.record);
|
|
3075
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'id')) {
|
|
3076
|
-
normalizedPayload.record.id = normalizeId(normalizedPayload.record.id) || normalizedPayload.record.id;
|
|
3077
|
-
}
|
|
3078
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'folderId')) {
|
|
3079
|
-
normalizedPayload.record.folderId = normalizeId(normalizedPayload.record.folderId) || normalizedPayload.record.folderId;
|
|
3080
|
-
}
|
|
3081
|
-
}
|
|
3082
|
-
if (rtlDurableIsObject(normalizedPayload.folder)) {
|
|
3083
|
-
normalizedPayload.folder = clone(normalizedPayload.folder);
|
|
3084
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.folder, 'id')) {
|
|
3085
|
-
normalizedPayload.folder.id = normalizeId(normalizedPayload.folder.id) || normalizedPayload.folder.id;
|
|
3086
|
-
}
|
|
3087
|
-
}
|
|
3088
3376
|
|
|
3089
3377
|
const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
|
|
3090
3378
|
const resolvedNow = Number.isFinite(operationNow)
|
|
@@ -3099,7 +3387,9 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3099
3387
|
|
|
3100
3388
|
return {
|
|
3101
3389
|
...rtlStripSessionTokens(input),
|
|
3102
|
-
|
|
3390
|
+
// Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
|
|
3391
|
+
// invalid whitespace or hostile ID instead of receiving a silently rewritten one.
|
|
3392
|
+
id: Object.prototype.hasOwnProperty.call(input, 'id') ? input.id : generatedId,
|
|
3103
3393
|
type: typeof input.type === 'string' ? input.type.trim() : input.type,
|
|
3104
3394
|
payload: normalizedPayload,
|
|
3105
3395
|
clientId: resolvedClientId,
|
|
@@ -3132,10 +3422,12 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
|
3132
3422
|
const baseline = rtlDurableIsObject(value) ? value : {};
|
|
3133
3423
|
return {
|
|
3134
3424
|
state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
|
|
3135
|
-
revision:
|
|
3425
|
+
revision: rtlRawNonNegativeSafeInteger(baseline.revision) ? baseline.revision : 0,
|
|
3136
3426
|
changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
|
|
3137
3427
|
? null
|
|
3138
|
-
:
|
|
3428
|
+
: rtlDurableIsObject(baseline.changeCursor)
|
|
3429
|
+
? clone(baseline.changeCursor)
|
|
3430
|
+
: String(baseline.changeCursor)
|
|
3139
3431
|
};
|
|
3140
3432
|
};
|
|
3141
3433
|
|
|
@@ -3212,6 +3504,29 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3212
3504
|
};
|
|
3213
3505
|
|
|
3214
3506
|
const rtlOperationIdentityReason = (operation, workspace) => {
|
|
3507
|
+
const candidateIds = [
|
|
3508
|
+
operation?.id,
|
|
3509
|
+
operation?.payload?.recordId,
|
|
3510
|
+
operation?.payload?.folderId,
|
|
3511
|
+
operation?.payload?.targetFolderId,
|
|
3512
|
+
operation?.payload?.trashEntryId,
|
|
3513
|
+
operation?.payload?.record?.id,
|
|
3514
|
+
operation?.payload?.folder?.id,
|
|
3515
|
+
...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
|
|
3516
|
+
...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
|
|
3517
|
+
];
|
|
3518
|
+
if (candidateIds.some((value) => (
|
|
3519
|
+
value !== undefined && value !== null &&
|
|
3520
|
+
!rtlIsSafeDocumentId(value)
|
|
3521
|
+
))) {
|
|
3522
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
|
|
3523
|
+
}
|
|
3524
|
+
const groupOrder = Array.isArray(operation?.payload?.groupOrder)
|
|
3525
|
+
? operation.payload.groupOrder
|
|
3526
|
+
: [];
|
|
3527
|
+
if (groupOrder.some((value) => !rtlIsSafeOpaqueGroupId(value))) {
|
|
3528
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
|
|
3529
|
+
}
|
|
3215
3530
|
const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
|
|
3216
3531
|
const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
|
|
3217
3532
|
if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
|
|
@@ -3369,6 +3684,11 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3369
3684
|
Object.prototype.hasOwnProperty.call(current, 'sessionToken')
|
|
3370
3685
|
? current.sessionToken
|
|
3371
3686
|
: null;
|
|
3687
|
+
// Durable resume proofs use an explicit, non-credential auth binding. The
|
|
3688
|
+
// transport/session token remains an in-memory callback fence only.
|
|
3689
|
+
const authSessionBinding = current && typeof current === 'object'
|
|
3690
|
+
? (current.authSessionBinding ?? current.authSessionId ?? current.authSessionKey ?? null)
|
|
3691
|
+
: null;
|
|
3372
3692
|
const uid = rtlNormalizeUid(current && typeof current === 'object'
|
|
3373
3693
|
? (current.uid ?? current.ownerUid)
|
|
3374
3694
|
: null);
|
|
@@ -3380,6 +3700,9 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3380
3700
|
current,
|
|
3381
3701
|
hasSession: typeof session?.current === 'function',
|
|
3382
3702
|
sessionToken,
|
|
3703
|
+
authSessionBinding: typeof authSessionBinding === 'string' && authSessionBinding.trim()
|
|
3704
|
+
? authSessionBinding
|
|
3705
|
+
: null,
|
|
3383
3706
|
uid,
|
|
3384
3707
|
workspaceEpoch: hasEpoch
|
|
3385
3708
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
@@ -3405,18 +3728,54 @@ const rtlSessionContext = (captured, workspace, client) => ({
|
|
|
3405
3728
|
});
|
|
3406
3729
|
|
|
3407
3730
|
const rtlEnvelopePayload = (value) => {
|
|
3731
|
+
const normalizeRevisionAlias = (candidate) => {
|
|
3732
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
3733
|
+
return candidate;
|
|
3734
|
+
}
|
|
3735
|
+
if (
|
|
3736
|
+
!Object.prototype.hasOwnProperty.call(candidate, 'revision') &&
|
|
3737
|
+
Object.prototype.hasOwnProperty.call(candidate, 'remoteRevision')
|
|
3738
|
+
) {
|
|
3739
|
+
return {...candidate, revision: candidate.remoteRevision};
|
|
3740
|
+
}
|
|
3741
|
+
return candidate;
|
|
3742
|
+
};
|
|
3408
3743
|
if (value && typeof value === 'object') {
|
|
3409
|
-
if (value.remoteBaseline) return value.remoteBaseline;
|
|
3410
|
-
if (value.baseline) return value.baseline;
|
|
3411
|
-
|
|
3412
|
-
|
|
3744
|
+
if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
|
|
3745
|
+
if (value.baseline) return normalizeRevisionAlias(value.baseline);
|
|
3746
|
+
// A canonical failure may carry an authoritative baseline for an
|
|
3747
|
+
// operation-id conflict. It must win over compatibility state fields
|
|
3748
|
+
// left beside `failure`; otherwise a stale outer state can be applied
|
|
3749
|
+
// while the canonical failure is being handled.
|
|
3750
|
+
if (value.failure && typeof value.failure === 'object') {
|
|
3751
|
+
const nested = rtlEnvelopePayload(value.failure);
|
|
3752
|
+
if (nested) return nested;
|
|
3753
|
+
}
|
|
3754
|
+
if (
|
|
3755
|
+
value.state !== undefined ||
|
|
3756
|
+
value.data !== undefined ||
|
|
3757
|
+
value.revision !== undefined ||
|
|
3758
|
+
value.remoteRevision !== undefined ||
|
|
3759
|
+
value.changeCursor !== undefined
|
|
3760
|
+
) {
|
|
3761
|
+
return normalizeRevisionAlias(value);
|
|
3762
|
+
}
|
|
3763
|
+
if (value.snapshot && typeof value.snapshot === 'object') {
|
|
3764
|
+
return normalizeRevisionAlias(value.snapshot);
|
|
3765
|
+
}
|
|
3766
|
+
if (value.error && typeof value.error === 'object') {
|
|
3767
|
+
const nested = rtlEnvelopePayload(value.error);
|
|
3768
|
+
if (nested) return nested;
|
|
3413
3769
|
}
|
|
3414
|
-
if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
|
|
3415
3770
|
// Adapters migrating from the v1 cloud port may still return a raw
|
|
3416
3771
|
// normalized state. Treat the presence of state-owned keys as an implicit
|
|
3417
3772
|
// baseline with the persisted revision.
|
|
3418
3773
|
if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
|
|
3419
|
-
return {
|
|
3774
|
+
return {
|
|
3775
|
+
state: value,
|
|
3776
|
+
revision: value.revision ?? value.remoteRevision,
|
|
3777
|
+
changeCursor: value.changeCursor
|
|
3778
|
+
};
|
|
3420
3779
|
}
|
|
3421
3780
|
}
|
|
3422
3781
|
return null;
|
|
@@ -3424,44 +3783,63 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3424
3783
|
|
|
3425
3784
|
const rtlEnvelopeSuccess = (value) => {
|
|
3426
3785
|
if (!value || typeof value !== 'object') return false;
|
|
3427
|
-
if (
|
|
3786
|
+
if (
|
|
3787
|
+
value.success === false ||
|
|
3788
|
+
value.ok === false ||
|
|
3789
|
+
value.error ||
|
|
3790
|
+
value.failure ||
|
|
3791
|
+
value.code === 'error'
|
|
3792
|
+
) return false;
|
|
3428
3793
|
return value.success === true || value.ok === true || (
|
|
3429
3794
|
value.success === undefined && value.ok === undefined && !value.error
|
|
3430
3795
|
);
|
|
3431
3796
|
};
|
|
3432
3797
|
|
|
3433
|
-
const
|
|
3434
|
-
const
|
|
3435
|
-
|
|
3436
|
-
const
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3798
|
+
const rtlExplicitRevisionInfo = (value) => {
|
|
3799
|
+
const revisions = [];
|
|
3800
|
+
let invalid = false;
|
|
3801
|
+
const visited = new Set();
|
|
3802
|
+
const add = (source, key) => {
|
|
3803
|
+
if (!source || typeof source !== 'object' ||
|
|
3804
|
+
!Object.prototype.hasOwnProperty.call(source, key)) return;
|
|
3805
|
+
const raw = source[key];
|
|
3806
|
+
const numeric = typeof raw === 'number' ||
|
|
3807
|
+
(typeof raw === 'string' && raw.trim() !== '')
|
|
3808
|
+
? Number(raw)
|
|
3809
|
+
: NaN;
|
|
3810
|
+
if (!Number.isSafeInteger(numeric) || numeric < 0) {
|
|
3811
|
+
invalid = true;
|
|
3812
|
+
return;
|
|
3813
|
+
}
|
|
3814
|
+
revisions.push(numeric);
|
|
3815
|
+
};
|
|
3816
|
+
const visit = (source, depth = 0) => {
|
|
3817
|
+
if (!source || typeof source !== 'object' || visited.has(source) || depth > 8) return;
|
|
3818
|
+
visited.add(source);
|
|
3819
|
+
add(source, 'revision');
|
|
3820
|
+
add(source, 'remoteRevision');
|
|
3821
|
+
[
|
|
3822
|
+
source.remoteBaseline,
|
|
3823
|
+
source.baseline,
|
|
3824
|
+
source.snapshot,
|
|
3825
|
+
source.failure,
|
|
3826
|
+
source.error
|
|
3827
|
+
].forEach((nested) => visit(nested, depth + 1));
|
|
3828
|
+
};
|
|
3829
|
+
visit(value);
|
|
3830
|
+
const unique = [...new Set(revisions)];
|
|
3831
|
+
if (unique.length > 1) invalid = true;
|
|
3832
|
+
return {
|
|
3833
|
+
present: revisions.length > 0 || invalid,
|
|
3834
|
+
valid: !invalid,
|
|
3835
|
+
revision: unique[0] ?? null
|
|
3836
|
+
};
|
|
3457
3837
|
};
|
|
3458
3838
|
|
|
3459
|
-
const
|
|
3460
|
-
const error =
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
const retryAfter = Number(rawRetryAfter);
|
|
3464
|
-
return Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
|
|
3839
|
+
const rtlInvalidRevisionError = () => {
|
|
3840
|
+
const error = new Error('sync_protocol_invalid_revision');
|
|
3841
|
+
error.code = 'sync_protocol_invalid_revision';
|
|
3842
|
+
return error;
|
|
3465
3843
|
};
|
|
3466
3844
|
|
|
3467
3845
|
const rtlResultOperationId = (result = {}) => normalizeId(
|
|
@@ -3569,6 +3947,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3569
3947
|
|
|
3570
3948
|
const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
|
|
3571
3949
|
|
|
3950
|
+
const workspaceMatchesSession = (captured) => {
|
|
3951
|
+
if (!captured?.hasSession) return true;
|
|
3952
|
+
return rtlNormalizeUid(workspace.ownerUid) === captured.uid &&
|
|
3953
|
+
Number(workspace.workspaceEpoch) === Number(captured.workspaceEpoch);
|
|
3954
|
+
};
|
|
3955
|
+
|
|
3572
3956
|
const isCurrent = async (captured) => {
|
|
3573
3957
|
if (destroyed || !captured) return false;
|
|
3574
3958
|
if (typeof session?.isCurrent === 'function') {
|
|
@@ -3595,11 +3979,23 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3595
3979
|
|
|
3596
3980
|
const persist = async (candidate, captured) => {
|
|
3597
3981
|
if (!(await isCurrent(captured))) return false;
|
|
3982
|
+
const cursor = candidate?.remoteBaseline?.changeCursor;
|
|
3983
|
+
if (cursor !== null && cursor !== undefined) {
|
|
3984
|
+
const durableCursor = rtlDurableIsObject(cursor) ? clone(cursor) : {value: String(cursor)};
|
|
3985
|
+
candidate.remoteBaseline.changeCursor = {
|
|
3986
|
+
...durableCursor,
|
|
3987
|
+
ownerUid: captured?.uid ?? null,
|
|
3988
|
+
uid: captured?.uid ?? null,
|
|
3989
|
+
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
|
|
3990
|
+
authSessionBinding: captured?.authSessionBinding ?? null
|
|
3991
|
+
};
|
|
3992
|
+
}
|
|
3993
|
+
const durableCandidate = rtlStripSessionTokens(candidate);
|
|
3598
3994
|
// The second argument is an optional adapter-side fence. The public
|
|
3599
3995
|
// StoragePort remains compatible with save(workspace); adapters that can
|
|
3600
3996
|
// enforce an atomic session/epoch check should consume this context before
|
|
3601
3997
|
// mutating durable storage.
|
|
3602
|
-
await storage.save(clone(
|
|
3998
|
+
await storage.save(clone(durableCandidate), {
|
|
3603
3999
|
sessionToken: captured?.sessionToken ?? null,
|
|
3604
4000
|
uid: captured?.uid ?? null,
|
|
3605
4001
|
ownerUid: captured?.uid ?? null,
|
|
@@ -3618,7 +4014,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3618
4014
|
}
|
|
3619
4015
|
const nextBaseline = rtlNormalizeRemoteBaseline({
|
|
3620
4016
|
state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
|
|
3621
|
-
revision: Number.
|
|
4017
|
+
revision: Number.isSafeInteger(revision) && revision >= 0
|
|
4018
|
+
? revision
|
|
4019
|
+
: candidate.remoteBaseline.revision,
|
|
3622
4020
|
changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
|
|
3623
4021
|
? remote.changeCursor
|
|
3624
4022
|
: candidate.remoteBaseline.changeCursor
|
|
@@ -3638,18 +4036,18 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3638
4036
|
|
|
3639
4037
|
const normalizeBootstrapResponse = (response) => {
|
|
3640
4038
|
if (!rtlEnvelopeSuccess(response)) {
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
});
|
|
4039
|
+
const source = response && typeof response === 'object' ? response : {
|
|
4040
|
+
message: 'recordtimelabel_bootstrap_failed',
|
|
4041
|
+
code: 'recordtimelabel_bootstrap_failed',
|
|
4042
|
+
reason: 'recordtimelabel_bootstrap_failed'
|
|
4043
|
+
};
|
|
4044
|
+
throw toRecordTimeLabelCloudFailureError(source);
|
|
3648
4045
|
}
|
|
3649
4046
|
const remote = rtlEnvelopePayload(response);
|
|
3650
4047
|
const revision = Number(remote?.revision);
|
|
3651
4048
|
const state = remote?.state ?? remote?.data;
|
|
3652
|
-
if (!remote || !rtlDurableIsObject(state) ||
|
|
4049
|
+
if (!remote || !rtlDurableIsObject(state) ||
|
|
4050
|
+
!Number.isSafeInteger(revision) || revision < 0) {
|
|
3653
4051
|
throw toRecordTimeLabelCloudFailureError({
|
|
3654
4052
|
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
3655
4053
|
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
@@ -3703,7 +4101,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3703
4101
|
const rethrowClassifiedCloudFailure = (captured, error) => {
|
|
3704
4102
|
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
3705
4103
|
rememberAuthTransitionFailure(captured, failure);
|
|
3706
|
-
if (error?.class === failure.class) throw error;
|
|
4104
|
+
if (error instanceof Error && error?.class === failure.class) throw error;
|
|
3707
4105
|
throw toRecordTimeLabelCloudFailureError(failure);
|
|
3708
4106
|
};
|
|
3709
4107
|
|
|
@@ -3714,6 +4112,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3714
4112
|
error.code = 'recordtimelabel_bootstrap_revision_behind_required';
|
|
3715
4113
|
error.bootstrapRevision = Number(baseline?.revision);
|
|
3716
4114
|
error.minimumRevision = minimum;
|
|
4115
|
+
error.failure = normalizeRecordTimeLabelCloudFailure({
|
|
4116
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4117
|
+
code: error.code,
|
|
4118
|
+
reason: error.code,
|
|
4119
|
+
message: error.message
|
|
4120
|
+
});
|
|
3717
4121
|
throw error;
|
|
3718
4122
|
}
|
|
3719
4123
|
return baseline;
|
|
@@ -3939,13 +4343,366 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3939
4343
|
nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
|
|
3940
4344
|
});
|
|
3941
4345
|
|
|
4346
|
+
const failureOutcome = (failure, error, extra = {}) => ({
|
|
4347
|
+
success: false,
|
|
4348
|
+
failure: clone(failure),
|
|
4349
|
+
error,
|
|
4350
|
+
class: failure.class,
|
|
4351
|
+
status: failure.status,
|
|
4352
|
+
code: failure.code,
|
|
4353
|
+
reason: failure.reason,
|
|
4354
|
+
retryable: failure.retryable,
|
|
4355
|
+
retryAfterMs: failure.retryAfterMs,
|
|
4356
|
+
bootstrapRequired: failure.bootstrapRequired,
|
|
4357
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4358
|
+
...extra
|
|
4359
|
+
});
|
|
4360
|
+
|
|
4361
|
+
const persistTerminalFailureBlock = async ({
|
|
4362
|
+
failure,
|
|
4363
|
+
error,
|
|
4364
|
+
captured,
|
|
4365
|
+
timestamp,
|
|
4366
|
+
identityRejectedCount,
|
|
4367
|
+
extra = {}
|
|
4368
|
+
}) => {
|
|
4369
|
+
const candidate = clone(workspace);
|
|
4370
|
+
candidate.syncMeta = {
|
|
4371
|
+
...candidate.syncMeta,
|
|
4372
|
+
lastSyncAttemptAt: timestamp,
|
|
4373
|
+
lastSyncError: failure.message || failure.code || 'terminal',
|
|
4374
|
+
lastSyncFailure: clone(failure),
|
|
4375
|
+
terminalFailureBlock: clone(failure)
|
|
4376
|
+
};
|
|
4377
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4378
|
+
workspace = candidate;
|
|
4379
|
+
return failureOutcome(failure, error, {
|
|
4380
|
+
protocolError: true,
|
|
4381
|
+
blocked: true,
|
|
4382
|
+
identityRejectedCount,
|
|
4383
|
+
...extra
|
|
4384
|
+
});
|
|
4385
|
+
};
|
|
4386
|
+
|
|
4387
|
+
const rebaseAfterBootstrapRequired = async ({
|
|
4388
|
+
failure,
|
|
4389
|
+
error,
|
|
4390
|
+
captured,
|
|
4391
|
+
context,
|
|
4392
|
+
timestamp,
|
|
4393
|
+
identityRejectedCount
|
|
4394
|
+
}) => {
|
|
4395
|
+
hydrationRequired = true;
|
|
4396
|
+
if (typeof cloud?.bootstrap !== 'function') {
|
|
4397
|
+
return failureOutcome(failure, error, {
|
|
4398
|
+
bootstrapRequired: true,
|
|
4399
|
+
hydrationBarrier: true,
|
|
4400
|
+
identityRejectedCount
|
|
4401
|
+
});
|
|
4402
|
+
}
|
|
4403
|
+
let baseline;
|
|
4404
|
+
try {
|
|
4405
|
+
baseline = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
4406
|
+
context,
|
|
4407
|
+
bootstrapAttemptOptions('apply-failure-rebase')
|
|
4408
|
+
));
|
|
4409
|
+
} catch (bootstrapError) {
|
|
4410
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4411
|
+
rethrowClassifiedCloudFailure(captured, bootstrapError);
|
|
4412
|
+
}
|
|
4413
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4414
|
+
const candidate = clone(workspace);
|
|
4415
|
+
const applied = applyRemoteBaseline(candidate, baseline);
|
|
4416
|
+
if (applied.stale) {
|
|
4417
|
+
const staleFailure = normalizeRecordTimeLabelCloudFailure({
|
|
4418
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4419
|
+
code: 'bootstrap_revision_behind_required',
|
|
4420
|
+
reason: 'bootstrap_revision_behind_required',
|
|
4421
|
+
message: 'bootstrap_revision_behind_required'
|
|
4422
|
+
});
|
|
4423
|
+
return failureOutcome(staleFailure, toRecordTimeLabelCloudFailureError(staleFailure), {
|
|
4424
|
+
bootstrapRequired: true,
|
|
4425
|
+
hydrationBarrier: true,
|
|
4426
|
+
identityRejectedCount
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
4429
|
+
candidate.syncMeta = {...candidate.syncMeta};
|
|
4430
|
+
delete candidate.syncMeta.terminalFailureBlock;
|
|
4431
|
+
candidate.syncMeta.lastSyncFailure = clone(failure);
|
|
4432
|
+
candidate.syncMeta.lastSyncAttemptAt = timestamp;
|
|
4433
|
+
candidate.syncMeta.lastSyncError = null;
|
|
4434
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4435
|
+
workspace = candidate;
|
|
4436
|
+
hydrationRequired = false;
|
|
4437
|
+
notify({type: 'hydration_rebased', failure: clone(failure)});
|
|
4438
|
+
return failureOutcome(failure, error, {
|
|
4439
|
+
bootstrapRequired: true,
|
|
4440
|
+
hydrationBarrier: false,
|
|
4441
|
+
rebaseCompleted: true,
|
|
4442
|
+
identityRejectedCount
|
|
4443
|
+
});
|
|
4444
|
+
};
|
|
4445
|
+
|
|
4446
|
+
const isAuthoritativeFailureBaseline = (value, minimumRevision, verifiedRevision = null) => {
|
|
4447
|
+
const remote = rtlEnvelopePayload(value);
|
|
4448
|
+
const revision = Number(remote?.revision ?? remote?.remoteRevision);
|
|
4449
|
+
const minimum = Number(minimumRevision);
|
|
4450
|
+
const verified = Number(verifiedRevision);
|
|
4451
|
+
return Boolean(
|
|
4452
|
+
remote &&
|
|
4453
|
+
rtlDurableIsObject(remote.state ?? remote.data) &&
|
|
4454
|
+
Number.isSafeInteger(revision) &&
|
|
4455
|
+
revision >= (Number.isFinite(minimum) ? minimum : 0) &&
|
|
4456
|
+
(verifiedRevision === null || verifiedRevision === undefined ||
|
|
4457
|
+
(Number.isSafeInteger(verified) && revision === verified))
|
|
4458
|
+
);
|
|
4459
|
+
};
|
|
4460
|
+
|
|
4461
|
+
const settleApplyFailure = async ({
|
|
4462
|
+
value,
|
|
4463
|
+
captured,
|
|
4464
|
+
context,
|
|
4465
|
+
ready,
|
|
4466
|
+
timestamp,
|
|
4467
|
+
identityRejectedCount
|
|
4468
|
+
}) => {
|
|
4469
|
+
const failure = normalizeRecordTimeLabelCloudFailure(value);
|
|
4470
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
4471
|
+
const responseRevisionInfo = rtlExplicitRevisionInfo(value);
|
|
4472
|
+
|
|
4473
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION) {
|
|
4474
|
+
// A stale response is never an acknowledgement for the captured
|
|
4475
|
+
// workspace. In particular, do not persist retry metadata or notify
|
|
4476
|
+
// listeners that may already be observing a newer session.
|
|
4477
|
+
return failureOutcome(failure, error, {
|
|
4478
|
+
stale: true,
|
|
4479
|
+
reason: 'stale_session',
|
|
4480
|
+
identityRejectedCount
|
|
4481
|
+
});
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
|
|
4485
|
+
rememberAuthTransitionFailure(captured, failure);
|
|
4486
|
+
throw error;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4489
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
4490
|
+
return rebaseAfterBootstrapRequired({
|
|
4491
|
+
failure,
|
|
4492
|
+
error,
|
|
4493
|
+
captured,
|
|
4494
|
+
context,
|
|
4495
|
+
timestamp,
|
|
4496
|
+
identityRejectedCount
|
|
4497
|
+
});
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT) {
|
|
4501
|
+
const candidate = clone(workspace);
|
|
4502
|
+
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
4503
|
+
candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
|
|
4504
|
+
readyIds.has(operation.id)
|
|
4505
|
+
? makeRetryOperation(operation, failure, failure.retryAfterMs, timestamp)
|
|
4506
|
+
: operation
|
|
4507
|
+
));
|
|
4508
|
+
candidate.syncMeta = {
|
|
4509
|
+
...candidate.syncMeta,
|
|
4510
|
+
lastSyncAttemptAt: timestamp,
|
|
4511
|
+
lastSyncError: failure.message || failure.reason || failure.code || 'retryable',
|
|
4512
|
+
lastSyncFailure: clone(failure)
|
|
4513
|
+
};
|
|
4514
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4515
|
+
workspace = candidate;
|
|
4516
|
+
notify({
|
|
4517
|
+
type: 'sync_retry',
|
|
4518
|
+
reason: failure.code || failure.reason || 'retryable',
|
|
4519
|
+
failure: clone(failure)
|
|
4520
|
+
});
|
|
4521
|
+
const retryAt = Math.min(...candidate.pendingOperations
|
|
4522
|
+
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
4523
|
+
.map((operation) => Number(operation.nextRetryAt)));
|
|
4524
|
+
return failureOutcome(failure, error, {
|
|
4525
|
+
retryable: true,
|
|
4526
|
+
retryAfterMs: failure.retryAfterMs,
|
|
4527
|
+
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
4528
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4529
|
+
identityRejectedCount
|
|
4530
|
+
});
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
// Only an explicit request/operation-id conflict is eligible for the
|
|
4534
|
+
// existing rejection-rebase path. A bulk operation is deliberately
|
|
4535
|
+
// excluded: quarantining one entry from a bulk job would acknowledge a
|
|
4536
|
+
// request whose server-side transaction did not run.
|
|
4537
|
+
const isBulkBatch = ready.some((operation) => (
|
|
4538
|
+
typeof RTL_BULK_OPERATION_TYPES !== 'undefined' &&
|
|
4539
|
+
RTL_BULK_OPERATION_TYPES.has(operation?.type)
|
|
4540
|
+
));
|
|
4541
|
+
const isOperationConflict = isRecordTimeLabelOperationConflictFailure(value);
|
|
4542
|
+
if (
|
|
4543
|
+
failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL &&
|
|
4544
|
+
isOperationConflict &&
|
|
4545
|
+
!isBulkBatch
|
|
4546
|
+
) {
|
|
4547
|
+
// A conflict rebase is only safe when every revision alias in the
|
|
4548
|
+
// response agrees. Reject malformed/conflicting revision metadata
|
|
4549
|
+
// before calling bootstrap or changing the ready outbox.
|
|
4550
|
+
if (!responseRevisionInfo.valid) {
|
|
4551
|
+
const protocolError = rtlInvalidRevisionError();
|
|
4552
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', protocolError);
|
|
4553
|
+
return persistTerminalFailureBlock({
|
|
4554
|
+
failure,
|
|
4555
|
+
error: protocolError,
|
|
4556
|
+
captured,
|
|
4557
|
+
timestamp,
|
|
4558
|
+
identityRejectedCount,
|
|
4559
|
+
extra: {protocolError: true}
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
let freshBaseline = null;
|
|
4563
|
+
const currentRevisionValue = Number(workspace.remoteBaseline.revision);
|
|
4564
|
+
const currentRevision = Number.isSafeInteger(currentRevisionValue) && currentRevisionValue >= 0
|
|
4565
|
+
? currentRevisionValue
|
|
4566
|
+
: 0;
|
|
4567
|
+
const requiredRevision = Math.max(
|
|
4568
|
+
currentRevision,
|
|
4569
|
+
responseRevisionInfo.revision ?? 0
|
|
4570
|
+
);
|
|
4571
|
+
if (typeof cloud?.bootstrap === 'function') {
|
|
4572
|
+
try {
|
|
4573
|
+
freshBaseline = requireBootstrapRevision(
|
|
4574
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
4575
|
+
context,
|
|
4576
|
+
bootstrapAttemptOptions('rejection-rebase')
|
|
4577
|
+
)),
|
|
4578
|
+
requiredRevision
|
|
4579
|
+
);
|
|
4580
|
+
} catch (bootstrapError) {
|
|
4581
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4582
|
+
rethrowClassifiedCloudFailure(captured, bootstrapError);
|
|
4583
|
+
}
|
|
4584
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4585
|
+
} else if (isAuthoritativeFailureBaseline(
|
|
4586
|
+
value,
|
|
4587
|
+
requiredRevision,
|
|
4588
|
+
responseRevisionInfo.revision
|
|
4589
|
+
)) {
|
|
4590
|
+
freshBaseline = rtlEnvelopePayload(value);
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
// Preserve 1A1's fail-closed contract when no fresh/authoritative
|
|
4594
|
+
// baseline is available: nothing, including outbox metadata, changes.
|
|
4595
|
+
if (!freshBaseline) {
|
|
4596
|
+
return persistTerminalFailureBlock({
|
|
4597
|
+
failure,
|
|
4598
|
+
error,
|
|
4599
|
+
captured,
|
|
4600
|
+
timestamp,
|
|
4601
|
+
identityRejectedCount
|
|
4602
|
+
});
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
const candidate = clone(workspace);
|
|
4606
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
4607
|
+
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
4608
|
+
const rejectionResponse = rtlStripSessionTokens({
|
|
4609
|
+
...((value && typeof value === 'object') ? value : {}),
|
|
4610
|
+
failure: clone(failure)
|
|
4611
|
+
});
|
|
4612
|
+
const nextRejected = {...candidate.rejectedOperations};
|
|
4613
|
+
const rejectedResults = [];
|
|
4614
|
+
const completedOperations = [];
|
|
4615
|
+
ready.forEach((operation) => {
|
|
4616
|
+
const reason = failure.reason || failure.code || 'operation_id_conflict';
|
|
4617
|
+
nextRejected[operation.id] = {
|
|
4618
|
+
id: operation.id,
|
|
4619
|
+
operation: clone(operation),
|
|
4620
|
+
status: 'rejected',
|
|
4621
|
+
reason,
|
|
4622
|
+
rejectedAt: timestamp,
|
|
4623
|
+
response: rejectionResponse
|
|
4624
|
+
};
|
|
4625
|
+
rejectedResults.push({
|
|
4626
|
+
id: operation.id,
|
|
4627
|
+
operationId: operation.id,
|
|
4628
|
+
status: 'rejected',
|
|
4629
|
+
applied: false,
|
|
4630
|
+
reason,
|
|
4631
|
+
code: failure.code,
|
|
4632
|
+
class: failure.class,
|
|
4633
|
+
statusCode: failure.status
|
|
4634
|
+
});
|
|
4635
|
+
completedOperations.push({operation, status: 'rejected'});
|
|
4636
|
+
});
|
|
4637
|
+
candidate.pendingOperations = candidate.pendingOperations.filter((operation) => (
|
|
4638
|
+
!readyIds.has(operation.id)
|
|
4639
|
+
));
|
|
4640
|
+
candidate.rejectedOperations = nextRejected;
|
|
4641
|
+
candidate.syncMeta = {
|
|
4642
|
+
...candidate.syncMeta,
|
|
4643
|
+
lastSyncedAt: timestamp,
|
|
4644
|
+
lastSyncAttemptAt: timestamp,
|
|
4645
|
+
lastSyncError: null,
|
|
4646
|
+
lastSyncFailure: clone(failure),
|
|
4647
|
+
lastAcknowledgedRevision: Math.max(
|
|
4648
|
+
rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
|
|
4649
|
+
Number(candidate.remoteBaseline.revision)
|
|
4650
|
+
)
|
|
4651
|
+
};
|
|
4652
|
+
rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
|
|
4653
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4654
|
+
workspace = candidate;
|
|
4655
|
+
notify({
|
|
4656
|
+
type: 'synced',
|
|
4657
|
+
operations: clone(rejectedResults),
|
|
4658
|
+
failure: clone(failure)
|
|
4659
|
+
});
|
|
4660
|
+
return {
|
|
4661
|
+
success: true,
|
|
4662
|
+
failure: clone(failure),
|
|
4663
|
+
error,
|
|
4664
|
+
class: failure.class,
|
|
4665
|
+
status: failure.status,
|
|
4666
|
+
code: failure.code,
|
|
4667
|
+
rejectedCount: ready.length,
|
|
4668
|
+
syncedCount: 0,
|
|
4669
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4670
|
+
identityRejectedCount
|
|
4671
|
+
};
|
|
4672
|
+
}
|
|
4673
|
+
|
|
4674
|
+
// Generic terminal failures are diagnostics only. In particular, do
|
|
4675
|
+
// not remove an entire ready batch merely because an old gateway returned
|
|
4676
|
+
// a non-retryable envelope. Block the FIFO explicitly so callers cannot
|
|
4677
|
+
// accidentally resend an unlocatable terminal batch forever.
|
|
4678
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
|
|
4679
|
+
return persistTerminalFailureBlock({
|
|
4680
|
+
failure,
|
|
4681
|
+
error,
|
|
4682
|
+
captured,
|
|
4683
|
+
timestamp,
|
|
4684
|
+
identityRejectedCount
|
|
4685
|
+
});
|
|
4686
|
+
};
|
|
4687
|
+
|
|
3942
4688
|
const processRemote = async (remoteValue, captured) => {
|
|
3943
4689
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3944
4690
|
const candidate = clone(workspace);
|
|
3945
4691
|
const remote = rtlEnvelopePayload(remoteValue);
|
|
3946
4692
|
const remoteRevision = Number(remote?.revision);
|
|
3947
|
-
if (!Number.
|
|
3948
|
-
|
|
4693
|
+
if (!Number.isSafeInteger(remoteRevision) || remoteRevision < 0) {
|
|
4694
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4695
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
4696
|
+
code: 'invalid_remote_revision',
|
|
4697
|
+
reason: 'invalid_remote_revision',
|
|
4698
|
+
message: 'invalid_remote_revision'
|
|
4699
|
+
});
|
|
4700
|
+
return {
|
|
4701
|
+
success: false,
|
|
4702
|
+
protocolError: true,
|
|
4703
|
+
reason: 'invalid_remote_revision',
|
|
4704
|
+
failure
|
|
4705
|
+
};
|
|
3949
4706
|
}
|
|
3950
4707
|
if (remoteRevision <= candidate.remoteBaseline.revision) {
|
|
3951
4708
|
return {success: true, ignored: true};
|
|
@@ -3955,7 +4712,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3955
4712
|
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
3956
4713
|
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
3957
4714
|
if (typeof cloud?.bootstrap !== 'function') {
|
|
3958
|
-
|
|
4715
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4716
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4717
|
+
code: 'revision_gap',
|
|
4718
|
+
reason: 'revision_gap',
|
|
4719
|
+
message: 'revision_gap'
|
|
4720
|
+
});
|
|
4721
|
+
return {
|
|
4722
|
+
success: false,
|
|
4723
|
+
reason: 'revision_gap',
|
|
4724
|
+
bootstrapRequired: true,
|
|
4725
|
+
failure,
|
|
4726
|
+
error: toRecordTimeLabelCloudFailureError(failure)
|
|
4727
|
+
};
|
|
3959
4728
|
}
|
|
3960
4729
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3961
4730
|
baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
|
|
@@ -3985,14 +4754,37 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3985
4754
|
state.settled = true;
|
|
3986
4755
|
resolvePromise({
|
|
3987
4756
|
success: true,
|
|
3988
|
-
...(value && typeof value === 'object' ? value : {})
|
|
3989
|
-
generation
|
|
4757
|
+
...(value && typeof value === 'object' ? value : {})
|
|
3990
4758
|
});
|
|
3991
4759
|
},
|
|
3992
4760
|
reject(error) {
|
|
3993
4761
|
if (state.settled) return;
|
|
3994
4762
|
state.settled = true;
|
|
3995
|
-
|
|
4763
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
4764
|
+
let canonicalError = error;
|
|
4765
|
+
if (error instanceof Error) {
|
|
4766
|
+
Object.assign(error, {
|
|
4767
|
+
schemaVersion: failure.schemaVersion,
|
|
4768
|
+
class: failure.class,
|
|
4769
|
+
code: failure.code,
|
|
4770
|
+
reason: failure.reason,
|
|
4771
|
+
status: failure.status,
|
|
4772
|
+
retryable: failure.retryable,
|
|
4773
|
+
retryAfterMs: failure.retryAfterMs,
|
|
4774
|
+
bootstrapRequired: failure.bootstrapRequired,
|
|
4775
|
+
requestId: failure.requestId
|
|
4776
|
+
});
|
|
4777
|
+
if (!error.failure) {
|
|
4778
|
+
Object.defineProperty(error, 'failure', {
|
|
4779
|
+
value: failure,
|
|
4780
|
+
enumerable: false,
|
|
4781
|
+
configurable: true
|
|
4782
|
+
});
|
|
4783
|
+
}
|
|
4784
|
+
} else {
|
|
4785
|
+
canonicalError = toRecordTimeLabelCloudFailureError(failure);
|
|
4786
|
+
}
|
|
4787
|
+
rejectPromise(canonicalError);
|
|
3996
4788
|
}
|
|
3997
4789
|
};
|
|
3998
4790
|
// Readiness is an opt-in host boundary. Keep a rejection observable to a
|
|
@@ -4004,7 +4796,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4004
4796
|
|
|
4005
4797
|
const staleRemoteReady = (state, reason = 'stale_session') => {
|
|
4006
4798
|
if (!state || state.settled) return;
|
|
4007
|
-
|
|
4799
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4800
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
|
|
4801
|
+
code: reason,
|
|
4802
|
+
reason
|
|
4803
|
+
});
|
|
4804
|
+
state.resolve({success: false, stale: true, reason, failure});
|
|
4008
4805
|
};
|
|
4009
4806
|
|
|
4010
4807
|
const isCurrentRemoteReady = (state) => (
|
|
@@ -4022,6 +4819,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4022
4819
|
if (settleReady) staleRemoteReady(remoteReadyState);
|
|
4023
4820
|
};
|
|
4024
4821
|
|
|
4822
|
+
const resetForSessionIdentity = (captured) => {
|
|
4823
|
+
stopCloudSubscription();
|
|
4824
|
+
workspace = rtlEmptyDurableWorkspace({
|
|
4825
|
+
ownerUid: captured?.uid,
|
|
4826
|
+
workspaceEpoch: captured?.workspaceEpoch
|
|
4827
|
+
});
|
|
4828
|
+
initialized = false;
|
|
4829
|
+
hydrationRequired = true;
|
|
4830
|
+
notify({type: 'session_changed'});
|
|
4831
|
+
};
|
|
4832
|
+
|
|
4025
4833
|
const startCloudSubscription = (captured, candidate) => {
|
|
4026
4834
|
stopCloudSubscription();
|
|
4027
4835
|
const readyState = createRemoteReadyState();
|
|
@@ -4035,7 +4843,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4035
4843
|
return;
|
|
4036
4844
|
}
|
|
4037
4845
|
if (typeof cloud?.subscribe !== 'function') {
|
|
4038
|
-
readyState.
|
|
4846
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
4847
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
4848
|
+
code: 'remote_provider_missing',
|
|
4849
|
+
reason: 'remote_provider_missing',
|
|
4850
|
+
message: 'remote_provider_missing'
|
|
4851
|
+
}));
|
|
4039
4852
|
return;
|
|
4040
4853
|
}
|
|
4041
4854
|
const context = rtlSessionContext(captured, candidate, client);
|
|
@@ -4043,24 +4856,64 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4043
4856
|
let subscriptionReturned = false;
|
|
4044
4857
|
let adapterReadyExpected = false;
|
|
4045
4858
|
let firstCallbackResult = null;
|
|
4859
|
+
let firstRootObservation = null;
|
|
4860
|
+
let adapterCatchUpProof = null;
|
|
4861
|
+
const settleReadyIfProven = () => {
|
|
4862
|
+
if (!isCurrentRemoteReady(readyState) || readyState.settled ||
|
|
4863
|
+
!firstRootObservation || (adapterReadyExpected && !adapterCatchUpProof)) {
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
const baselineRevision = Number.isSafeInteger(workspace.remoteBaseline.revision)
|
|
4867
|
+
? workspace.remoteBaseline.revision
|
|
4868
|
+
: 0;
|
|
4869
|
+
const proof = adapterCatchUpProof || {};
|
|
4870
|
+
const revision = Number.isSafeInteger(Number(proof.revision))
|
|
4871
|
+
? Number(proof.revision)
|
|
4872
|
+
: baselineRevision;
|
|
4873
|
+
const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
|
|
4874
|
+
? Number(proof.caughtUpToRevision)
|
|
4875
|
+
: revision;
|
|
4876
|
+
readyState.resolve({
|
|
4877
|
+
sessionKey: proof.sessionKey ??
|
|
4878
|
+
context.sessionKey ??
|
|
4879
|
+
captured.sessionToken ??
|
|
4880
|
+
captured.uid ??
|
|
4881
|
+
null,
|
|
4882
|
+
revision,
|
|
4883
|
+
caughtUpToRevision
|
|
4884
|
+
});
|
|
4885
|
+
};
|
|
4886
|
+
const handleRootObservation = async (result) => {
|
|
4887
|
+
if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
|
|
4888
|
+
if (result?.stale === true || result?.reason === 'stale_session') return;
|
|
4889
|
+
if (result?.success === false) {
|
|
4890
|
+
readyState.reject(toRecordTimeLabelCloudFailureError(
|
|
4891
|
+
result.failure || {
|
|
4892
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
4893
|
+
code: result.reason || 'remote_initial_observation_failed',
|
|
4894
|
+
reason: result.reason || 'remote_initial_observation_failed'
|
|
4895
|
+
}
|
|
4896
|
+
));
|
|
4897
|
+
return;
|
|
4898
|
+
}
|
|
4899
|
+
firstRootObservation = result || {success: true};
|
|
4900
|
+
if (!adapterReadyExpected) {
|
|
4901
|
+
adapterCatchUpProof = {
|
|
4902
|
+
success: true,
|
|
4903
|
+
sessionKey: captured.sessionToken ?? captured.uid ?? null,
|
|
4904
|
+
revision: workspace.remoteBaseline.revision,
|
|
4905
|
+
caughtUpToRevision: workspace.remoteBaseline.revision
|
|
4906
|
+
};
|
|
4907
|
+
}
|
|
4908
|
+
settleReadyIfProven();
|
|
4909
|
+
};
|
|
4046
4910
|
const onRemote = (remoteValue) => {
|
|
4047
4911
|
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
4048
4912
|
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
4049
4913
|
}
|
|
4050
4914
|
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
4051
4915
|
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
4052
|
-
callbackResult.then((result) => {
|
|
4053
|
-
if (
|
|
4054
|
-
subscriptionReturned &&
|
|
4055
|
-
!adapterReadyExpected &&
|
|
4056
|
-
isCurrentRemoteReady(readyState)
|
|
4057
|
-
) {
|
|
4058
|
-
readyState.resolve({
|
|
4059
|
-
revision: workspace.remoteBaseline.revision,
|
|
4060
|
-
result
|
|
4061
|
-
});
|
|
4062
|
-
}
|
|
4063
|
-
}).catch((error) => {
|
|
4916
|
+
callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
|
|
4064
4917
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
4065
4918
|
readyState.reject(error);
|
|
4066
4919
|
}
|
|
@@ -4076,29 +4929,52 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4076
4929
|
logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
|
|
4077
4930
|
return;
|
|
4078
4931
|
}
|
|
4079
|
-
unsubscribeCloud = typeof disposer === 'function'
|
|
4932
|
+
unsubscribeCloud = typeof disposer === 'function'
|
|
4933
|
+
? disposer
|
|
4934
|
+
: (typeof disposer?.unsubscribe === 'function' ? disposer.unsubscribe : null);
|
|
4935
|
+
const onSubscriptionError = (error) => {
|
|
4936
|
+
if (!isCurrentRemoteReady(readyState)) return;
|
|
4937
|
+
stopCloudSubscription({settleReady: false});
|
|
4938
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
4939
|
+
readyState.reject(toRecordTimeLabelCloudFailureError(failure));
|
|
4940
|
+
notify({type: 'remote_error', failure: clone(failure)});
|
|
4941
|
+
};
|
|
4942
|
+
try {
|
|
4943
|
+
if (typeof disposer?.setErrorHandler === 'function') {
|
|
4944
|
+
disposer.setErrorHandler(onSubscriptionError);
|
|
4945
|
+
} else if (typeof disposer?.onError === 'function') {
|
|
4946
|
+
disposer.onError(onSubscriptionError);
|
|
4947
|
+
}
|
|
4948
|
+
} catch (error) {
|
|
4949
|
+
onSubscriptionError(error);
|
|
4950
|
+
return;
|
|
4951
|
+
}
|
|
4080
4952
|
adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
|
|
4081
4953
|
subscriptionReturned = true;
|
|
4082
4954
|
if (adapterReadyExpected) {
|
|
4083
4955
|
Promise.resolve(disposer.ready).then((result) => {
|
|
4084
4956
|
if (!isCurrentRemoteReady(readyState)) return;
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4957
|
+
if (result?.success === false) {
|
|
4958
|
+
readyState.reject(toRecordTimeLabelCloudFailureError(
|
|
4959
|
+
result.failure || {
|
|
4960
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
4961
|
+
code: result.reason || 'remote_initial_observation_failed',
|
|
4962
|
+
reason: result.reason || 'remote_initial_observation_failed'
|
|
4963
|
+
}
|
|
4964
|
+
));
|
|
4965
|
+
} else {
|
|
4966
|
+
adapterCatchUpProof = result && typeof result === 'object'
|
|
4967
|
+
? {...result, success: true}
|
|
4968
|
+
: {success: true};
|
|
4969
|
+
settleReadyIfProven();
|
|
4970
|
+
}
|
|
4089
4971
|
}).catch((error) => {
|
|
4090
4972
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
4091
4973
|
readyState.reject(error);
|
|
4092
4974
|
}
|
|
4093
4975
|
});
|
|
4094
4976
|
} else if (firstCallbackResult) {
|
|
4095
|
-
firstCallbackResult.then((
|
|
4096
|
-
if (!isCurrentRemoteReady(readyState)) return;
|
|
4097
|
-
readyState.resolve({
|
|
4098
|
-
revision: workspace.remoteBaseline.revision,
|
|
4099
|
-
result
|
|
4100
|
-
});
|
|
4101
|
-
}).catch(() => null);
|
|
4977
|
+
firstCallbackResult.then(() => settleReadyIfProven()).catch(() => null);
|
|
4102
4978
|
}
|
|
4103
4979
|
};
|
|
4104
4980
|
|
|
@@ -4114,10 +4990,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4114
4990
|
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
4115
4991
|
const loadedBaseline = loaded?.remoteBaseline;
|
|
4116
4992
|
const committedRevision = Number(loadedBaseline?.revision);
|
|
4117
|
-
const hasValidCommittedBaseline =
|
|
4118
|
-
rtlDurableIsObject(loadedBaseline.state) &&
|
|
4119
|
-
Number.isFinite(committedRevision) &&
|
|
4120
|
-
committedRevision > 0;
|
|
4993
|
+
const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
|
|
4121
4994
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
4122
4995
|
const identityChecked = rtlQuarantineOperations(
|
|
4123
4996
|
candidate,
|
|
@@ -4135,6 +5008,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4135
5008
|
candidate.remoteBaseline.revision === committedRevision &&
|
|
4136
5009
|
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
4137
5010
|
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
5011
|
+
// Do not carry untrusted resume state into a fresh hydration attempt.
|
|
5012
|
+
// Only the bootstrap response may establish the next cursor/marker.
|
|
5013
|
+
candidate.remoteBaseline.changeCursor = null;
|
|
5014
|
+
candidate.syncMeta = {...candidate.syncMeta};
|
|
5015
|
+
['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding'].forEach((key) => {
|
|
5016
|
+
delete candidate.syncMeta[key];
|
|
5017
|
+
});
|
|
4138
5018
|
let bootstrap;
|
|
4139
5019
|
try {
|
|
4140
5020
|
bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
@@ -4147,6 +5027,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4147
5027
|
}
|
|
4148
5028
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4149
5029
|
applyRemoteBaseline(candidate, bootstrap);
|
|
5030
|
+
delete candidate.syncMeta.terminalFailureBlock;
|
|
4150
5031
|
}
|
|
4151
5032
|
|
|
4152
5033
|
// Always persist the normalized durable shape before subscribing. This
|
|
@@ -4191,7 +5072,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4191
5072
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
4192
5073
|
}
|
|
4193
5074
|
const captured = capture();
|
|
5075
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
4194
5076
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5077
|
+
if (!workspaceMatchesSession(captured)) {
|
|
5078
|
+
resetForSessionIdentity(captured);
|
|
5079
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5080
|
+
}
|
|
4195
5081
|
const input = Array.isArray(operations)
|
|
4196
5082
|
? operations
|
|
4197
5083
|
: (operations && typeof operations === 'object' ? [operations] : []);
|
|
@@ -4272,11 +5158,25 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4272
5158
|
};
|
|
4273
5159
|
|
|
4274
5160
|
const syncInternal = async (reason) => {
|
|
5161
|
+
const captured = capture();
|
|
5162
|
+
throwIfAuthTransitionLatched(captured);
|
|
4275
5163
|
if (!initialized || hydrationRequired) {
|
|
4276
5164
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
4277
5165
|
}
|
|
4278
|
-
const captured = capture();
|
|
4279
5166
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
5167
|
+
if (!workspaceMatchesSession(captured)) {
|
|
5168
|
+
resetForSessionIdentity(captured);
|
|
5169
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5170
|
+
}
|
|
5171
|
+
const blockedFailure = workspace.syncMeta?.terminalFailureBlock;
|
|
5172
|
+
if (blockedFailure) {
|
|
5173
|
+
const failure = normalizeRecordTimeLabelCloudFailure(blockedFailure);
|
|
5174
|
+
return failureOutcome(
|
|
5175
|
+
failure,
|
|
5176
|
+
toRecordTimeLabelCloudFailureError(failure),
|
|
5177
|
+
{protocolError: true, blocked: true, identityRejectedCount: 0}
|
|
5178
|
+
);
|
|
5179
|
+
}
|
|
4280
5180
|
const timestamp = now();
|
|
4281
5181
|
const identityChecked = rtlQuarantineOperations(
|
|
4282
5182
|
workspace,
|
|
@@ -4328,7 +5228,22 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4328
5228
|
};
|
|
4329
5229
|
}
|
|
4330
5230
|
if (typeof cloud?.applyOperations !== 'function') {
|
|
4331
|
-
|
|
5231
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5232
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5233
|
+
code: 'missing_cloud_apply_operations',
|
|
5234
|
+
reason: 'missing_cloud_apply_operations',
|
|
5235
|
+
message: 'missing_cloud_apply_operations'
|
|
5236
|
+
});
|
|
5237
|
+
return {
|
|
5238
|
+
success: false,
|
|
5239
|
+
failure,
|
|
5240
|
+
error: toRecordTimeLabelCloudFailureError(failure),
|
|
5241
|
+
class: failure.class,
|
|
5242
|
+
code: failure.code,
|
|
5243
|
+
reason: failure.reason,
|
|
5244
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5245
|
+
identityRejectedCount
|
|
5246
|
+
};
|
|
4332
5247
|
}
|
|
4333
5248
|
const context = rtlSessionContext(captured, workspace, client);
|
|
4334
5249
|
const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
|
|
@@ -4350,59 +5265,79 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4350
5265
|
}, context);
|
|
4351
5266
|
} catch (error) {
|
|
4352
5267
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
};
|
|
5268
|
+
return settleApplyFailure({
|
|
5269
|
+
value: error,
|
|
5270
|
+
captured,
|
|
5271
|
+
context,
|
|
5272
|
+
ready,
|
|
5273
|
+
timestamp,
|
|
5274
|
+
identityRejectedCount
|
|
5275
|
+
});
|
|
4362
5276
|
}
|
|
4363
5277
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4364
5278
|
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
5279
|
+
const hasCanonicalFailure = Boolean(
|
|
5280
|
+
response &&
|
|
5281
|
+
typeof response === 'object' &&
|
|
5282
|
+
Object.prototype.hasOwnProperty.call(response, 'failure') &&
|
|
5283
|
+
response.failure != null
|
|
5284
|
+
);
|
|
5285
|
+
if (hasCanonicalFailure || !rtlEnvelopeSuccess(response)) {
|
|
5286
|
+
// An explicit operationResults array remains a supported legacy
|
|
5287
|
+
// response shape, but a canonical `failure` is authoritative even if a
|
|
5288
|
+
// gateway accidentally includes an empty/partial result array beside it.
|
|
5289
|
+
if (hasCanonicalFailure || !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults')) {
|
|
5290
|
+
return settleApplyFailure({
|
|
5291
|
+
value: response,
|
|
5292
|
+
captured,
|
|
5293
|
+
context,
|
|
5294
|
+
ready,
|
|
5295
|
+
timestamp,
|
|
5296
|
+
identityRejectedCount
|
|
5297
|
+
});
|
|
4372
5298
|
}
|
|
4373
|
-
const retryAfterMs = rtlRetryAfterMs(response);
|
|
4374
|
-
const candidate = clone(workspace);
|
|
4375
|
-
const byId = new Set(ready.map((operation) => operation.id));
|
|
4376
|
-
candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
|
|
4377
|
-
byId.has(operation.id)
|
|
4378
|
-
? makeRetryOperation(operation, response, retryAfterMs, timestamp)
|
|
4379
|
-
: operation
|
|
4380
|
-
));
|
|
4381
|
-
candidate.syncMeta = {
|
|
4382
|
-
...candidate.syncMeta,
|
|
4383
|
-
lastSyncAttemptAt: timestamp,
|
|
4384
|
-
lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
|
|
4385
|
-
};
|
|
4386
|
-
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4387
|
-
workspace = candidate;
|
|
4388
|
-
notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
|
|
4389
|
-
const retryAt = Math.min(...candidate.pendingOperations
|
|
4390
|
-
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
4391
|
-
.map((operation) => Number(operation.nextRetryAt)));
|
|
4392
|
-
return {
|
|
4393
|
-
success: false,
|
|
4394
|
-
retryable: true,
|
|
4395
|
-
retryAfterMs,
|
|
4396
|
-
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
4397
|
-
pendingCount: workspace.pendingOperations.length,
|
|
4398
|
-
identityRejectedCount
|
|
4399
|
-
};
|
|
4400
5299
|
}
|
|
4401
5300
|
|
|
5301
|
+
const responseRevisionInfo = rtlExplicitRevisionInfo(response);
|
|
5302
|
+
if (!responseRevisionInfo.valid) {
|
|
5303
|
+
const protocolError = rtlInvalidRevisionError();
|
|
5304
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5305
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5306
|
+
code: protocolError.code,
|
|
5307
|
+
reason: protocolError.code,
|
|
5308
|
+
message: protocolError.message
|
|
5309
|
+
});
|
|
5310
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
5311
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
5312
|
+
return persistTerminalFailureBlock({
|
|
5313
|
+
failure,
|
|
5314
|
+
error,
|
|
5315
|
+
captured,
|
|
5316
|
+
timestamp,
|
|
5317
|
+
identityRejectedCount,
|
|
5318
|
+
extra: {protocolError: true}
|
|
5319
|
+
});
|
|
5320
|
+
}
|
|
5321
|
+
const responseBaseline = rtlEnvelopePayload(response);
|
|
5322
|
+
const responseRevision = responseRevisionInfo.revision;
|
|
4402
5323
|
const parsed = normalizeOperationResults(response, wireOperations);
|
|
4403
5324
|
if (parsed.error) {
|
|
4404
|
-
|
|
4405
|
-
|
|
5325
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5326
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5327
|
+
code: parsed.error.code || 'protocol_error',
|
|
5328
|
+
reason: parsed.error.reason || parsed.error.code || 'protocol_error',
|
|
5329
|
+
message: parsed.error.message || parsed.error.code || 'protocol_error'
|
|
5330
|
+
});
|
|
5331
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
5332
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
5333
|
+
return persistTerminalFailureBlock({
|
|
5334
|
+
failure,
|
|
5335
|
+
error,
|
|
5336
|
+
captured,
|
|
5337
|
+
timestamp,
|
|
5338
|
+
identityRejectedCount,
|
|
5339
|
+
extra: {protocolError: true}
|
|
5340
|
+
});
|
|
4406
5341
|
}
|
|
4407
5342
|
let sawRetryable = false;
|
|
4408
5343
|
for (const result of parsed.results) {
|
|
@@ -4412,7 +5347,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4412
5347
|
const error = new Error('sync_protocol_fifo_retry_barrier_violation');
|
|
4413
5348
|
error.code = 'sync_protocol_fifo_retry_barrier_violation';
|
|
4414
5349
|
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
4415
|
-
|
|
5350
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5351
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5352
|
+
code: error.code,
|
|
5353
|
+
reason: error.code,
|
|
5354
|
+
message: error.message
|
|
5355
|
+
});
|
|
5356
|
+
return persistTerminalFailureBlock({
|
|
5357
|
+
failure,
|
|
5358
|
+
error,
|
|
5359
|
+
captured,
|
|
5360
|
+
timestamp,
|
|
5361
|
+
identityRejectedCount,
|
|
5362
|
+
extra: {protocolError: true}
|
|
5363
|
+
});
|
|
4416
5364
|
}
|
|
4417
5365
|
}
|
|
4418
5366
|
const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
|
|
@@ -4450,40 +5398,109 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4450
5398
|
};
|
|
4451
5399
|
}
|
|
4452
5400
|
}
|
|
4453
|
-
appliedOperations.forEach((operation) => {
|
|
4454
|
-
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
4455
|
-
applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
|
|
4456
|
-
);
|
|
4457
|
-
});
|
|
4458
|
-
const responseRevision = Number(response.revision ?? response.remoteRevision);
|
|
4459
|
-
const responseBaseline = rtlEnvelopePayload(response);
|
|
4460
5401
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
4461
|
-
|
|
4462
|
-
|
|
5402
|
+
const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
|
|
5403
|
+
Number.isSafeInteger(responseRevision)
|
|
5404
|
+
? {...responseBaseline, revision: responseRevision}
|
|
5405
|
+
: responseBaseline;
|
|
5406
|
+
const currentBaselineRevision = Number(candidate.remoteBaseline.revision);
|
|
5407
|
+
const hasAllAppliedResults = parsed.results.length > 0 &&
|
|
5408
|
+
parsed.results.every((result) => result.status === 'applied');
|
|
5409
|
+
const responseIsStale = Number.isFinite(responseRevision) &&
|
|
5410
|
+
responseRevision <= currentBaselineRevision;
|
|
5411
|
+
const responseIsExactNext = Number.isFinite(responseRevision) &&
|
|
5412
|
+
responseRevision === currentBaselineRevision + 1;
|
|
5413
|
+
const responseIsForwardGap = Number.isFinite(responseRevision) &&
|
|
5414
|
+
responseRevision > currentBaselineRevision + 1;
|
|
5415
|
+
|
|
5416
|
+
const hasAuthoritativeFullBaseline = !responseIsStale &&
|
|
5417
|
+
rtlDurableIsObject(responseState) &&
|
|
5418
|
+
Number.isSafeInteger(responseRevision) &&
|
|
5419
|
+
responseRevision > currentBaselineRevision;
|
|
5420
|
+
if (
|
|
5421
|
+
!responseIsStale &&
|
|
5422
|
+
rejectedCount > 0 &&
|
|
5423
|
+
typeof cloud?.bootstrap !== 'function' &&
|
|
5424
|
+
!hasAuthoritativeFullBaseline
|
|
5425
|
+
) {
|
|
5426
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5427
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
5428
|
+
code: 'recordtimelabel_bootstrap_required',
|
|
5429
|
+
reason: 'recordtimelabel_bootstrap_required',
|
|
5430
|
+
message: 'recordtimelabel_bootstrap_required'
|
|
5431
|
+
});
|
|
5432
|
+
return {
|
|
5433
|
+
success: false,
|
|
5434
|
+
bootstrapRequired: true,
|
|
5435
|
+
failure,
|
|
5436
|
+
error: toRecordTimeLabelCloudFailureError(failure),
|
|
5437
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5438
|
+
identityRejectedCount
|
|
5439
|
+
};
|
|
5440
|
+
}
|
|
5441
|
+
|
|
5442
|
+
// A receipt replay can arrive after realtime has already promoted the
|
|
5443
|
+
// authoritative state. ACK completion is still durable, but replaying
|
|
5444
|
+
// its operations would apply settings/order changes a second time.
|
|
5445
|
+
let requiresAuthoritativeRecovery = (responseIsForwardGap && rejectedCount === 0) ||
|
|
5446
|
+
(responseIsExactNext && !hasAllAppliedResults && rejectedCount === 0);
|
|
5447
|
+
if (
|
|
5448
|
+
responseIsForwardGap &&
|
|
5449
|
+
rejectedCount === 0 &&
|
|
5450
|
+
typeof cloud?.bootstrap !== 'function' &&
|
|
5451
|
+
typeof cloud?.catchUp !== 'function'
|
|
5452
|
+
) {
|
|
5453
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5454
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
5455
|
+
code: 'recordtimelabel_bootstrap_required',
|
|
5456
|
+
reason: 'recordtimelabel_bootstrap_required',
|
|
5457
|
+
message: 'recordtimelabel_bootstrap_required'
|
|
5458
|
+
});
|
|
5459
|
+
return {
|
|
5460
|
+
success: false,
|
|
5461
|
+
bootstrapRequired: true,
|
|
5462
|
+
failure,
|
|
5463
|
+
error: toRecordTimeLabelCloudFailureError(failure),
|
|
5464
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5465
|
+
identityRejectedCount
|
|
5466
|
+
};
|
|
5467
|
+
}
|
|
5468
|
+
if (requiresAuthoritativeRecovery) {
|
|
5469
|
+
const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
|
|
5470
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
5471
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
5472
|
+
} else if (!responseIsStale && rtlDurableIsObject(responseState)) {
|
|
5473
|
+
// A same-transaction full baseline is authoritative at the exact next
|
|
5474
|
+
// revision. Older receipt payloads are intentionally ignored above.
|
|
5475
|
+
applyRemoteBaseline(candidate, responseBaselineForApply);
|
|
4463
5476
|
} else if (
|
|
5477
|
+
!responseIsStale &&
|
|
5478
|
+
responseIsExactNext &&
|
|
4464
5479
|
rejectedCount === 0 &&
|
|
4465
5480
|
!sawRetryable &&
|
|
4466
|
-
|
|
4467
|
-
appliedOperations.length > 0 &&
|
|
4468
|
-
Number.isFinite(responseRevision) &&
|
|
4469
|
-
responseRevision === candidate.remoteBaseline.revision + 1
|
|
5481
|
+
hasAllAppliedResults
|
|
4470
5482
|
) {
|
|
4471
|
-
// The v2
|
|
4472
|
-
//
|
|
4473
|
-
|
|
5483
|
+
// The compact v2 ACK omits state. Promote only a complete all-applied
|
|
5484
|
+
// transaction at exactly the next revision; a gap must be recovered.
|
|
5485
|
+
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
5486
|
+
appliedOperations.reduce(
|
|
5487
|
+
(state, operation) => applyRecordTimeLabelOperation(state, operation),
|
|
5488
|
+
candidate.remoteBaseline.state
|
|
5489
|
+
)
|
|
5490
|
+
);
|
|
4474
5491
|
candidate.remoteBaseline.revision = responseRevision;
|
|
4475
5492
|
if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
|
|
4476
5493
|
candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
|
|
4477
5494
|
}
|
|
4478
|
-
} else if (
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
5495
|
+
} else if (!responseIsStale && !Number.isFinite(responseRevision)) {
|
|
5496
|
+
// Legacy envelopes without a revision have no stale-receipt ordering
|
|
5497
|
+
// signal. Preserve their historical local promotion compatibility.
|
|
5498
|
+
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
5499
|
+
appliedOperations.reduce(
|
|
5500
|
+
(state, operation) => applyRecordTimeLabelOperation(state, operation),
|
|
5501
|
+
candidate.remoteBaseline.state
|
|
5502
|
+
)
|
|
5503
|
+
);
|
|
4487
5504
|
}
|
|
4488
5505
|
candidate.pendingOperations = nextPending;
|
|
4489
5506
|
candidate.rejectedOperations = nextRejected;
|
|
@@ -4654,7 +5671,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4654
5671
|
const engine = {
|
|
4655
5672
|
init() {
|
|
4656
5673
|
return enqueue(async () => {
|
|
4657
|
-
|
|
5674
|
+
const captured = capture();
|
|
5675
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
5676
|
+
if (initialized && !hydrationRequired && workspaceMatchesSession(captured)) {
|
|
5677
|
+
return getSnapshot();
|
|
5678
|
+
}
|
|
5679
|
+
if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
|
|
5680
|
+
resetForSessionIdentity(captured);
|
|
5681
|
+
}
|
|
4658
5682
|
return initialize();
|
|
4659
5683
|
});
|
|
4660
5684
|
},
|
|
@@ -4677,10 +5701,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4677
5701
|
|
|
4678
5702
|
waitForRemoteReady() {
|
|
4679
5703
|
if (destroyed) {
|
|
5704
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5705
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
|
|
5706
|
+
code: 'stale_session',
|
|
5707
|
+
reason: 'stale_session'
|
|
5708
|
+
});
|
|
4680
5709
|
return Promise.resolve({
|
|
4681
5710
|
success: false,
|
|
4682
5711
|
stale: true,
|
|
4683
5712
|
reason: 'stale_session',
|
|
5713
|
+
failure,
|
|
4684
5714
|
generation: remoteReadyState.generation
|
|
4685
5715
|
});
|
|
4686
5716
|
}
|
|
@@ -4884,20 +5914,45 @@ const RTL_BULK_OPERATION_TYPES = new Set([
|
|
|
4884
5914
|
]);
|
|
4885
5915
|
const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
|
|
4886
5916
|
|
|
4887
|
-
const
|
|
4888
|
-
const
|
|
4889
|
-
|
|
4890
|
-
);
|
|
4891
|
-
const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
4892
|
-
operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
|
|
4893
|
-
);
|
|
4894
|
-
const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
|
|
4895
|
-
operation?.payload?.trashEntryId || operation?.payload?.id
|
|
5917
|
+
export const isRecordTimeLabelSafeDocumentId = rtlIsSafeDocumentId;
|
|
5918
|
+
const rtlOwn = (object, key) => Boolean(
|
|
5919
|
+
object && Object.prototype.hasOwnProperty.call(object, key)
|
|
4896
5920
|
);
|
|
5921
|
+
|
|
5922
|
+
const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
|
|
5923
|
+
const rtlFirstPresent = (object, keys) => {
|
|
5924
|
+
for (const key of keys) {
|
|
5925
|
+
if (Object.prototype.hasOwnProperty.call(object || {}, key)) return object[key];
|
|
5926
|
+
}
|
|
5927
|
+
return undefined;
|
|
5928
|
+
};
|
|
5929
|
+
const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5930
|
+
'recordId', 'id'
|
|
5931
|
+
]) ?? operation?.payload?.record?.id ?? null;
|
|
5932
|
+
const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5933
|
+
'folderId', 'id'
|
|
5934
|
+
]) ?? operation?.payload?.folder?.id ?? null;
|
|
5935
|
+
const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
5936
|
+
'trashEntryId', 'id'
|
|
5937
|
+
]) ?? null;
|
|
4897
5938
|
const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
|
|
4898
5939
|
encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
|
|
4899
5940
|
);
|
|
5941
|
+
const rtlGetLifecycleTombstone = (documents, kind, entityId) => {
|
|
5942
|
+
const logicalId = `${kind}:${normalizeId(entityId)}`;
|
|
5943
|
+
const aliases = lifecycleTombstoneKeyAliases(logicalId);
|
|
5944
|
+
const collection = documents?.lifecycleTombstones || {};
|
|
5945
|
+
for (const alias of aliases) {
|
|
5946
|
+
if (rtlOwn(collection, alias)) return collection[alias];
|
|
5947
|
+
}
|
|
5948
|
+
const rootField = kind === 'folder' ? 'deletedFolderTombstones' : 'deletedRecordTombstones';
|
|
5949
|
+
const rootCollection = documents?.root?.[rootField] || {};
|
|
5950
|
+
return rtlOwn(rootCollection, normalizeId(entityId))
|
|
5951
|
+
? rootCollection[normalizeId(entityId)]
|
|
5952
|
+
: null;
|
|
5953
|
+
};
|
|
4900
5954
|
const rtlCloneDocuments = (documents = {}) => ({
|
|
5955
|
+
...clone(documents || {}),
|
|
4901
5956
|
root: clone(documents.root || null),
|
|
4902
5957
|
records: clone(documents.records || {}),
|
|
4903
5958
|
folders: clone(documents.folders || {}),
|
|
@@ -4906,6 +5961,24 @@ const rtlCloneDocuments = (documents = {}) => ({
|
|
|
4906
5961
|
ops: {}
|
|
4907
5962
|
});
|
|
4908
5963
|
const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
|
|
5964
|
+
const rtlIsSafeOpaqueGroupId = (value) => (
|
|
5965
|
+
typeof value === 'string' && value.length > 0 &&
|
|
5966
|
+
new TextEncoder().encode(value).byteLength <= RECORD_TIMELABEL_SAFE_ID_MAX_BYTES &&
|
|
5967
|
+
!/[\u0000-\u001F\u007F-\u009F]/u.test(value)
|
|
5968
|
+
);
|
|
5969
|
+
const rtlHasUnsafeOrderId = (order, {opaqueGroup = false} = {}) => toArray(order).some((id) => (
|
|
5970
|
+
opaqueGroup ? !rtlIsSafeOpaqueGroupId(id) : !rtlIsSafeDocumentId(id)
|
|
5971
|
+
));
|
|
5972
|
+
const rtlMergeOpaqueOrder = (...orders) => {
|
|
5973
|
+
const seen = new Set();
|
|
5974
|
+
const result = [];
|
|
5975
|
+
orders.flatMap((order) => toArray(order)).forEach((id) => {
|
|
5976
|
+
if (!rtlIsSafeOpaqueGroupId(id) || seen.has(id)) return;
|
|
5977
|
+
seen.add(id);
|
|
5978
|
+
result.push(id);
|
|
5979
|
+
});
|
|
5980
|
+
return result;
|
|
5981
|
+
};
|
|
4909
5982
|
/**
|
|
4910
5983
|
* Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
|
|
4911
5984
|
* so the planner must not drop ids on a prefix guess either.
|
|
@@ -4934,7 +6007,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
|
|
|
4934
6007
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
4935
6008
|
return;
|
|
4936
6009
|
}
|
|
4937
|
-
if (value.some((id) => !
|
|
6010
|
+
if (value.some((id) => !rtlIsSafeDocumentId(id))) {
|
|
4938
6011
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
4939
6012
|
}
|
|
4940
6013
|
};
|
|
@@ -5029,9 +6102,10 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
5029
6102
|
export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
|
|
5030
6103
|
const errors = [];
|
|
5031
6104
|
const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
|
|
5032
|
-
toArray(body?.client?.capabilities).
|
|
5033
|
-
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
5034
|
-
|
|
6105
|
+
toArray(body?.client?.capabilities).some((capability) => (
|
|
6106
|
+
capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE ||
|
|
6107
|
+
capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1
|
|
6108
|
+
));
|
|
5035
6109
|
let requestBytes = 0;
|
|
5036
6110
|
try {
|
|
5037
6111
|
requestBytes = rtlByteLength(JSON.stringify(body));
|
|
@@ -5042,7 +6116,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
5042
6116
|
if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
|
|
5043
6117
|
errors.push('unsupported_protocol_version');
|
|
5044
6118
|
}
|
|
5045
|
-
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(
|
|
6119
|
+
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(body.requestId) ||
|
|
6120
|
+
!rtlIsSafeDocumentId(body.requestId)) {
|
|
5046
6121
|
errors.push('invalid_request_id');
|
|
5047
6122
|
}
|
|
5048
6123
|
if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
|
|
@@ -5056,8 +6131,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
5056
6131
|
}
|
|
5057
6132
|
const operationIds = new Set();
|
|
5058
6133
|
operations.forEach((operation, index) => {
|
|
5059
|
-
const id =
|
|
5060
|
-
if (!id || id.length > 200) errors.push(`operation_${index}_invalid_id`);
|
|
6134
|
+
const id = operation?.id;
|
|
6135
|
+
if (!rtlIsSafeDocumentId(id) || id.length > 200) errors.push(`operation_${index}_invalid_id`);
|
|
5061
6136
|
if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
|
|
5062
6137
|
operationIds.add(id);
|
|
5063
6138
|
if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
|
|
@@ -5083,12 +6158,16 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
5083
6158
|
if (!toFiniteTimestamp(operation?.createdAt)) {
|
|
5084
6159
|
errors.push(`operation_${index}_invalid_created_at`);
|
|
5085
6160
|
}
|
|
5086
|
-
if (rtlOperationRecordId(operation)
|
|
6161
|
+
if (rtlOperationRecordId(operation) && !rtlIsSafeDocumentId(rtlOperationRecordId(operation))) {
|
|
5087
6162
|
errors.push(`operation_${index}_invalid_record_id`);
|
|
5088
6163
|
}
|
|
5089
|
-
if (rtlOperationFolderId(operation)
|
|
6164
|
+
if (rtlOperationFolderId(operation) && !rtlIsSafeDocumentId(rtlOperationFolderId(operation))) {
|
|
5090
6165
|
errors.push(`operation_${index}_invalid_folder_id`);
|
|
5091
6166
|
}
|
|
6167
|
+
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
6168
|
+
if (trashEntryId && !rtlIsSafeDocumentId(trashEntryId)) {
|
|
6169
|
+
errors.push(`operation_${index}_invalid_trash_entry_id`);
|
|
6170
|
+
}
|
|
5092
6171
|
});
|
|
5093
6172
|
|
|
5094
6173
|
return {ok: errors.length === 0, errors, requestBytes, operations};
|
|
@@ -5124,13 +6203,35 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
5124
6203
|
if (recordId) {
|
|
5125
6204
|
recordIds.add(recordId);
|
|
5126
6205
|
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
6206
|
+
trashEntryIds.add(normalizeId(payload.trashEntryId) || `record:${recordId}`);
|
|
5127
6207
|
}
|
|
5128
6208
|
break;
|
|
5129
6209
|
}
|
|
5130
6210
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
6211
|
+
case OPERATION_TYPES.FOLDER_RESTORE:
|
|
5131
6212
|
case OPERATION_TYPES.TRASH_PURGE: {
|
|
5132
6213
|
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
5133
6214
|
if (trashEntryId) trashEntryIds.add(trashEntryId);
|
|
6215
|
+
const explicitEntityId = normalizeId(
|
|
6216
|
+
payload.recordId || payload.folderId || payload.entityId
|
|
6217
|
+
);
|
|
6218
|
+
const inferredKind = operation.type === OPERATION_TYPES.FOLDER_RESTORE
|
|
6219
|
+
? 'folder'
|
|
6220
|
+
: operation.type === OPERATION_TYPES.RECORD_RESTORE
|
|
6221
|
+
? 'record'
|
|
6222
|
+
: null;
|
|
6223
|
+
let decodedTrashEntryId = trashEntryId;
|
|
6224
|
+
try { decodedTrashEntryId = decodeURIComponent(trashEntryId || ''); } catch { /* keep raw */ }
|
|
6225
|
+
const inferredEntityId = explicitEntityId || (
|
|
6226
|
+
inferredKind && decodedTrashEntryId?.startsWith(`${inferredKind}:`)
|
|
6227
|
+
? normalizeId(decodedTrashEntryId.slice(inferredKind.length + 1))
|
|
6228
|
+
: ''
|
|
6229
|
+
);
|
|
6230
|
+
if (inferredKind && inferredEntityId) {
|
|
6231
|
+
lifecycleTombstoneIds.add(
|
|
6232
|
+
rtlLifecycleTombstoneDocumentId(inferredKind, inferredEntityId)
|
|
6233
|
+
);
|
|
6234
|
+
}
|
|
5134
6235
|
break;
|
|
5135
6236
|
}
|
|
5136
6237
|
case OPERATION_TYPES.RECORD_REORDER:
|
|
@@ -5219,7 +6320,8 @@ const rtlApplyOperationToPartialDocuments = ({
|
|
|
5219
6320
|
|
|
5220
6321
|
const rtlCanRestoreLocalFolder = (root, folder) => {
|
|
5221
6322
|
if (!folder?.id) return false;
|
|
5222
|
-
const
|
|
6323
|
+
const tombstones = root?.deletedFolderTombstones;
|
|
6324
|
+
const tombstone = rtlOwn(tombstones, folder.id) ? tombstones[folder.id] : null;
|
|
5223
6325
|
return !tombstone;
|
|
5224
6326
|
};
|
|
5225
6327
|
|
|
@@ -5230,8 +6332,9 @@ const rtlBuildLocalFolderDocument = (folder, now) => {
|
|
|
5230
6332
|
}, {now}).folders[folder.id] || null;
|
|
5231
6333
|
};
|
|
5232
6334
|
|
|
5233
|
-
const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
5234
|
-
if (folders
|
|
6335
|
+
const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowContext = false}) => {
|
|
6336
|
+
if (rtlOwn(folders, folderId)) return folders[folderId];
|
|
6337
|
+
if (!allowContext) return null;
|
|
5235
6338
|
const localFolder = toArray(localState?.folders)
|
|
5236
6339
|
.find((folder) => folder?.id === folderId);
|
|
5237
6340
|
if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
|
|
@@ -5246,7 +6349,19 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
|
5246
6349
|
|
|
5247
6350
|
const rtlSetRoot = (target, source) => {
|
|
5248
6351
|
Object.keys(target).forEach((key) => delete target[key]);
|
|
5249
|
-
|
|
6352
|
+
const next = clone(source || {});
|
|
6353
|
+
// `Object.assign` invokes the legacy `__proto__` setter on a normal target.
|
|
6354
|
+
// Define each own key as data instead, so malformed legacy/root documents
|
|
6355
|
+
// cannot mutate the candidate's prototype while the planner is rebuilding
|
|
6356
|
+
// its immutable document map.
|
|
6357
|
+
Reflect.ownKeys(next).forEach((key) => {
|
|
6358
|
+
Object.defineProperty(target, key, {
|
|
6359
|
+
value: next[key],
|
|
6360
|
+
enumerable: true,
|
|
6361
|
+
configurable: true,
|
|
6362
|
+
writable: true
|
|
6363
|
+
});
|
|
6364
|
+
});
|
|
5250
6365
|
};
|
|
5251
6366
|
|
|
5252
6367
|
/**
|
|
@@ -5257,22 +6372,48 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5257
6372
|
operations,
|
|
5258
6373
|
localState = {},
|
|
5259
6374
|
now = Date.now(),
|
|
5260
|
-
requireLifecycleGeneration = true
|
|
6375
|
+
requireLifecycleGeneration = true,
|
|
6376
|
+
plannerMode = RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT
|
|
5261
6377
|
} = {}) => {
|
|
6378
|
+
const selectedPlannerMode = plannerMode === RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
|
|
6379
|
+
? RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
|
|
6380
|
+
: RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
|
|
6381
|
+
const allowContext = selectedPlannerMode === RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
|
|
5262
6382
|
const previousDocuments = rtlCloneDocuments(documents);
|
|
5263
6383
|
const nextDocuments = rtlCloneDocuments(documents);
|
|
5264
6384
|
const preservedExpandedGroups = previousDocuments.root &&
|
|
5265
6385
|
Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
|
|
5266
6386
|
? clone(previousDocuments.root.expandedGroups)
|
|
5267
6387
|
: undefined;
|
|
5268
|
-
|
|
6388
|
+
// The deterministic planner deliberately does not inspect mutable local
|
|
6389
|
+
// context. Keep the old context-assisted behaviour behind an explicit
|
|
6390
|
+
// legacy mode so rolling clients can continue to use it while gateways
|
|
6391
|
+
// migrate to the stricter input contract.
|
|
6392
|
+
const normalizedLocalState = allowContext ? normalizeState(localState || {}) : {};
|
|
5269
6393
|
const operationResults = [];
|
|
5270
6394
|
let changed = false;
|
|
5271
6395
|
if (!nextDocuments.root) {
|
|
5272
|
-
return {
|
|
6396
|
+
return {
|
|
6397
|
+
requiresMigration: true,
|
|
6398
|
+
plannerMode: selectedPlannerMode,
|
|
6399
|
+
previousDocuments,
|
|
6400
|
+
nextDocuments,
|
|
6401
|
+
operationResults
|
|
6402
|
+
};
|
|
5273
6403
|
}
|
|
5274
6404
|
|
|
5275
6405
|
for (const operation of toArray(operations).filter(Boolean)) {
|
|
6406
|
+
const operationId = operation?.id;
|
|
6407
|
+
if (!rtlIsSafeDocumentId(operationId) || operationId.length > 200) {
|
|
6408
|
+
operationResults.push({
|
|
6409
|
+
id: operationId ?? null,
|
|
6410
|
+
type: operation?.type ?? null,
|
|
6411
|
+
applied: false,
|
|
6412
|
+
status: 'rejected',
|
|
6413
|
+
reason: 'invalid_document_id'
|
|
6414
|
+
});
|
|
6415
|
+
continue;
|
|
6416
|
+
}
|
|
5276
6417
|
if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
5277
6418
|
operationResults.push({
|
|
5278
6419
|
id: operation.id || null,
|
|
@@ -5287,21 +6428,33 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5287
6428
|
const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
|
|
5288
6429
|
const recordId = rtlOperationRecordId(operation);
|
|
5289
6430
|
const folderId = rtlOperationFolderId(operation);
|
|
6431
|
+
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
5290
6432
|
let applied = null;
|
|
5291
6433
|
let reason = null;
|
|
5292
6434
|
let lifecycleTombstoneId = null;
|
|
5293
6435
|
|
|
5294
|
-
|
|
6436
|
+
// Never use prototype-inherited values as document snapshots. Apart
|
|
6437
|
+
// from being incorrect for IDs such as "constructor", doing so would
|
|
6438
|
+
// allow user-controlled Firestore IDs to reach unsafe map writes.
|
|
6439
|
+
if ((recordId && !rtlIsSafeDocumentId(recordId)) ||
|
|
6440
|
+
(folderId && !rtlIsSafeDocumentId(folderId)) ||
|
|
6441
|
+
(trashEntryId && !rtlIsSafeDocumentId(trashEntryId))) {
|
|
6442
|
+
reason = 'invalid_document_id';
|
|
6443
|
+
}
|
|
6444
|
+
|
|
6445
|
+
switch (reason ? null : operation.type) {
|
|
5295
6446
|
case OPERATION_TYPES.RECORD_CREATE: {
|
|
5296
6447
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
5297
|
-
if (nextDocuments.records
|
|
6448
|
+
if (rtlOwn(nextDocuments.records, recordId)) { reason = 'already_exists'; break; }
|
|
5298
6449
|
const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
|
|
5299
6450
|
applied = rtlApplyOperationToPartialDocuments({
|
|
5300
6451
|
...nextDocuments,
|
|
5301
6452
|
operation: {...operation, payload: {...payload, folderId: targetFolderId}},
|
|
5302
6453
|
now: operationNow
|
|
5303
6454
|
});
|
|
5304
|
-
const recordDocument = applied.documents.records
|
|
6455
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6456
|
+
? applied.documents.records[recordId]
|
|
6457
|
+
: null;
|
|
5305
6458
|
if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
|
|
5306
6459
|
const candidateRoot = clone(applied.documents.root);
|
|
5307
6460
|
const candidateFolders = clone(nextDocuments.folders);
|
|
@@ -5310,7 +6463,8 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5310
6463
|
folders: candidateFolders,
|
|
5311
6464
|
localState: normalizedLocalState,
|
|
5312
6465
|
folderId: targetFolderId,
|
|
5313
|
-
now: operationNow
|
|
6466
|
+
now: operationNow,
|
|
6467
|
+
allowContext
|
|
5314
6468
|
});
|
|
5315
6469
|
if (!targetFolder) {
|
|
5316
6470
|
reason = 'folder_not_found';
|
|
@@ -5324,13 +6478,17 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5324
6478
|
break;
|
|
5325
6479
|
}
|
|
5326
6480
|
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
5327
|
-
const existing = nextDocuments.records
|
|
6481
|
+
const existing = rtlOwn(nextDocuments.records, recordId)
|
|
6482
|
+
? nextDocuments.records[recordId]
|
|
6483
|
+
: null;
|
|
5328
6484
|
if (!recordId || !existing) {
|
|
5329
6485
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
5330
6486
|
break;
|
|
5331
6487
|
}
|
|
5332
6488
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5333
|
-
const recordDocument = applied.documents.records
|
|
6489
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6490
|
+
? applied.documents.records[recordId]
|
|
6491
|
+
: null;
|
|
5334
6492
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
5335
6493
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5336
6494
|
nextDocuments.records[recordId] = recordDocument;
|
|
@@ -5338,7 +6496,9 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5338
6496
|
break;
|
|
5339
6497
|
}
|
|
5340
6498
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
5341
|
-
const existing = nextDocuments.records
|
|
6499
|
+
const existing = rtlOwn(nextDocuments.records, recordId)
|
|
6500
|
+
? nextDocuments.records[recordId]
|
|
6501
|
+
: null;
|
|
5342
6502
|
if (!recordId || !existing) {
|
|
5343
6503
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
5344
6504
|
break;
|
|
@@ -5350,7 +6510,9 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5350
6510
|
operation: {...operation, payload: {...payload, targetFolderId}},
|
|
5351
6511
|
now: operationNow
|
|
5352
6512
|
});
|
|
5353
|
-
const recordDocument = applied.documents.records
|
|
6513
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6514
|
+
? applied.documents.records[recordId]
|
|
6515
|
+
: null;
|
|
5354
6516
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
5355
6517
|
const candidateRoot = clone(applied.documents.root);
|
|
5356
6518
|
const candidateFolders = clone(nextDocuments.folders);
|
|
@@ -5359,10 +6521,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5359
6521
|
folders: candidateFolders,
|
|
5360
6522
|
localState: normalizedLocalState,
|
|
5361
6523
|
folderId: targetFolderId,
|
|
5362
|
-
now: operationNow
|
|
6524
|
+
now: operationNow,
|
|
6525
|
+
allowContext
|
|
5363
6526
|
});
|
|
5364
6527
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
5365
|
-
if (candidateFolders
|
|
6528
|
+
if (rtlOwn(candidateFolders, sourceFolderId)) {
|
|
5366
6529
|
candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
5367
6530
|
candidateFolders[sourceFolderId].recordOrder
|
|
5368
6531
|
).filter((id) => id !== recordId);
|
|
@@ -5379,8 +6542,14 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5379
6542
|
}
|
|
5380
6543
|
case OPERATION_TYPES.RECORD_REORDER: {
|
|
5381
6544
|
const targetFolderId = safeFolderId(payload.folderId);
|
|
5382
|
-
const targetFolder = nextDocuments.folders
|
|
6545
|
+
const targetFolder = rtlOwn(nextDocuments.folders, targetFolderId)
|
|
6546
|
+
? nextDocuments.folders[targetFolderId]
|
|
6547
|
+
: null;
|
|
5383
6548
|
const requested = rtlPayloadOrder(operation, ['recordIds']);
|
|
6549
|
+
if (rtlHasUnsafeOrderId(requested)) {
|
|
6550
|
+
reason = 'invalid_document_id';
|
|
6551
|
+
break;
|
|
6552
|
+
}
|
|
5384
6553
|
if (!targetFolder || requested.length === 0) {
|
|
5385
6554
|
reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
|
|
5386
6555
|
break;
|
|
@@ -5394,14 +6563,50 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5394
6563
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
5395
6564
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
5396
6565
|
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
|
|
5397
|
-
const
|
|
6566
|
+
const existingRecord = rtlOwn(nextDocuments.records, recordId)
|
|
6567
|
+
? nextDocuments.records[recordId]
|
|
6568
|
+
: null;
|
|
6569
|
+
const operationTrashEntryId = trashEntryId || `record:${recordId}`;
|
|
6570
|
+
const existingTrashEntry = rtlOwn(nextDocuments.trash, operationTrashEntryId)
|
|
6571
|
+
? nextDocuments.trash[operationTrashEntryId]
|
|
6572
|
+
: null;
|
|
6573
|
+
const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
|
|
6574
|
+
if (!existingRecord) {
|
|
6575
|
+
const hasTrash = Boolean(existingTrashEntry);
|
|
6576
|
+
const hasTombstone = Boolean(existingTombstone);
|
|
6577
|
+
const trashGeneration = Number(existingTrashEntry?.lifecycleGeneration || 0);
|
|
6578
|
+
const tombstoneGeneration = Number(existingTombstone?.lifecycleGeneration || 0);
|
|
6579
|
+
const validTrashGeneration = Number.isSafeInteger(trashGeneration) && trashGeneration > 0;
|
|
6580
|
+
const validTombstoneGeneration = Number.isSafeInteger(tombstoneGeneration) &&
|
|
6581
|
+
tombstoneGeneration > 0;
|
|
6582
|
+
if (hasTrash && hasTombstone) {
|
|
6583
|
+
reason = validTrashGeneration && validTombstoneGeneration &&
|
|
6584
|
+
trashGeneration === tombstoneGeneration
|
|
6585
|
+
? 'already_deleted'
|
|
6586
|
+
: 'lifecycle_conflict';
|
|
6587
|
+
} else if (hasTombstone) {
|
|
6588
|
+
reason = validTombstoneGeneration ? 'already_deleted' : 'lifecycle_conflict';
|
|
6589
|
+
} else if (hasTrash) {
|
|
6590
|
+
reason = 'lifecycle_conflict';
|
|
6591
|
+
} else {
|
|
6592
|
+
reason = 'record_not_found';
|
|
6593
|
+
}
|
|
6594
|
+
break;
|
|
6595
|
+
}
|
|
6596
|
+
if (existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
6597
|
+
Number(existingRecord.lifecycleGeneration || 0)) {
|
|
6598
|
+
reason = 'already_deleted';
|
|
6599
|
+
break;
|
|
6600
|
+
}
|
|
6601
|
+
const sourceFolderId = safeFolderId(existingRecord.folderId);
|
|
5398
6602
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5399
6603
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5400
|
-
const
|
|
5401
|
-
|
|
5402
|
-
|
|
6604
|
+
const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
|
|
6605
|
+
? applied.documents.trash[operationTrashEntryId]
|
|
6606
|
+
: null;
|
|
6607
|
+
if (trashDocument) nextDocuments.trash[operationTrashEntryId] = trashDocument;
|
|
5403
6608
|
delete nextDocuments.records[recordId];
|
|
5404
|
-
if (nextDocuments.folders
|
|
6609
|
+
if (rtlOwn(nextDocuments.folders, sourceFolderId)) {
|
|
5405
6610
|
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
5406
6611
|
nextDocuments.folders[sourceFolderId].recordOrder
|
|
5407
6612
|
).filter((id) => id !== recordId);
|
|
@@ -5410,8 +6615,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5410
6615
|
break;
|
|
5411
6616
|
}
|
|
5412
6617
|
case OPERATION_TYPES.RECORD_RESTORE: {
|
|
5413
|
-
const
|
|
5414
|
-
const trashEntry = nextDocuments.trash
|
|
6618
|
+
const restoreTrashEntryId = trashEntryId;
|
|
6619
|
+
const trashEntry = rtlOwn(nextDocuments.trash, restoreTrashEntryId)
|
|
6620
|
+
? nextDocuments.trash[restoreTrashEntryId]
|
|
6621
|
+
: null;
|
|
5415
6622
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
5416
6623
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
5417
6624
|
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
@@ -5427,11 +6634,23 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5427
6634
|
break;
|
|
5428
6635
|
}
|
|
5429
6636
|
const restoredRecordId = normalizeId(trashEntry.entityId);
|
|
5430
|
-
if (!restoredRecordId || nextDocuments.records
|
|
6637
|
+
if (!restoredRecordId || rtlOwn(nextDocuments.records, restoredRecordId)) {
|
|
5431
6638
|
reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
|
|
5432
6639
|
break;
|
|
5433
6640
|
}
|
|
5434
6641
|
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
|
|
6642
|
+
if (requireLifecycleGeneration !== false) {
|
|
6643
|
+
const lifecycleTombstone = rtlGetLifecycleTombstone(
|
|
6644
|
+
nextDocuments,
|
|
6645
|
+
'record',
|
|
6646
|
+
restoredRecordId
|
|
6647
|
+
);
|
|
6648
|
+
if (!lifecycleTombstone ||
|
|
6649
|
+
Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
|
|
6650
|
+
reason = 'lifecycle_conflict';
|
|
6651
|
+
break;
|
|
6652
|
+
}
|
|
6653
|
+
}
|
|
5435
6654
|
// Explicit legacy compatibility is planner-local. It does not change the
|
|
5436
6655
|
// original operation, its wire bytes, or the strict durable reducer contract.
|
|
5437
6656
|
const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
|
|
@@ -5442,11 +6661,16 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5442
6661
|
operation: reducerOperation,
|
|
5443
6662
|
now: operationNow
|
|
5444
6663
|
});
|
|
5445
|
-
const restoredRecord = applied.documents.records
|
|
6664
|
+
const restoredRecord = rtlOwn(applied.documents.records, restoredRecordId)
|
|
6665
|
+
? applied.documents.records[restoredRecordId]
|
|
6666
|
+
: null;
|
|
5446
6667
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
5447
6668
|
const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
|
|
5448
|
-
const targetFolder = nextDocuments.folders
|
|
5449
|
-
nextDocuments.folders[
|
|
6669
|
+
const targetFolder = (rtlOwn(nextDocuments.folders, targetFolderId)
|
|
6670
|
+
? nextDocuments.folders[targetFolderId]
|
|
6671
|
+
: null) || (rtlOwn(nextDocuments.folders, DEFAULT_FOLDER_ID)
|
|
6672
|
+
? nextDocuments.folders[DEFAULT_FOLDER_ID]
|
|
6673
|
+
: null);
|
|
5450
6674
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
5451
6675
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5452
6676
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
@@ -5462,8 +6686,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5462
6686
|
break;
|
|
5463
6687
|
}
|
|
5464
6688
|
case OPERATION_TYPES.TRASH_PURGE: {
|
|
5465
|
-
const
|
|
5466
|
-
const trashEntry = nextDocuments.trash
|
|
6689
|
+
const purgeTrashEntryId = trashEntryId;
|
|
6690
|
+
const trashEntry = rtlOwn(nextDocuments.trash, purgeTrashEntryId)
|
|
6691
|
+
? nextDocuments.trash[purgeTrashEntryId]
|
|
6692
|
+
: null;
|
|
5467
6693
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
5468
6694
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
5469
6695
|
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
@@ -5474,20 +6700,24 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5474
6700
|
reason = 'lifecycle_conflict';
|
|
5475
6701
|
break;
|
|
5476
6702
|
}
|
|
5477
|
-
delete nextDocuments.trash[
|
|
6703
|
+
delete nextDocuments.trash[purgeTrashEntryId];
|
|
5478
6704
|
changed = true;
|
|
5479
6705
|
break;
|
|
5480
6706
|
}
|
|
5481
6707
|
case OPERATION_TYPES.FOLDER_CREATE:
|
|
5482
6708
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
5483
6709
|
if (!folderId) { reason = 'missing_folder_id'; break; }
|
|
5484
|
-
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders
|
|
6710
|
+
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !rtlOwn(nextDocuments.folders, folderId)) {
|
|
5485
6711
|
reason = 'folder_not_found';
|
|
5486
6712
|
break;
|
|
5487
6713
|
}
|
|
5488
|
-
const previousOrder = nextDocuments.folders
|
|
6714
|
+
const previousOrder = rtlOwn(nextDocuments.folders, folderId)
|
|
6715
|
+
? nextDocuments.folders[folderId]?.recordOrder || []
|
|
6716
|
+
: [];
|
|
5489
6717
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5490
|
-
const folderDocument = applied.documents.folders
|
|
6718
|
+
const folderDocument = rtlOwn(applied.documents.folders, folderId)
|
|
6719
|
+
? applied.documents.folders[folderId]
|
|
6720
|
+
: null;
|
|
5491
6721
|
if (!folderDocument) { reason = 'folder_not_found'; break; }
|
|
5492
6722
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5493
6723
|
nextDocuments.folders[folderId] = {
|
|
@@ -5507,27 +6737,37 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5507
6737
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
5508
6738
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
5509
6739
|
case OPERATION_TYPES.SETTINGS_UPDATE: {
|
|
6740
|
+
if (operation.type === OPERATION_TYPES.FOLDER_REORDER &&
|
|
6741
|
+
rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['folderOrder', 'order']))) {
|
|
6742
|
+
reason = 'invalid_document_id';
|
|
6743
|
+
break;
|
|
6744
|
+
}
|
|
6745
|
+
if (operation.type === OPERATION_TYPES.GROUP_REORDER &&
|
|
6746
|
+
rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['groupOrder', 'order']), {opaqueGroup: true})) {
|
|
6747
|
+
reason = 'invalid_document_id';
|
|
6748
|
+
break;
|
|
6749
|
+
}
|
|
5510
6750
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5511
6751
|
const appliedRoot = applied.documents.root;
|
|
5512
6752
|
if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
|
|
5513
6753
|
appliedRoot.folderOrder = rtlMergeOrder(
|
|
5514
6754
|
rtlPayloadOrder(operation, ['folderOrder', 'order']),
|
|
5515
6755
|
nextDocuments.root.folderOrder,
|
|
5516
|
-
normalizedLocalState.folderOrder
|
|
6756
|
+
...(allowContext ? [normalizedLocalState.folderOrder] : [])
|
|
5517
6757
|
);
|
|
5518
6758
|
} else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
|
|
5519
|
-
appliedRoot.groupOrder =
|
|
6759
|
+
appliedRoot.groupOrder = rtlMergeOpaqueOrder(
|
|
5520
6760
|
rtlPayloadOrder(operation, ['groupOrder', 'order']),
|
|
5521
6761
|
nextDocuments.root.groupOrder,
|
|
5522
|
-
normalizedLocalState.groupOrder
|
|
5523
|
-
)
|
|
6762
|
+
...(allowContext ? [normalizedLocalState.groupOrder] : [])
|
|
6763
|
+
);
|
|
5524
6764
|
}
|
|
5525
6765
|
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
5526
6766
|
changed = true;
|
|
5527
6767
|
break;
|
|
5528
6768
|
}
|
|
5529
6769
|
default:
|
|
5530
|
-
reason = 'unsupported_operation';
|
|
6770
|
+
if (!reason) reason = 'unsupported_operation';
|
|
5531
6771
|
}
|
|
5532
6772
|
if (applied && !reason && lifecycleTombstoneId) {
|
|
5533
6773
|
const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
|
|
@@ -5541,6 +6781,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5541
6781
|
id: operation.id || null,
|
|
5542
6782
|
type: operation.type || null,
|
|
5543
6783
|
applied: !reason,
|
|
6784
|
+
...(reason === 'already_deleted' ? {status: 'noop'} : {}),
|
|
5544
6785
|
reason
|
|
5545
6786
|
});
|
|
5546
6787
|
}
|
|
@@ -5562,6 +6803,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5562
6803
|
});
|
|
5563
6804
|
return {
|
|
5564
6805
|
requiresMigration: false,
|
|
6806
|
+
plannerMode: selectedPlannerMode,
|
|
5565
6807
|
previousDocuments,
|
|
5566
6808
|
nextDocuments,
|
|
5567
6809
|
changes,
|
|
@@ -5569,6 +6811,19 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5569
6811
|
};
|
|
5570
6812
|
};
|
|
5571
6813
|
|
|
6814
|
+
// Dedicated API boundary for gateways: deterministic planning cannot inspect
|
|
6815
|
+
// mutable host/local context and always uses the immutable-ID contract.
|
|
6816
|
+
export const planRecordTimeLabelDeterministicOperationChanges = (options = {}) => (
|
|
6817
|
+
planFirestoreV2OperationChanges({
|
|
6818
|
+
...options,
|
|
6819
|
+
plannerMode: RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC,
|
|
6820
|
+
localState: {}
|
|
6821
|
+
})
|
|
6822
|
+
);
|
|
6823
|
+
// Canonical name used by the Firestore gateway capability probe.
|
|
6824
|
+
export const planFirestoreV2DeterministicOperationChanges =
|
|
6825
|
+
planRecordTimeLabelDeterministicOperationChanges;
|
|
6826
|
+
|
|
5572
6827
|
/**
|
|
5573
6828
|
* Counts all billable writes before a transaction reserves quota.
|
|
5574
6829
|
*/
|
|
@@ -5759,10 +7014,15 @@ export default {
|
|
|
5759
7014
|
isValidChannelName,
|
|
5760
7015
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5761
7016
|
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
7017
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
5762
7018
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
7019
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
5763
7020
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5764
7021
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
7022
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
7023
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
5765
7024
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
7025
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
5766
7026
|
normalizeRecordTimeLabelCloudFailure,
|
|
5767
7027
|
toRecordTimeLabelCloudFailureError,
|
|
5768
7028
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
@@ -5774,6 +7034,11 @@ export default {
|
|
|
5774
7034
|
RTL_MAX_REQUEST_BYTES,
|
|
5775
7035
|
RTL_MAX_TARGET_WRITES,
|
|
5776
7036
|
RTL_TRASH_RETENTION_MS,
|
|
7037
|
+
RECORD_TIMELABEL_PLANNER_MODES,
|
|
7038
|
+
RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
|
|
7039
|
+
isRecordTimeLabelSafeDocumentId,
|
|
7040
|
+
normalizeRecordTimeLabelImmutableId,
|
|
7041
|
+
normalizeRecordTimeLabelPlannerId,
|
|
5777
7042
|
OPERATION_TYPES,
|
|
5778
7043
|
RECORD_TIMELABEL_SYNC_MODES,
|
|
5779
7044
|
RECORD_TIMELABEL_CLOUD_SCHEMAS,
|
|
@@ -5812,6 +7077,8 @@ export default {
|
|
|
5812
7077
|
extendFirestoreV2OperationReadPlanWithRecords,
|
|
5813
7078
|
extendFirestoreV2OperationReadPlanWithTrash,
|
|
5814
7079
|
planFirestoreV2OperationChanges,
|
|
7080
|
+
planRecordTimeLabelDeterministicOperationChanges,
|
|
7081
|
+
planFirestoreV2DeterministicOperationChanges,
|
|
5815
7082
|
estimateFirestoreV2WriteUnits,
|
|
5816
7083
|
normalizeRecordTimeLabelOperationResults,
|
|
5817
7084
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
@@ -5822,6 +7089,7 @@ export default {
|
|
|
5822
7089
|
flushPendingOperations,
|
|
5823
7090
|
mergeRemoteStateIntoLocal,
|
|
5824
7091
|
applyOperation,
|
|
7092
|
+
applyExplicitRecordUpdate,
|
|
5825
7093
|
applyRecordTimeLabelOperation,
|
|
5826
7094
|
createOperation,
|
|
5827
7095
|
createRecordTimeLabelSyncEngine,
|