@recordtimelabel/core 0.6.1 → 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 +28 -2
- package/package.json +1 -1
- package/src/changefeed.js +67 -8
- package/src/firestore-v2.js +11 -0
- package/src/index.js +1681 -315
- package/src/protocol.js +562 -2
package/src/index.js
CHANGED
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
import {
|
|
2
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
3
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
2
4
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
3
6
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
4
7
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
8
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
9
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
10
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
5
11
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
6
12
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
7
13
|
buildRecordTimeLabelRequestId,
|
|
8
14
|
createRecordTimeLabelTransportFailureResults,
|
|
15
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
16
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
9
17
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
10
18
|
normalizeRecordTimeLabelOperationResults,
|
|
19
|
+
normalizeRecordTimeLabelImmutableId,
|
|
20
|
+
normalizeRecordTimeLabelPlannerId,
|
|
21
|
+
toRecordTimeLabelCloudFailureError,
|
|
11
22
|
toRecordTimeLabelWireOperation
|
|
12
23
|
} from './protocol.js';
|
|
13
24
|
|
|
14
25
|
export {
|
|
26
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
27
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
15
28
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
29
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
16
30
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
17
31
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
32
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
33
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
34
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
18
35
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
19
36
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
20
37
|
buildRecordTimeLabelRequestId,
|
|
21
38
|
createRecordTimeLabelTransportFailureResults,
|
|
39
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
40
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
22
41
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
23
42
|
normalizeRecordTimeLabelOperationResults,
|
|
43
|
+
normalizeRecordTimeLabelImmutableId,
|
|
44
|
+
normalizeRecordTimeLabelPlannerId,
|
|
45
|
+
toRecordTimeLabelCloudFailureError,
|
|
24
46
|
toRecordTimeLabelWireOperation
|
|
25
47
|
} from './protocol.js';
|
|
26
48
|
|
|
@@ -119,7 +141,7 @@ export {
|
|
|
119
141
|
selectBestMatchingTwitchVod
|
|
120
142
|
};
|
|
121
143
|
|
|
122
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
144
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.4';
|
|
123
145
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
124
146
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
125
147
|
'fifo-retry-fence',
|
|
@@ -128,7 +150,11 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
128
150
|
'remote-subscription-readiness',
|
|
129
151
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
130
152
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
131
|
-
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
153
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
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
|
|
132
158
|
]);
|
|
133
159
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
134
160
|
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
@@ -136,6 +162,11 @@ export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
|
|
|
136
162
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
137
163
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
138
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;
|
|
139
170
|
|
|
140
171
|
export const OPERATION_TYPES = Object.freeze({
|
|
141
172
|
RECORD_CREATE: 'record.create',
|
|
@@ -186,6 +217,7 @@ export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
|
|
|
186
217
|
OWNER_MISMATCH: 'operation_owner_mismatch',
|
|
187
218
|
WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
|
|
188
219
|
ID_CONFLICT: 'operation_id_conflict',
|
|
220
|
+
INVALID_DOCUMENT_ID: 'invalid_document_id',
|
|
189
221
|
LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
|
|
190
222
|
LIFECYCLE_CONFLICT: 'lifecycle_conflict'
|
|
191
223
|
});
|
|
@@ -202,7 +234,20 @@ const clone = (value, seen = new WeakMap()) => {
|
|
|
202
234
|
const copy = Array.isArray(value) ? [] : {};
|
|
203
235
|
seen.set(value, copy);
|
|
204
236
|
Reflect.ownKeys(value).forEach((key) => {
|
|
205
|
-
|
|
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
|
+
});
|
|
206
251
|
});
|
|
207
252
|
return copy;
|
|
208
253
|
};
|
|
@@ -275,9 +320,21 @@ const normalizeId = (value) => {
|
|
|
275
320
|
return String(value).trim();
|
|
276
321
|
};
|
|
277
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
|
+
|
|
278
333
|
const safeFolderId = (folderId) => {
|
|
279
334
|
const normalized = normalizeId(folderId);
|
|
280
|
-
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized))
|
|
335
|
+
if (!normalized || !rtlIsSafeDocumentId(normalized) || VIRTUAL_FOLDER_IDS.has(normalized)) {
|
|
336
|
+
return DEFAULT_FOLDER_ID;
|
|
337
|
+
}
|
|
281
338
|
return normalized;
|
|
282
339
|
};
|
|
283
340
|
|
|
@@ -307,7 +364,7 @@ const normalizeTombstones = (value) => {
|
|
|
307
364
|
|
|
308
365
|
return entries.reduce((result, [rawId, rawTombstone]) => {
|
|
309
366
|
const id = normalizeId(rawId);
|
|
310
|
-
if (!id) return result;
|
|
367
|
+
if (!id || !rtlIsSafeDocumentId(id)) return result;
|
|
311
368
|
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
312
369
|
? { ...rawTombstone }
|
|
313
370
|
: {};
|
|
@@ -332,7 +389,8 @@ const normalizeTrashEntries = (value) => {
|
|
|
332
389
|
const id = normalizeId(rawId || rawEntry.id);
|
|
333
390
|
const kind = normalizeId(rawEntry.kind);
|
|
334
391
|
const entityId = normalizeId(rawEntry.entityId);
|
|
335
|
-
if (!id || !entityId || !
|
|
392
|
+
if (!id || !entityId || !rtlIsSafeDocumentId(id) || !rtlIsSafeDocumentId(entityId) ||
|
|
393
|
+
!new Set(['record', 'folder']).has(kind)) return result;
|
|
336
394
|
const deletedAt = toFiniteTimestamp(rawEntry.deletedAt);
|
|
337
395
|
const purgeAt = toFiniteTimestamp(rawEntry.purgeAt) ||
|
|
338
396
|
((deletedAt || Date.now()) + RTL_TRASH_RETENTION_MS);
|
|
@@ -357,7 +415,7 @@ export const getActiveTrashEntries = (value, now = Date.now()) => Object.values(
|
|
|
357
415
|
const mergeTombstones = (left = {}, right = {}) => {
|
|
358
416
|
const merged = { ...left };
|
|
359
417
|
Object.entries(right).forEach(([id, tombstone]) => {
|
|
360
|
-
const current = merged[id];
|
|
418
|
+
const current = rtlOwn(merged, id) ? merged[id] : null;
|
|
361
419
|
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
362
420
|
const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
|
|
363
421
|
if (!current || nextGeneration > currentGeneration ||
|
|
@@ -372,7 +430,7 @@ const mergeTombstones = (left = {}, right = {}) => {
|
|
|
372
430
|
const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
|
|
373
431
|
const merged = normalizeTrashEntries(remoteEntries);
|
|
374
432
|
Object.entries(normalizeTrashEntries(localEntries)).forEach(([id, entry]) => {
|
|
375
|
-
const current = merged[id];
|
|
433
|
+
const current = rtlOwn(merged, id) ? merged[id] : null;
|
|
376
434
|
const currentVersion = Number(current?.lifecycleGeneration || 0);
|
|
377
435
|
const nextVersion = Number(entry.lifecycleGeneration || 0);
|
|
378
436
|
if (!current || nextVersion > currentVersion ||
|
|
@@ -508,10 +566,37 @@ export const mergeRecords = (left, right) => {
|
|
|
508
566
|
}
|
|
509
567
|
});
|
|
510
568
|
|
|
511
|
-
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
512
569
|
return merged;
|
|
513
570
|
};
|
|
514
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
|
+
|
|
515
600
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
516
601
|
const source = records && typeof records === 'object' ? records : {};
|
|
517
602
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
@@ -524,7 +609,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
524
609
|
safeList.forEach((record, index) => {
|
|
525
610
|
if (!record || typeof record !== 'object') return;
|
|
526
611
|
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
527
|
-
if (!id) return;
|
|
612
|
+
if (!id || !rtlIsSafeDocumentId(id)) return;
|
|
528
613
|
|
|
529
614
|
const targetFolderId = resolveRecordFolderId(folderId, record);
|
|
530
615
|
|
|
@@ -540,7 +625,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
540
625
|
const folderTombstone = folderTombstones[targetFolderId];
|
|
541
626
|
if (folderTombstone) return;
|
|
542
627
|
|
|
543
|
-
if (!normalized
|
|
628
|
+
if (!rtlOwn(normalized, targetFolderId)) normalized[targetFolderId] = [];
|
|
544
629
|
|
|
545
630
|
const existing = placements.get(id);
|
|
546
631
|
if (!existing) {
|
|
@@ -589,7 +674,7 @@ const ensureFolders = (folders = []) => {
|
|
|
589
674
|
toArray(folders).forEach((folder) => {
|
|
590
675
|
if (!folder || typeof folder !== 'object') return;
|
|
591
676
|
const id = normalizeId(folder.id);
|
|
592
|
-
if (!id || seen.has(id)) return;
|
|
677
|
+
if (!id || !rtlIsSafeDocumentId(id) || seen.has(id)) return;
|
|
593
678
|
seen.add(id);
|
|
594
679
|
nextFolders.push({ ...folder, id });
|
|
595
680
|
});
|
|
@@ -616,10 +701,10 @@ const ensureFolders = (folders = []) => {
|
|
|
616
701
|
const ensureRecordFolders = (records, folders) => {
|
|
617
702
|
const nextRecords = { ...records };
|
|
618
703
|
folders.forEach((folder) => {
|
|
619
|
-
if (!nextRecords
|
|
704
|
+
if (!rtlOwn(nextRecords, folder.id)) nextRecords[folder.id] = [];
|
|
620
705
|
});
|
|
621
|
-
if (!nextRecords
|
|
622
|
-
if (!nextRecords
|
|
706
|
+
if (!rtlOwn(nextRecords, 'all')) nextRecords.all = [];
|
|
707
|
+
if (!rtlOwn(nextRecords, DEFAULT_FOLDER_ID)) nextRecords[DEFAULT_FOLDER_ID] = [];
|
|
623
708
|
nextRecords.all = [];
|
|
624
709
|
return nextRecords;
|
|
625
710
|
};
|
|
@@ -915,8 +1000,12 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
915
1000
|
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
916
1001
|
if (!record) return normalized;
|
|
917
1002
|
const recordId = normalizeId(record.id || payload.recordId);
|
|
918
|
-
if (!recordId) return normalized;
|
|
919
|
-
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;
|
|
920
1009
|
if (tombstone) return normalized;
|
|
921
1010
|
|
|
922
1011
|
const folderId = safeFolderId(payload.folderId || record.folderId);
|
|
@@ -937,24 +1026,25 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
937
1026
|
|
|
938
1027
|
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
939
1028
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
940
|
-
if (!recordId) return normalized;
|
|
1029
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
941
1030
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
942
1031
|
if (!entry) return normalized;
|
|
943
1032
|
const patch = payload.patch || payload.record || {};
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
});
|
|
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;
|
|
950
1038
|
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
951
1039
|
break;
|
|
952
1040
|
}
|
|
953
1041
|
|
|
954
1042
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
955
1043
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
956
|
-
const
|
|
957
|
-
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);
|
|
958
1048
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
959
1049
|
if (!entry) return normalized;
|
|
960
1050
|
const movedRecord = {
|
|
@@ -972,7 +1062,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
972
1062
|
}
|
|
973
1063
|
|
|
974
1064
|
case OPERATION_TYPES.RECORD_REORDER: {
|
|
975
|
-
const
|
|
1065
|
+
const rawFolderId = normalizeId(payload.folderId);
|
|
1066
|
+
if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
|
|
1067
|
+
const folderId = safeFolderId(rawFolderId);
|
|
976
1068
|
const existingFolderRecords = toArray(nextState.records?.[folderId]);
|
|
977
1069
|
const payloadRecords = toArray(payload.records);
|
|
978
1070
|
const recordsById = new Map();
|
|
@@ -998,6 +1090,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
998
1090
|
? payload.recordIds
|
|
999
1091
|
: payloadRecords.map((record) => getRecordId(record))
|
|
1000
1092
|
);
|
|
1093
|
+
if (orderedIds.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
|
|
1001
1094
|
if (orderedIds.length === 0) return normalized;
|
|
1002
1095
|
|
|
1003
1096
|
const seen = new Set();
|
|
@@ -1021,13 +1114,21 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1021
1114
|
|
|
1022
1115
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
1023
1116
|
const recordId = normalizeId(payload.recordId || payload.id);
|
|
1024
|
-
if (!recordId) return normalized;
|
|
1117
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1025
1118
|
const existingEntry = findRecordEntry(nextState.records, recordId);
|
|
1026
1119
|
const recordSnapshot = payload.record && typeof payload.record === 'object'
|
|
1027
1120
|
? {...payload.record, id: recordId}
|
|
1028
1121
|
: existingEntry?.record;
|
|
1029
1122
|
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
1030
|
-
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
|
+
}
|
|
1031
1132
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1032
1133
|
const previousGeneration = Math.max(
|
|
1033
1134
|
Number(recordSnapshot?.lifecycleGeneration || 0),
|
|
@@ -1062,16 +1163,29 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1062
1163
|
}
|
|
1063
1164
|
|
|
1064
1165
|
case OPERATION_TYPES.RECORD_RESTORE: {
|
|
1166
|
+
const restoreRecordId = normalizeId(payload.recordId || payload.id);
|
|
1065
1167
|
const trashEntryId = normalizeId(payload.trashEntryId) ||
|
|
1066
|
-
`record:${
|
|
1067
|
-
|
|
1168
|
+
`record:${restoreRecordId}`;
|
|
1169
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1170
|
+
const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
|
|
1171
|
+
? nextState.trashEntries[trashEntryId]
|
|
1172
|
+
: null;
|
|
1068
1173
|
if (!trashEntry || trashEntry.kind !== 'record') return normalized;
|
|
1069
1174
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
1070
1175
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
1071
1176
|
if (expectedGeneration === null ||
|
|
1072
1177
|
expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
|
|
1073
1178
|
const recordId = normalizeId(trashEntry.entityId);
|
|
1074
|
-
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
|
+
}
|
|
1075
1189
|
const recordSnapshot = trashEntry.payload?.record;
|
|
1076
1190
|
if (!recordSnapshot) return normalized;
|
|
1077
1191
|
const originalFolderId = safeFolderId(trashEntry.originalFolderId);
|
|
@@ -1100,8 +1214,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1100
1214
|
case OPERATION_TYPES.FOLDER_CREATE: {
|
|
1101
1215
|
const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
|
|
1102
1216
|
const folderId = normalizeId(folder?.id || payload.folderId);
|
|
1103
|
-
if (!folderId) return normalized;
|
|
1104
|
-
if (nextState.deletedFolderTombstones
|
|
1217
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1218
|
+
if (rtlOwn(nextState.deletedFolderTombstones, folderId) &&
|
|
1219
|
+
nextState.deletedFolderTombstones[folderId]) return normalized;
|
|
1105
1220
|
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
1106
1221
|
const nextFolder = {
|
|
1107
1222
|
...(folder || {}),
|
|
@@ -1119,7 +1234,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1119
1234
|
|
|
1120
1235
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
1121
1236
|
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
1122
|
-
if (!folderId) return normalized;
|
|
1237
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1123
1238
|
const patch = payload.patch || payload.folder || {};
|
|
1124
1239
|
nextState.folders = nextState.folders.map((folder) => (
|
|
1125
1240
|
folder.id === folderId
|
|
@@ -1131,12 +1246,24 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1131
1246
|
|
|
1132
1247
|
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
1133
1248
|
const folderId = normalizeId(payload.folderId || payload.id);
|
|
1134
|
-
if (!folderId || folderId
|
|
1249
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId) ||
|
|
1250
|
+
folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
|
|
1135
1251
|
const folderSnapshot = payload.folder && typeof payload.folder === 'object'
|
|
1136
1252
|
? {...payload.folder, id: folderId}
|
|
1137
1253
|
: nextState.folders.find((folder) => folder.id === folderId);
|
|
1138
1254
|
const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
|
|
1139
|
-
|
|
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
|
+
}
|
|
1140
1267
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1141
1268
|
const previousGeneration = Math.max(
|
|
1142
1269
|
Number(folderSnapshot?.lifecycleGeneration || 0),
|
|
@@ -1145,6 +1272,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1145
1272
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1146
1273
|
if (folderSnapshot) {
|
|
1147
1274
|
const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
|
|
1275
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1148
1276
|
nextState.trashEntries[trashEntryId] = {
|
|
1149
1277
|
id: trashEntryId,
|
|
1150
1278
|
kind: 'folder',
|
|
@@ -1178,7 +1306,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1178
1306
|
};
|
|
1179
1307
|
folderRecords.forEach((record) => {
|
|
1180
1308
|
const recordId = getRecordId(record);
|
|
1181
|
-
if (!recordId) return;
|
|
1309
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return;
|
|
1182
1310
|
nextState.deletedRecordTombstones[recordId] = {
|
|
1183
1311
|
id: recordId,
|
|
1184
1312
|
folderId,
|
|
@@ -1192,16 +1320,28 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1192
1320
|
}
|
|
1193
1321
|
|
|
1194
1322
|
case OPERATION_TYPES.FOLDER_RESTORE: {
|
|
1323
|
+
const restoreFolderId = normalizeId(payload.folderId || payload.id);
|
|
1195
1324
|
const trashEntryId = normalizeId(payload.trashEntryId) ||
|
|
1196
|
-
`folder:${
|
|
1197
|
-
|
|
1325
|
+
`folder:${restoreFolderId}`;
|
|
1326
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1327
|
+
const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
|
|
1328
|
+
? nextState.trashEntries[trashEntryId]
|
|
1329
|
+
: null;
|
|
1198
1330
|
if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
|
|
1199
1331
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
1200
1332
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
1201
1333
|
if (expectedGeneration === null ||
|
|
1202
1334
|
expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
|
|
1203
1335
|
const folderId = normalizeId(trashEntry.entityId);
|
|
1204
|
-
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
|
+
}
|
|
1205
1345
|
const folderSnapshot = trashEntry.payload?.folder;
|
|
1206
1346
|
if (!folderSnapshot) return normalized;
|
|
1207
1347
|
const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
|
|
@@ -1290,6 +1430,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1290
1430
|
|
|
1291
1431
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
1292
1432
|
nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
|
|
1433
|
+
if (nextState.folderOrder.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
|
|
1293
1434
|
break;
|
|
1294
1435
|
|
|
1295
1436
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
@@ -1397,6 +1538,11 @@ export const mergeLocalRemote = ({
|
|
|
1397
1538
|
});
|
|
1398
1539
|
});
|
|
1399
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.
|
|
1400
1546
|
toArray(pendingOps).forEach((operation) => {
|
|
1401
1547
|
mergedState = applyOperation(mergedState, operation);
|
|
1402
1548
|
});
|
|
@@ -1731,7 +1877,9 @@ const reorderV2RecordsByFolderRecordOrder = (records = {}, recordOrderByFolder =
|
|
|
1731
1877
|
|
|
1732
1878
|
const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
1733
1879
|
const recordId = normalizeId(record.id);
|
|
1734
|
-
if (!recordId) return null;
|
|
1880
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return null;
|
|
1881
|
+
const normalizedFolderId = normalizeId(folderId);
|
|
1882
|
+
if (normalizedFolderId && !rtlIsSafeDocumentId(normalizedFolderId)) return null;
|
|
1735
1883
|
const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
|
|
1736
1884
|
delete cleanRecord.pendingSync;
|
|
1737
1885
|
delete cleanRecord.syncAttempts;
|
|
@@ -1744,7 +1892,7 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
|
1744
1892
|
|
|
1745
1893
|
const cleanV2FolderDocument = (folder = {}, now) => {
|
|
1746
1894
|
const folderId = normalizeId(folder.id);
|
|
1747
|
-
if (!folderId) return null;
|
|
1895
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return null;
|
|
1748
1896
|
const {
|
|
1749
1897
|
schemaVersion,
|
|
1750
1898
|
recordOrder,
|
|
@@ -1762,7 +1910,7 @@ const cleanV2FolderDocument = (folder = {}, now) => {
|
|
|
1762
1910
|
|
|
1763
1911
|
const cleanV2OperationDocument = (operation = {}, now) => {
|
|
1764
1912
|
const operationId = normalizeId(operation.id);
|
|
1765
|
-
if (!operationId || !operation.type) return null;
|
|
1913
|
+
if (!operationId || !rtlIsSafeDocumentId(operationId) || !operation.type) return null;
|
|
1766
1914
|
return {
|
|
1767
1915
|
...operation,
|
|
1768
1916
|
id: operationId,
|
|
@@ -1774,7 +1922,8 @@ const cleanV2OperationDocument = (operation = {}, now) => {
|
|
|
1774
1922
|
const cleanV2TrashDocument = (entry = {}, now) => {
|
|
1775
1923
|
const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
|
|
1776
1924
|
const trashEntry = Object.values(normalized)[0];
|
|
1777
|
-
if (!trashEntry)
|
|
1925
|
+
if (!trashEntry || !rtlIsSafeDocumentId(trashEntry.id) ||
|
|
1926
|
+
!rtlIsSafeDocumentId(trashEntry.entityId)) return null;
|
|
1778
1927
|
return {
|
|
1779
1928
|
...trashEntry,
|
|
1780
1929
|
deletedAt: trashEntry.deletedAt || now,
|
|
@@ -1790,6 +1939,7 @@ const buildV2LifecycleTombstoneDocuments = (
|
|
|
1790
1939
|
const documents = {};
|
|
1791
1940
|
const append = (kind, tombstones) => {
|
|
1792
1941
|
Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
|
|
1942
|
+
if (!rtlIsSafeDocumentId(entityId)) return;
|
|
1793
1943
|
const id = `${kind}:${entityId}`;
|
|
1794
1944
|
documents[encodeURIComponent(id)] = {
|
|
1795
1945
|
id,
|
|
@@ -3001,6 +3151,168 @@ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
|
|
|
3001
3151
|
|
|
3002
3152
|
const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
3003
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
|
+
|
|
3004
3316
|
const rtlToFiniteNumber = (value, fallback = 0) => {
|
|
3005
3317
|
const number = Number(value);
|
|
3006
3318
|
return Number.isFinite(number) ? number : fallback;
|
|
@@ -3026,12 +3338,26 @@ const rtlNormalizeWorkspaceEpoch = (value, fallback = 0) => {
|
|
|
3026
3338
|
|
|
3027
3339
|
const rtlCloneOperation = (operation) => clone(operation && typeof operation === 'object' ? operation : {});
|
|
3028
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
|
+
|
|
3029
3355
|
const rtlStripSessionTokens = (value) => {
|
|
3030
3356
|
if (Array.isArray(value)) return value.map((entry) => rtlStripSessionTokens(entry));
|
|
3031
3357
|
if (!rtlDurableIsObject(value)) return value;
|
|
3032
3358
|
const result = {};
|
|
3033
3359
|
Object.entries(value).forEach(([key, entry]) => {
|
|
3034
|
-
if (key
|
|
3360
|
+
if (rtlIsCredentialKey(key)) return;
|
|
3035
3361
|
result[key] = rtlStripSessionTokens(entry);
|
|
3036
3362
|
});
|
|
3037
3363
|
return result;
|
|
@@ -3047,35 +3373,6 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3047
3373
|
const input = operation && typeof operation === 'object' ? operation : {};
|
|
3048
3374
|
const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
|
|
3049
3375
|
const normalizedPayload = payload;
|
|
3050
|
-
const trimPayloadId = (key) => {
|
|
3051
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload, key)) {
|
|
3052
|
-
normalizedPayload[key] = normalizeId(normalizedPayload[key]) || normalizedPayload[key];
|
|
3053
|
-
}
|
|
3054
|
-
};
|
|
3055
|
-
[
|
|
3056
|
-
'recordId', 'folderId', 'targetFolderId', 'trashEntryId', 'batchId', 'id',
|
|
3057
|
-
'clientInstanceId', 'instanceId'
|
|
3058
|
-
].forEach(trimPayloadId);
|
|
3059
|
-
['recordIds', 'folderOrder', 'groupOrder', 'expandedGroups', 'groupIds', 'ids'].forEach((key) => {
|
|
3060
|
-
if (Array.isArray(normalizedPayload[key])) {
|
|
3061
|
-
normalizedPayload[key] = normalizeIdList(normalizedPayload[key]);
|
|
3062
|
-
}
|
|
3063
|
-
});
|
|
3064
|
-
if (rtlDurableIsObject(normalizedPayload.record)) {
|
|
3065
|
-
normalizedPayload.record = clone(normalizedPayload.record);
|
|
3066
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'id')) {
|
|
3067
|
-
normalizedPayload.record.id = normalizeId(normalizedPayload.record.id) || normalizedPayload.record.id;
|
|
3068
|
-
}
|
|
3069
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'folderId')) {
|
|
3070
|
-
normalizedPayload.record.folderId = normalizeId(normalizedPayload.record.folderId) || normalizedPayload.record.folderId;
|
|
3071
|
-
}
|
|
3072
|
-
}
|
|
3073
|
-
if (rtlDurableIsObject(normalizedPayload.folder)) {
|
|
3074
|
-
normalizedPayload.folder = clone(normalizedPayload.folder);
|
|
3075
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.folder, 'id')) {
|
|
3076
|
-
normalizedPayload.folder.id = normalizeId(normalizedPayload.folder.id) || normalizedPayload.folder.id;
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
3376
|
|
|
3080
3377
|
const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
|
|
3081
3378
|
const resolvedNow = Number.isFinite(operationNow)
|
|
@@ -3090,7 +3387,9 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3090
3387
|
|
|
3091
3388
|
return {
|
|
3092
3389
|
...rtlStripSessionTokens(input),
|
|
3093
|
-
|
|
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,
|
|
3094
3393
|
type: typeof input.type === 'string' ? input.type.trim() : input.type,
|
|
3095
3394
|
payload: normalizedPayload,
|
|
3096
3395
|
clientId: resolvedClientId,
|
|
@@ -3123,10 +3422,12 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
|
3123
3422
|
const baseline = rtlDurableIsObject(value) ? value : {};
|
|
3124
3423
|
return {
|
|
3125
3424
|
state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
|
|
3126
|
-
revision:
|
|
3425
|
+
revision: rtlRawNonNegativeSafeInteger(baseline.revision) ? baseline.revision : 0,
|
|
3127
3426
|
changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
|
|
3128
3427
|
? null
|
|
3129
|
-
:
|
|
3428
|
+
: rtlDurableIsObject(baseline.changeCursor)
|
|
3429
|
+
? clone(baseline.changeCursor)
|
|
3430
|
+
: String(baseline.changeCursor)
|
|
3130
3431
|
};
|
|
3131
3432
|
};
|
|
3132
3433
|
|
|
@@ -3203,6 +3504,29 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3203
3504
|
};
|
|
3204
3505
|
|
|
3205
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
|
+
}
|
|
3206
3530
|
const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
|
|
3207
3531
|
const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
|
|
3208
3532
|
if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
|
|
@@ -3354,12 +3678,17 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3354
3678
|
} catch {
|
|
3355
3679
|
current = null;
|
|
3356
3680
|
}
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
?
|
|
3362
|
-
:
|
|
3681
|
+
// SessionPort.sessionToken is an opaque fence for equality only. Never treat
|
|
3682
|
+
// current.id or the whole session object as an authorization credential.
|
|
3683
|
+
const sessionToken = current && typeof current === 'object' &&
|
|
3684
|
+
Object.prototype.hasOwnProperty.call(current, 'sessionToken')
|
|
3685
|
+
? current.sessionToken
|
|
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;
|
|
3363
3692
|
const uid = rtlNormalizeUid(current && typeof current === 'object'
|
|
3364
3693
|
? (current.uid ?? current.ownerUid)
|
|
3365
3694
|
: null);
|
|
@@ -3370,8 +3699,10 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3370
3699
|
return {
|
|
3371
3700
|
current,
|
|
3372
3701
|
hasSession: typeof session?.current === 'function',
|
|
3373
|
-
|
|
3374
|
-
|
|
3702
|
+
sessionToken,
|
|
3703
|
+
authSessionBinding: typeof authSessionBinding === 'string' && authSessionBinding.trim()
|
|
3704
|
+
? authSessionBinding
|
|
3705
|
+
: null,
|
|
3375
3706
|
uid,
|
|
3376
3707
|
workspaceEpoch: hasEpoch
|
|
3377
3708
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
@@ -3380,9 +3711,16 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3380
3711
|
};
|
|
3381
3712
|
};
|
|
3382
3713
|
|
|
3714
|
+
const rtlSameSessionIdentity = (left, right) => (
|
|
3715
|
+
Boolean(left) &&
|
|
3716
|
+
Boolean(right) &&
|
|
3717
|
+
left.uid === right.uid &&
|
|
3718
|
+
Number(left.workspaceEpoch) === Number(right.workspaceEpoch) &&
|
|
3719
|
+
Object.is(left.sessionToken, right.sessionToken)
|
|
3720
|
+
);
|
|
3721
|
+
|
|
3383
3722
|
const rtlSessionContext = (captured, workspace, client) => ({
|
|
3384
3723
|
sessionToken: captured?.sessionToken ?? null,
|
|
3385
|
-
token: captured?.token ?? null,
|
|
3386
3724
|
uid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
3387
3725
|
ownerUid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
3388
3726
|
workspaceEpoch: workspace?.workspaceEpoch ?? captured?.workspaceEpoch ?? 0,
|
|
@@ -3390,18 +3728,54 @@ const rtlSessionContext = (captured, workspace, client) => ({
|
|
|
3390
3728
|
});
|
|
3391
3729
|
|
|
3392
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
|
+
};
|
|
3393
3743
|
if (value && typeof value === 'object') {
|
|
3394
|
-
if (value.remoteBaseline) return value.remoteBaseline;
|
|
3395
|
-
if (value.baseline) return value.baseline;
|
|
3396
|
-
|
|
3397
|
-
|
|
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;
|
|
3398
3769
|
}
|
|
3399
|
-
if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
|
|
3400
3770
|
// Adapters migrating from the v1 cloud port may still return a raw
|
|
3401
3771
|
// normalized state. Treat the presence of state-owned keys as an implicit
|
|
3402
3772
|
// baseline with the persisted revision.
|
|
3403
3773
|
if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
|
|
3404
|
-
return {
|
|
3774
|
+
return {
|
|
3775
|
+
state: value,
|
|
3776
|
+
revision: value.revision ?? value.remoteRevision,
|
|
3777
|
+
changeCursor: value.changeCursor
|
|
3778
|
+
};
|
|
3405
3779
|
}
|
|
3406
3780
|
}
|
|
3407
3781
|
return null;
|
|
@@ -3409,44 +3783,63 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3409
3783
|
|
|
3410
3784
|
const rtlEnvelopeSuccess = (value) => {
|
|
3411
3785
|
if (!value || typeof value !== 'object') return false;
|
|
3412
|
-
if (
|
|
3786
|
+
if (
|
|
3787
|
+
value.success === false ||
|
|
3788
|
+
value.ok === false ||
|
|
3789
|
+
value.error ||
|
|
3790
|
+
value.failure ||
|
|
3791
|
+
value.code === 'error'
|
|
3792
|
+
) return false;
|
|
3413
3793
|
return value.success === true || value.ok === true || (
|
|
3414
3794
|
value.success === undefined && value.ok === undefined && !value.error
|
|
3415
3795
|
);
|
|
3416
3796
|
};
|
|
3417
3797
|
|
|
3418
|
-
const
|
|
3419
|
-
const
|
|
3420
|
-
|
|
3421
|
-
const
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
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
|
+
};
|
|
3442
3837
|
};
|
|
3443
3838
|
|
|
3444
|
-
const
|
|
3445
|
-
const error =
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
const retryAfter = Number(rawRetryAfter);
|
|
3449
|
-
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;
|
|
3450
3843
|
};
|
|
3451
3844
|
|
|
3452
3845
|
const rtlResultOperationId = (result = {}) => normalizeId(
|
|
@@ -3516,6 +3909,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3516
3909
|
})
|
|
3517
3910
|
};
|
|
3518
3911
|
let bootstrapAttemptSequence = 0;
|
|
3912
|
+
let authTransitionLatch = null;
|
|
3519
3913
|
const listeners = new Set();
|
|
3520
3914
|
let queue = Promise.resolve();
|
|
3521
3915
|
|
|
@@ -3553,6 +3947,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3553
3947
|
|
|
3554
3948
|
const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
|
|
3555
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
|
+
|
|
3556
3956
|
const isCurrent = async (captured) => {
|
|
3557
3957
|
if (destroyed || !captured) return false;
|
|
3558
3958
|
if (typeof session?.isCurrent === 'function') {
|
|
@@ -3568,18 +3968,34 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3568
3968
|
}
|
|
3569
3969
|
if (typeof session?.current !== 'function') return true;
|
|
3570
3970
|
const current = rtlExtractSession(session, captured.workspaceEpoch);
|
|
3571
|
-
if (
|
|
3971
|
+
if (
|
|
3972
|
+
captured.sessionToken !== undefined &&
|
|
3973
|
+
captured.sessionToken !== null &&
|
|
3974
|
+
!Object.is(current.sessionToken, captured.sessionToken)
|
|
3975
|
+
) return false;
|
|
3572
3976
|
if (captured.uid !== current.uid) return false;
|
|
3573
3977
|
return !captured.hasEpoch || current.workspaceEpoch === captured.workspaceEpoch;
|
|
3574
3978
|
};
|
|
3575
3979
|
|
|
3576
3980
|
const persist = async (candidate, captured) => {
|
|
3577
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);
|
|
3578
3994
|
// The second argument is an optional adapter-side fence. The public
|
|
3579
3995
|
// StoragePort remains compatible with save(workspace); adapters that can
|
|
3580
3996
|
// enforce an atomic session/epoch check should consume this context before
|
|
3581
3997
|
// mutating durable storage.
|
|
3582
|
-
await storage.save(clone(
|
|
3998
|
+
await storage.save(clone(durableCandidate), {
|
|
3583
3999
|
sessionToken: captured?.sessionToken ?? null,
|
|
3584
4000
|
uid: captured?.uid ?? null,
|
|
3585
4001
|
ownerUid: captured?.uid ?? null,
|
|
@@ -3598,7 +4014,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3598
4014
|
}
|
|
3599
4015
|
const nextBaseline = rtlNormalizeRemoteBaseline({
|
|
3600
4016
|
state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
|
|
3601
|
-
revision: Number.
|
|
4017
|
+
revision: Number.isSafeInteger(revision) && revision >= 0
|
|
4018
|
+
? revision
|
|
4019
|
+
: candidate.remoteBaseline.revision,
|
|
3602
4020
|
changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
|
|
3603
4021
|
? remote.changeCursor
|
|
3604
4022
|
: candidate.remoteBaseline.changeCursor
|
|
@@ -3618,21 +4036,75 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3618
4036
|
|
|
3619
4037
|
const normalizeBootstrapResponse = (response) => {
|
|
3620
4038
|
if (!rtlEnvelopeSuccess(response)) {
|
|
3621
|
-
const
|
|
3622
|
-
|
|
3623
|
-
|
|
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);
|
|
3624
4045
|
}
|
|
3625
4046
|
const remote = rtlEnvelopePayload(response);
|
|
3626
4047
|
const revision = Number(remote?.revision);
|
|
3627
4048
|
const state = remote?.state ?? remote?.data;
|
|
3628
|
-
if (!remote || !rtlDurableIsObject(state) ||
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
4049
|
+
if (!remote || !rtlDurableIsObject(state) ||
|
|
4050
|
+
!Number.isSafeInteger(revision) || revision < 0) {
|
|
4051
|
+
throw toRecordTimeLabelCloudFailureError({
|
|
4052
|
+
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
4053
|
+
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
4054
|
+
message: 'recordtimelabel_invalid_bootstrap_response',
|
|
4055
|
+
retryable: false
|
|
4056
|
+
});
|
|
3632
4057
|
}
|
|
3633
4058
|
return {state, revision, changeCursor: remote.changeCursor ?? null};
|
|
3634
4059
|
};
|
|
3635
4060
|
|
|
4061
|
+
const clearAuthTransitionLatchIfSessionChanged = (captured) => {
|
|
4062
|
+
if (!authTransitionLatch) return;
|
|
4063
|
+
if (!rtlSameSessionIdentity(authTransitionLatch, captured)) {
|
|
4064
|
+
authTransitionLatch = null;
|
|
4065
|
+
}
|
|
4066
|
+
};
|
|
4067
|
+
|
|
4068
|
+
const throwIfAuthTransitionLatched = (captured) => {
|
|
4069
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
4070
|
+
if (!authTransitionLatch) return;
|
|
4071
|
+
throw toRecordTimeLabelCloudFailureError(authTransitionLatch.failure);
|
|
4072
|
+
};
|
|
4073
|
+
|
|
4074
|
+
const rememberAuthTransitionFailure = (captured, failure) => {
|
|
4075
|
+
if (failure?.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
authTransitionLatch = {
|
|
4079
|
+
uid: captured?.uid ?? null,
|
|
4080
|
+
sessionToken: captured?.sessionToken ?? null,
|
|
4081
|
+
workspaceEpoch: captured?.workspaceEpoch ?? 0,
|
|
4082
|
+
failure: {
|
|
4083
|
+
class: failure.class,
|
|
4084
|
+
status: failure.status ?? null,
|
|
4085
|
+
reason: failure.reason ?? failure.code ?? 'auth-transition-required',
|
|
4086
|
+
code: failure.code ?? failure.reason ?? 'auth-transition-required',
|
|
4087
|
+
message: failure.message || failure.reason || failure.code || 'auth-transition-required',
|
|
4088
|
+
retryable: false,
|
|
4089
|
+
retryAfterMs: null,
|
|
4090
|
+
bootstrapRequired: false
|
|
4091
|
+
}
|
|
4092
|
+
};
|
|
4093
|
+
};
|
|
4094
|
+
|
|
4095
|
+
const capturedFromContext = (context) => ({
|
|
4096
|
+
uid: context?.uid ?? null,
|
|
4097
|
+
sessionToken: context?.sessionToken ?? null,
|
|
4098
|
+
workspaceEpoch: context?.workspaceEpoch ?? 0
|
|
4099
|
+
});
|
|
4100
|
+
|
|
4101
|
+
const rethrowClassifiedCloudFailure = (captured, error) => {
|
|
4102
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
4103
|
+
rememberAuthTransitionFailure(captured, failure);
|
|
4104
|
+
if (error instanceof Error && error?.class === failure.class) throw error;
|
|
4105
|
+
throw toRecordTimeLabelCloudFailureError(failure);
|
|
4106
|
+
};
|
|
4107
|
+
|
|
3636
4108
|
const requireBootstrapRevision = (baseline, minimumRevision) => {
|
|
3637
4109
|
const minimum = Number(minimumRevision);
|
|
3638
4110
|
if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
|
|
@@ -3640,6 +4112,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3640
4112
|
error.code = 'recordtimelabel_bootstrap_revision_behind_required';
|
|
3641
4113
|
error.bootstrapRevision = Number(baseline?.revision);
|
|
3642
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
|
+
});
|
|
3643
4121
|
throw error;
|
|
3644
4122
|
}
|
|
3645
4123
|
return baseline;
|
|
@@ -3651,10 +4129,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3651
4129
|
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3652
4130
|
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3653
4131
|
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3654
|
-
// bootstrap walk
|
|
3655
|
-
//
|
|
3656
|
-
//
|
|
4132
|
+
// bootstrap walk only for explicit bootstrap-required / cursor-gap
|
|
4133
|
+
// outcomes. Auth and transient failures keep their class and must not
|
|
4134
|
+
// start a second full walk. A successful but stale catch-up response is
|
|
4135
|
+
// fail-closed: falling back would hide a revision contract violation.
|
|
3657
4136
|
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
4137
|
+
const captured = capturedFromContext(context);
|
|
4138
|
+
throwIfAuthTransitionLatched(captured);
|
|
3658
4139
|
if (typeof cloud?.catchUp === 'function') {
|
|
3659
4140
|
try {
|
|
3660
4141
|
const caught = await cloud.catchUp(context, {
|
|
@@ -3665,20 +4146,35 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3665
4146
|
if (rtlEnvelopeSuccess(caught)) {
|
|
3666
4147
|
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3667
4148
|
}
|
|
4149
|
+
const failure = normalizeRecordTimeLabelCloudFailure(caught);
|
|
4150
|
+
if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
4151
|
+
rethrowClassifiedCloudFailure(captured, failure);
|
|
4152
|
+
}
|
|
3668
4153
|
} catch (error) {
|
|
3669
4154
|
if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
|
|
3670
4155
|
throw error;
|
|
3671
4156
|
}
|
|
3672
|
-
|
|
4157
|
+
if (error?.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED ||
|
|
4158
|
+
normalizeRecordTimeLabelCloudFailure(error).class ===
|
|
4159
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
4160
|
+
// Explicit gap recovery only.
|
|
4161
|
+
} else {
|
|
4162
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4163
|
+
}
|
|
3673
4164
|
}
|
|
3674
4165
|
}
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
4166
|
+
throwIfAuthTransitionLatched(captured);
|
|
4167
|
+
try {
|
|
4168
|
+
return requireBootstrapRevision(
|
|
4169
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
4170
|
+
context,
|
|
4171
|
+
bootstrapAttemptOptions(mode)
|
|
4172
|
+
)),
|
|
4173
|
+
targetRevision
|
|
4174
|
+
);
|
|
4175
|
+
} catch (error) {
|
|
4176
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4177
|
+
}
|
|
3682
4178
|
};
|
|
3683
4179
|
|
|
3684
4180
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
@@ -3847,13 +4343,366 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3847
4343
|
nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
|
|
3848
4344
|
});
|
|
3849
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
|
+
|
|
3850
4688
|
const processRemote = async (remoteValue, captured) => {
|
|
3851
4689
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3852
4690
|
const candidate = clone(workspace);
|
|
3853
4691
|
const remote = rtlEnvelopePayload(remoteValue);
|
|
3854
4692
|
const remoteRevision = Number(remote?.revision);
|
|
3855
|
-
if (!Number.
|
|
3856
|
-
|
|
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
|
+
};
|
|
3857
4706
|
}
|
|
3858
4707
|
if (remoteRevision <= candidate.remoteBaseline.revision) {
|
|
3859
4708
|
return {success: true, ignored: true};
|
|
@@ -3863,7 +4712,19 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3863
4712
|
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
3864
4713
|
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
3865
4714
|
if (typeof cloud?.bootstrap !== 'function') {
|
|
3866
|
-
|
|
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
|
+
};
|
|
3867
4728
|
}
|
|
3868
4729
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3869
4730
|
baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
|
|
@@ -3893,14 +4754,37 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3893
4754
|
state.settled = true;
|
|
3894
4755
|
resolvePromise({
|
|
3895
4756
|
success: true,
|
|
3896
|
-
...(value && typeof value === 'object' ? value : {})
|
|
3897
|
-
generation
|
|
4757
|
+
...(value && typeof value === 'object' ? value : {})
|
|
3898
4758
|
});
|
|
3899
4759
|
},
|
|
3900
4760
|
reject(error) {
|
|
3901
4761
|
if (state.settled) return;
|
|
3902
4762
|
state.settled = true;
|
|
3903
|
-
|
|
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);
|
|
3904
4788
|
}
|
|
3905
4789
|
};
|
|
3906
4790
|
// Readiness is an opt-in host boundary. Keep a rejection observable to a
|
|
@@ -3912,7 +4796,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3912
4796
|
|
|
3913
4797
|
const staleRemoteReady = (state, reason = 'stale_session') => {
|
|
3914
4798
|
if (!state || state.settled) return;
|
|
3915
|
-
|
|
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});
|
|
3916
4805
|
};
|
|
3917
4806
|
|
|
3918
4807
|
const isCurrentRemoteReady = (state) => (
|
|
@@ -3930,6 +4819,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3930
4819
|
if (settleReady) staleRemoteReady(remoteReadyState);
|
|
3931
4820
|
};
|
|
3932
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
|
+
|
|
3933
4833
|
const startCloudSubscription = (captured, candidate) => {
|
|
3934
4834
|
stopCloudSubscription();
|
|
3935
4835
|
const readyState = createRemoteReadyState();
|
|
@@ -3943,7 +4843,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3943
4843
|
return;
|
|
3944
4844
|
}
|
|
3945
4845
|
if (typeof cloud?.subscribe !== 'function') {
|
|
3946
|
-
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
|
+
}));
|
|
3947
4852
|
return;
|
|
3948
4853
|
}
|
|
3949
4854
|
const context = rtlSessionContext(captured, candidate, client);
|
|
@@ -3951,24 +4856,64 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3951
4856
|
let subscriptionReturned = false;
|
|
3952
4857
|
let adapterReadyExpected = false;
|
|
3953
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
|
+
};
|
|
3954
4910
|
const onRemote = (remoteValue) => {
|
|
3955
4911
|
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
3956
4912
|
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
3957
4913
|
}
|
|
3958
4914
|
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
3959
4915
|
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
3960
|
-
callbackResult.then((result) => {
|
|
3961
|
-
if (
|
|
3962
|
-
subscriptionReturned &&
|
|
3963
|
-
!adapterReadyExpected &&
|
|
3964
|
-
isCurrentRemoteReady(readyState)
|
|
3965
|
-
) {
|
|
3966
|
-
readyState.resolve({
|
|
3967
|
-
revision: workspace.remoteBaseline.revision,
|
|
3968
|
-
result
|
|
3969
|
-
});
|
|
3970
|
-
}
|
|
3971
|
-
}).catch((error) => {
|
|
4916
|
+
callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
|
|
3972
4917
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
3973
4918
|
readyState.reject(error);
|
|
3974
4919
|
}
|
|
@@ -3984,34 +4929,58 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3984
4929
|
logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
|
|
3985
4930
|
return;
|
|
3986
4931
|
}
|
|
3987
|
-
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
|
+
}
|
|
3988
4952
|
adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
|
|
3989
4953
|
subscriptionReturned = true;
|
|
3990
4954
|
if (adapterReadyExpected) {
|
|
3991
4955
|
Promise.resolve(disposer.ready).then((result) => {
|
|
3992
4956
|
if (!isCurrentRemoteReady(readyState)) return;
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
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
|
+
}
|
|
3997
4971
|
}).catch((error) => {
|
|
3998
4972
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
3999
4973
|
readyState.reject(error);
|
|
4000
4974
|
}
|
|
4001
4975
|
});
|
|
4002
4976
|
} else if (firstCallbackResult) {
|
|
4003
|
-
firstCallbackResult.then((
|
|
4004
|
-
if (!isCurrentRemoteReady(readyState)) return;
|
|
4005
|
-
readyState.resolve({
|
|
4006
|
-
revision: workspace.remoteBaseline.revision,
|
|
4007
|
-
result
|
|
4008
|
-
});
|
|
4009
|
-
}).catch(() => null);
|
|
4977
|
+
firstCallbackResult.then(() => settleReadyIfProven()).catch(() => null);
|
|
4010
4978
|
}
|
|
4011
4979
|
};
|
|
4012
4980
|
|
|
4013
4981
|
const initialize = async () => {
|
|
4014
4982
|
const captured = capture();
|
|
4983
|
+
throwIfAuthTransitionLatched(captured);
|
|
4015
4984
|
const loaded = await storage.load();
|
|
4016
4985
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4017
4986
|
// Read the raw owner and baseline before normalizeLoadedWorkspace adopts
|
|
@@ -4021,10 +4990,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4021
4990
|
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
4022
4991
|
const loadedBaseline = loaded?.remoteBaseline;
|
|
4023
4992
|
const committedRevision = Number(loadedBaseline?.revision);
|
|
4024
|
-
const hasValidCommittedBaseline =
|
|
4025
|
-
rtlDurableIsObject(loadedBaseline.state) &&
|
|
4026
|
-
Number.isFinite(committedRevision) &&
|
|
4027
|
-
committedRevision > 0;
|
|
4993
|
+
const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
|
|
4028
4994
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
4029
4995
|
const identityChecked = rtlQuarantineOperations(
|
|
4030
4996
|
candidate,
|
|
@@ -4042,6 +5008,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4042
5008
|
candidate.remoteBaseline.revision === committedRevision &&
|
|
4043
5009
|
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
4044
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
|
+
});
|
|
4045
5018
|
let bootstrap;
|
|
4046
5019
|
try {
|
|
4047
5020
|
bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
@@ -4050,10 +5023,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4050
5023
|
));
|
|
4051
5024
|
} catch (error) {
|
|
4052
5025
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4053
|
-
|
|
5026
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4054
5027
|
}
|
|
4055
5028
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4056
5029
|
applyRemoteBaseline(candidate, bootstrap);
|
|
5030
|
+
delete candidate.syncMeta.terminalFailureBlock;
|
|
4057
5031
|
}
|
|
4058
5032
|
|
|
4059
5033
|
// Always persist the normalized durable shape before subscribing. This
|
|
@@ -4068,6 +5042,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4068
5042
|
if (destroyed) return;
|
|
4069
5043
|
enqueue(async () => {
|
|
4070
5044
|
const current = capture();
|
|
5045
|
+
clearAuthTransitionLatchIfSessionChanged(current);
|
|
4071
5046
|
const sameIdentity = current.uid === workspace.ownerUid &&
|
|
4072
5047
|
Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
|
|
4073
5048
|
if (sameIdentity) {
|
|
@@ -4097,7 +5072,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4097
5072
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
4098
5073
|
}
|
|
4099
5074
|
const captured = capture();
|
|
5075
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
4100
5076
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5077
|
+
if (!workspaceMatchesSession(captured)) {
|
|
5078
|
+
resetForSessionIdentity(captured);
|
|
5079
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5080
|
+
}
|
|
4101
5081
|
const input = Array.isArray(operations)
|
|
4102
5082
|
? operations
|
|
4103
5083
|
: (operations && typeof operations === 'object' ? [operations] : []);
|
|
@@ -4178,11 +5158,25 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4178
5158
|
};
|
|
4179
5159
|
|
|
4180
5160
|
const syncInternal = async (reason) => {
|
|
5161
|
+
const captured = capture();
|
|
5162
|
+
throwIfAuthTransitionLatched(captured);
|
|
4181
5163
|
if (!initialized || hydrationRequired) {
|
|
4182
5164
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
4183
5165
|
}
|
|
4184
|
-
const captured = capture();
|
|
4185
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
|
+
}
|
|
4186
5180
|
const timestamp = now();
|
|
4187
5181
|
const identityChecked = rtlQuarantineOperations(
|
|
4188
5182
|
workspace,
|
|
@@ -4234,7 +5228,22 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4234
5228
|
};
|
|
4235
5229
|
}
|
|
4236
5230
|
if (typeof cloud?.applyOperations !== 'function') {
|
|
4237
|
-
|
|
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
|
+
};
|
|
4238
5247
|
}
|
|
4239
5248
|
const context = rtlSessionContext(captured, workspace, client);
|
|
4240
5249
|
const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
|
|
@@ -4256,59 +5265,79 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4256
5265
|
}, context);
|
|
4257
5266
|
} catch (error) {
|
|
4258
5267
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
};
|
|
5268
|
+
return settleApplyFailure({
|
|
5269
|
+
value: error,
|
|
5270
|
+
captured,
|
|
5271
|
+
context,
|
|
5272
|
+
ready,
|
|
5273
|
+
timestamp,
|
|
5274
|
+
identityRejectedCount
|
|
5275
|
+
});
|
|
4268
5276
|
}
|
|
4269
5277
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4270
5278
|
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
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
|
+
});
|
|
4278
5298
|
}
|
|
4279
|
-
const retryAfterMs = rtlRetryAfterMs(response);
|
|
4280
|
-
const candidate = clone(workspace);
|
|
4281
|
-
const byId = new Set(ready.map((operation) => operation.id));
|
|
4282
|
-
candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
|
|
4283
|
-
byId.has(operation.id)
|
|
4284
|
-
? makeRetryOperation(operation, response, retryAfterMs, timestamp)
|
|
4285
|
-
: operation
|
|
4286
|
-
));
|
|
4287
|
-
candidate.syncMeta = {
|
|
4288
|
-
...candidate.syncMeta,
|
|
4289
|
-
lastSyncAttemptAt: timestamp,
|
|
4290
|
-
lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
|
|
4291
|
-
};
|
|
4292
|
-
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4293
|
-
workspace = candidate;
|
|
4294
|
-
notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
|
|
4295
|
-
const retryAt = Math.min(...candidate.pendingOperations
|
|
4296
|
-
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
4297
|
-
.map((operation) => Number(operation.nextRetryAt)));
|
|
4298
|
-
return {
|
|
4299
|
-
success: false,
|
|
4300
|
-
retryable: true,
|
|
4301
|
-
retryAfterMs,
|
|
4302
|
-
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
4303
|
-
pendingCount: workspace.pendingOperations.length,
|
|
4304
|
-
identityRejectedCount
|
|
4305
|
-
};
|
|
4306
5299
|
}
|
|
4307
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;
|
|
4308
5323
|
const parsed = normalizeOperationResults(response, wireOperations);
|
|
4309
5324
|
if (parsed.error) {
|
|
4310
|
-
|
|
4311
|
-
|
|
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
|
+
});
|
|
4312
5341
|
}
|
|
4313
5342
|
let sawRetryable = false;
|
|
4314
5343
|
for (const result of parsed.results) {
|
|
@@ -4318,7 +5347,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4318
5347
|
const error = new Error('sync_protocol_fifo_retry_barrier_violation');
|
|
4319
5348
|
error.code = 'sync_protocol_fifo_retry_barrier_violation';
|
|
4320
5349
|
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
4321
|
-
|
|
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
|
+
});
|
|
4322
5364
|
}
|
|
4323
5365
|
}
|
|
4324
5366
|
const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
|
|
@@ -4356,40 +5398,109 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4356
5398
|
};
|
|
4357
5399
|
}
|
|
4358
5400
|
}
|
|
4359
|
-
appliedOperations.forEach((operation) => {
|
|
4360
|
-
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
4361
|
-
applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
|
|
4362
|
-
);
|
|
4363
|
-
});
|
|
4364
|
-
const responseRevision = Number(response.revision ?? response.remoteRevision);
|
|
4365
|
-
const responseBaseline = rtlEnvelopePayload(response);
|
|
4366
5401
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
4367
|
-
|
|
4368
|
-
|
|
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);
|
|
4369
5476
|
} else if (
|
|
5477
|
+
!responseIsStale &&
|
|
5478
|
+
responseIsExactNext &&
|
|
4370
5479
|
rejectedCount === 0 &&
|
|
4371
5480
|
!sawRetryable &&
|
|
4372
|
-
|
|
4373
|
-
appliedOperations.length > 0 &&
|
|
4374
|
-
Number.isFinite(responseRevision) &&
|
|
4375
|
-
responseRevision === candidate.remoteBaseline.revision + 1
|
|
5481
|
+
hasAllAppliedResults
|
|
4376
5482
|
) {
|
|
4377
|
-
// The v2
|
|
4378
|
-
//
|
|
4379
|
-
|
|
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
|
+
);
|
|
4380
5491
|
candidate.remoteBaseline.revision = responseRevision;
|
|
4381
5492
|
if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
|
|
4382
5493
|
candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
|
|
4383
5494
|
}
|
|
4384
|
-
} else if (
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
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
|
+
);
|
|
4393
5504
|
}
|
|
4394
5505
|
candidate.pendingOperations = nextPending;
|
|
4395
5506
|
candidate.rejectedOperations = nextRejected;
|
|
@@ -4420,7 +5531,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4420
5531
|
);
|
|
4421
5532
|
} catch (error) {
|
|
4422
5533
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4423
|
-
|
|
5534
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4424
5535
|
}
|
|
4425
5536
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4426
5537
|
applyRemoteBaseline(candidate, freshBaseline);
|
|
@@ -4560,7 +5671,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4560
5671
|
const engine = {
|
|
4561
5672
|
init() {
|
|
4562
5673
|
return enqueue(async () => {
|
|
4563
|
-
|
|
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
|
+
}
|
|
4564
5682
|
return initialize();
|
|
4565
5683
|
});
|
|
4566
5684
|
},
|
|
@@ -4583,10 +5701,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4583
5701
|
|
|
4584
5702
|
waitForRemoteReady() {
|
|
4585
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
|
+
});
|
|
4586
5709
|
return Promise.resolve({
|
|
4587
5710
|
success: false,
|
|
4588
5711
|
stale: true,
|
|
4589
5712
|
reason: 'stale_session',
|
|
5713
|
+
failure,
|
|
4590
5714
|
generation: remoteReadyState.generation
|
|
4591
5715
|
});
|
|
4592
5716
|
}
|
|
@@ -4790,20 +5914,45 @@ const RTL_BULK_OPERATION_TYPES = new Set([
|
|
|
4790
5914
|
]);
|
|
4791
5915
|
const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
|
|
4792
5916
|
|
|
4793
|
-
const
|
|
4794
|
-
const
|
|
4795
|
-
|
|
4796
|
-
);
|
|
4797
|
-
const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
4798
|
-
operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
|
|
4799
|
-
);
|
|
4800
|
-
const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
|
|
4801
|
-
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)
|
|
4802
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;
|
|
4803
5938
|
const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
|
|
4804
5939
|
encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
|
|
4805
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
|
+
};
|
|
4806
5954
|
const rtlCloneDocuments = (documents = {}) => ({
|
|
5955
|
+
...clone(documents || {}),
|
|
4807
5956
|
root: clone(documents.root || null),
|
|
4808
5957
|
records: clone(documents.records || {}),
|
|
4809
5958
|
folders: clone(documents.folders || {}),
|
|
@@ -4812,6 +5961,24 @@ const rtlCloneDocuments = (documents = {}) => ({
|
|
|
4812
5961
|
ops: {}
|
|
4813
5962
|
});
|
|
4814
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
|
+
};
|
|
4815
5982
|
/**
|
|
4816
5983
|
* Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
|
|
4817
5984
|
* so the planner must not drop ids on a prefix guess either.
|
|
@@ -4840,7 +6007,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
|
|
|
4840
6007
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
4841
6008
|
return;
|
|
4842
6009
|
}
|
|
4843
|
-
if (value.some((id) => !
|
|
6010
|
+
if (value.some((id) => !rtlIsSafeDocumentId(id))) {
|
|
4844
6011
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
4845
6012
|
}
|
|
4846
6013
|
};
|
|
@@ -4935,9 +6102,10 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
4935
6102
|
export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
|
|
4936
6103
|
const errors = [];
|
|
4937
6104
|
const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
|
|
4938
|
-
toArray(body?.client?.capabilities).
|
|
4939
|
-
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
4940
|
-
|
|
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
|
+
));
|
|
4941
6109
|
let requestBytes = 0;
|
|
4942
6110
|
try {
|
|
4943
6111
|
requestBytes = rtlByteLength(JSON.stringify(body));
|
|
@@ -4948,7 +6116,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
4948
6116
|
if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
|
|
4949
6117
|
errors.push('unsupported_protocol_version');
|
|
4950
6118
|
}
|
|
4951
|
-
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(
|
|
6119
|
+
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(body.requestId) ||
|
|
6120
|
+
!rtlIsSafeDocumentId(body.requestId)) {
|
|
4952
6121
|
errors.push('invalid_request_id');
|
|
4953
6122
|
}
|
|
4954
6123
|
if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
|
|
@@ -4962,8 +6131,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
4962
6131
|
}
|
|
4963
6132
|
const operationIds = new Set();
|
|
4964
6133
|
operations.forEach((operation, index) => {
|
|
4965
|
-
const id =
|
|
4966
|
-
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`);
|
|
4967
6136
|
if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
|
|
4968
6137
|
operationIds.add(id);
|
|
4969
6138
|
if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
|
|
@@ -4989,12 +6158,16 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
4989
6158
|
if (!toFiniteTimestamp(operation?.createdAt)) {
|
|
4990
6159
|
errors.push(`operation_${index}_invalid_created_at`);
|
|
4991
6160
|
}
|
|
4992
|
-
if (rtlOperationRecordId(operation)
|
|
6161
|
+
if (rtlOperationRecordId(operation) && !rtlIsSafeDocumentId(rtlOperationRecordId(operation))) {
|
|
4993
6162
|
errors.push(`operation_${index}_invalid_record_id`);
|
|
4994
6163
|
}
|
|
4995
|
-
if (rtlOperationFolderId(operation)
|
|
6164
|
+
if (rtlOperationFolderId(operation) && !rtlIsSafeDocumentId(rtlOperationFolderId(operation))) {
|
|
4996
6165
|
errors.push(`operation_${index}_invalid_folder_id`);
|
|
4997
6166
|
}
|
|
6167
|
+
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
6168
|
+
if (trashEntryId && !rtlIsSafeDocumentId(trashEntryId)) {
|
|
6169
|
+
errors.push(`operation_${index}_invalid_trash_entry_id`);
|
|
6170
|
+
}
|
|
4998
6171
|
});
|
|
4999
6172
|
|
|
5000
6173
|
return {ok: errors.length === 0, errors, requestBytes, operations};
|
|
@@ -5030,13 +6203,35 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
5030
6203
|
if (recordId) {
|
|
5031
6204
|
recordIds.add(recordId);
|
|
5032
6205
|
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
6206
|
+
trashEntryIds.add(normalizeId(payload.trashEntryId) || `record:${recordId}`);
|
|
5033
6207
|
}
|
|
5034
6208
|
break;
|
|
5035
6209
|
}
|
|
5036
6210
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
6211
|
+
case OPERATION_TYPES.FOLDER_RESTORE:
|
|
5037
6212
|
case OPERATION_TYPES.TRASH_PURGE: {
|
|
5038
6213
|
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
5039
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
|
+
}
|
|
5040
6235
|
break;
|
|
5041
6236
|
}
|
|
5042
6237
|
case OPERATION_TYPES.RECORD_REORDER:
|
|
@@ -5125,7 +6320,8 @@ const rtlApplyOperationToPartialDocuments = ({
|
|
|
5125
6320
|
|
|
5126
6321
|
const rtlCanRestoreLocalFolder = (root, folder) => {
|
|
5127
6322
|
if (!folder?.id) return false;
|
|
5128
|
-
const
|
|
6323
|
+
const tombstones = root?.deletedFolderTombstones;
|
|
6324
|
+
const tombstone = rtlOwn(tombstones, folder.id) ? tombstones[folder.id] : null;
|
|
5129
6325
|
return !tombstone;
|
|
5130
6326
|
};
|
|
5131
6327
|
|
|
@@ -5136,8 +6332,9 @@ const rtlBuildLocalFolderDocument = (folder, now) => {
|
|
|
5136
6332
|
}, {now}).folders[folder.id] || null;
|
|
5137
6333
|
};
|
|
5138
6334
|
|
|
5139
|
-
const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
5140
|
-
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;
|
|
5141
6338
|
const localFolder = toArray(localState?.folders)
|
|
5142
6339
|
.find((folder) => folder?.id === folderId);
|
|
5143
6340
|
if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
|
|
@@ -5152,7 +6349,19 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
|
5152
6349
|
|
|
5153
6350
|
const rtlSetRoot = (target, source) => {
|
|
5154
6351
|
Object.keys(target).forEach((key) => delete target[key]);
|
|
5155
|
-
|
|
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
|
+
});
|
|
5156
6365
|
};
|
|
5157
6366
|
|
|
5158
6367
|
/**
|
|
@@ -5163,22 +6372,48 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5163
6372
|
operations,
|
|
5164
6373
|
localState = {},
|
|
5165
6374
|
now = Date.now(),
|
|
5166
|
-
requireLifecycleGeneration = true
|
|
6375
|
+
requireLifecycleGeneration = true,
|
|
6376
|
+
plannerMode = RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT
|
|
5167
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;
|
|
5168
6382
|
const previousDocuments = rtlCloneDocuments(documents);
|
|
5169
6383
|
const nextDocuments = rtlCloneDocuments(documents);
|
|
5170
6384
|
const preservedExpandedGroups = previousDocuments.root &&
|
|
5171
6385
|
Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
|
|
5172
6386
|
? clone(previousDocuments.root.expandedGroups)
|
|
5173
6387
|
: undefined;
|
|
5174
|
-
|
|
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 || {}) : {};
|
|
5175
6393
|
const operationResults = [];
|
|
5176
6394
|
let changed = false;
|
|
5177
6395
|
if (!nextDocuments.root) {
|
|
5178
|
-
return {
|
|
6396
|
+
return {
|
|
6397
|
+
requiresMigration: true,
|
|
6398
|
+
plannerMode: selectedPlannerMode,
|
|
6399
|
+
previousDocuments,
|
|
6400
|
+
nextDocuments,
|
|
6401
|
+
operationResults
|
|
6402
|
+
};
|
|
5179
6403
|
}
|
|
5180
6404
|
|
|
5181
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
|
+
}
|
|
5182
6417
|
if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
5183
6418
|
operationResults.push({
|
|
5184
6419
|
id: operation.id || null,
|
|
@@ -5193,21 +6428,33 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5193
6428
|
const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
|
|
5194
6429
|
const recordId = rtlOperationRecordId(operation);
|
|
5195
6430
|
const folderId = rtlOperationFolderId(operation);
|
|
6431
|
+
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
5196
6432
|
let applied = null;
|
|
5197
6433
|
let reason = null;
|
|
5198
6434
|
let lifecycleTombstoneId = null;
|
|
5199
6435
|
|
|
5200
|
-
|
|
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) {
|
|
5201
6446
|
case OPERATION_TYPES.RECORD_CREATE: {
|
|
5202
6447
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
5203
|
-
if (nextDocuments.records
|
|
6448
|
+
if (rtlOwn(nextDocuments.records, recordId)) { reason = 'already_exists'; break; }
|
|
5204
6449
|
const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
|
|
5205
6450
|
applied = rtlApplyOperationToPartialDocuments({
|
|
5206
6451
|
...nextDocuments,
|
|
5207
6452
|
operation: {...operation, payload: {...payload, folderId: targetFolderId}},
|
|
5208
6453
|
now: operationNow
|
|
5209
6454
|
});
|
|
5210
|
-
const recordDocument = applied.documents.records
|
|
6455
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6456
|
+
? applied.documents.records[recordId]
|
|
6457
|
+
: null;
|
|
5211
6458
|
if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
|
|
5212
6459
|
const candidateRoot = clone(applied.documents.root);
|
|
5213
6460
|
const candidateFolders = clone(nextDocuments.folders);
|
|
@@ -5216,7 +6463,8 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5216
6463
|
folders: candidateFolders,
|
|
5217
6464
|
localState: normalizedLocalState,
|
|
5218
6465
|
folderId: targetFolderId,
|
|
5219
|
-
now: operationNow
|
|
6466
|
+
now: operationNow,
|
|
6467
|
+
allowContext
|
|
5220
6468
|
});
|
|
5221
6469
|
if (!targetFolder) {
|
|
5222
6470
|
reason = 'folder_not_found';
|
|
@@ -5230,13 +6478,17 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5230
6478
|
break;
|
|
5231
6479
|
}
|
|
5232
6480
|
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
5233
|
-
const existing = nextDocuments.records
|
|
6481
|
+
const existing = rtlOwn(nextDocuments.records, recordId)
|
|
6482
|
+
? nextDocuments.records[recordId]
|
|
6483
|
+
: null;
|
|
5234
6484
|
if (!recordId || !existing) {
|
|
5235
6485
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
5236
6486
|
break;
|
|
5237
6487
|
}
|
|
5238
6488
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5239
|
-
const recordDocument = applied.documents.records
|
|
6489
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6490
|
+
? applied.documents.records[recordId]
|
|
6491
|
+
: null;
|
|
5240
6492
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
5241
6493
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5242
6494
|
nextDocuments.records[recordId] = recordDocument;
|
|
@@ -5244,7 +6496,9 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5244
6496
|
break;
|
|
5245
6497
|
}
|
|
5246
6498
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
5247
|
-
const existing = nextDocuments.records
|
|
6499
|
+
const existing = rtlOwn(nextDocuments.records, recordId)
|
|
6500
|
+
? nextDocuments.records[recordId]
|
|
6501
|
+
: null;
|
|
5248
6502
|
if (!recordId || !existing) {
|
|
5249
6503
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
5250
6504
|
break;
|
|
@@ -5256,7 +6510,9 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5256
6510
|
operation: {...operation, payload: {...payload, targetFolderId}},
|
|
5257
6511
|
now: operationNow
|
|
5258
6512
|
});
|
|
5259
|
-
const recordDocument = applied.documents.records
|
|
6513
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6514
|
+
? applied.documents.records[recordId]
|
|
6515
|
+
: null;
|
|
5260
6516
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
5261
6517
|
const candidateRoot = clone(applied.documents.root);
|
|
5262
6518
|
const candidateFolders = clone(nextDocuments.folders);
|
|
@@ -5265,10 +6521,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5265
6521
|
folders: candidateFolders,
|
|
5266
6522
|
localState: normalizedLocalState,
|
|
5267
6523
|
folderId: targetFolderId,
|
|
5268
|
-
now: operationNow
|
|
6524
|
+
now: operationNow,
|
|
6525
|
+
allowContext
|
|
5269
6526
|
});
|
|
5270
6527
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
5271
|
-
if (candidateFolders
|
|
6528
|
+
if (rtlOwn(candidateFolders, sourceFolderId)) {
|
|
5272
6529
|
candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
5273
6530
|
candidateFolders[sourceFolderId].recordOrder
|
|
5274
6531
|
).filter((id) => id !== recordId);
|
|
@@ -5285,8 +6542,14 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5285
6542
|
}
|
|
5286
6543
|
case OPERATION_TYPES.RECORD_REORDER: {
|
|
5287
6544
|
const targetFolderId = safeFolderId(payload.folderId);
|
|
5288
|
-
const targetFolder = nextDocuments.folders
|
|
6545
|
+
const targetFolder = rtlOwn(nextDocuments.folders, targetFolderId)
|
|
6546
|
+
? nextDocuments.folders[targetFolderId]
|
|
6547
|
+
: null;
|
|
5289
6548
|
const requested = rtlPayloadOrder(operation, ['recordIds']);
|
|
6549
|
+
if (rtlHasUnsafeOrderId(requested)) {
|
|
6550
|
+
reason = 'invalid_document_id';
|
|
6551
|
+
break;
|
|
6552
|
+
}
|
|
5290
6553
|
if (!targetFolder || requested.length === 0) {
|
|
5291
6554
|
reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
|
|
5292
6555
|
break;
|
|
@@ -5300,14 +6563,50 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5300
6563
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
5301
6564
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
5302
6565
|
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
|
|
5303
|
-
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);
|
|
5304
6602
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5305
6603
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5306
|
-
const
|
|
5307
|
-
|
|
5308
|
-
|
|
6604
|
+
const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
|
|
6605
|
+
? applied.documents.trash[operationTrashEntryId]
|
|
6606
|
+
: null;
|
|
6607
|
+
if (trashDocument) nextDocuments.trash[operationTrashEntryId] = trashDocument;
|
|
5309
6608
|
delete nextDocuments.records[recordId];
|
|
5310
|
-
if (nextDocuments.folders
|
|
6609
|
+
if (rtlOwn(nextDocuments.folders, sourceFolderId)) {
|
|
5311
6610
|
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
5312
6611
|
nextDocuments.folders[sourceFolderId].recordOrder
|
|
5313
6612
|
).filter((id) => id !== recordId);
|
|
@@ -5316,8 +6615,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5316
6615
|
break;
|
|
5317
6616
|
}
|
|
5318
6617
|
case OPERATION_TYPES.RECORD_RESTORE: {
|
|
5319
|
-
const
|
|
5320
|
-
const trashEntry = nextDocuments.trash
|
|
6618
|
+
const restoreTrashEntryId = trashEntryId;
|
|
6619
|
+
const trashEntry = rtlOwn(nextDocuments.trash, restoreTrashEntryId)
|
|
6620
|
+
? nextDocuments.trash[restoreTrashEntryId]
|
|
6621
|
+
: null;
|
|
5321
6622
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
5322
6623
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
5323
6624
|
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
@@ -5333,11 +6634,23 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5333
6634
|
break;
|
|
5334
6635
|
}
|
|
5335
6636
|
const restoredRecordId = normalizeId(trashEntry.entityId);
|
|
5336
|
-
if (!restoredRecordId || nextDocuments.records
|
|
6637
|
+
if (!restoredRecordId || rtlOwn(nextDocuments.records, restoredRecordId)) {
|
|
5337
6638
|
reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
|
|
5338
6639
|
break;
|
|
5339
6640
|
}
|
|
5340
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
|
+
}
|
|
5341
6654
|
// Explicit legacy compatibility is planner-local. It does not change the
|
|
5342
6655
|
// original operation, its wire bytes, or the strict durable reducer contract.
|
|
5343
6656
|
const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
|
|
@@ -5348,11 +6661,16 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5348
6661
|
operation: reducerOperation,
|
|
5349
6662
|
now: operationNow
|
|
5350
6663
|
});
|
|
5351
|
-
const restoredRecord = applied.documents.records
|
|
6664
|
+
const restoredRecord = rtlOwn(applied.documents.records, restoredRecordId)
|
|
6665
|
+
? applied.documents.records[restoredRecordId]
|
|
6666
|
+
: null;
|
|
5352
6667
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
5353
6668
|
const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
|
|
5354
|
-
const targetFolder = nextDocuments.folders
|
|
5355
|
-
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);
|
|
5356
6674
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
5357
6675
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5358
6676
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
@@ -5368,8 +6686,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5368
6686
|
break;
|
|
5369
6687
|
}
|
|
5370
6688
|
case OPERATION_TYPES.TRASH_PURGE: {
|
|
5371
|
-
const
|
|
5372
|
-
const trashEntry = nextDocuments.trash
|
|
6689
|
+
const purgeTrashEntryId = trashEntryId;
|
|
6690
|
+
const trashEntry = rtlOwn(nextDocuments.trash, purgeTrashEntryId)
|
|
6691
|
+
? nextDocuments.trash[purgeTrashEntryId]
|
|
6692
|
+
: null;
|
|
5373
6693
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
5374
6694
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
5375
6695
|
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
@@ -5380,20 +6700,24 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5380
6700
|
reason = 'lifecycle_conflict';
|
|
5381
6701
|
break;
|
|
5382
6702
|
}
|
|
5383
|
-
delete nextDocuments.trash[
|
|
6703
|
+
delete nextDocuments.trash[purgeTrashEntryId];
|
|
5384
6704
|
changed = true;
|
|
5385
6705
|
break;
|
|
5386
6706
|
}
|
|
5387
6707
|
case OPERATION_TYPES.FOLDER_CREATE:
|
|
5388
6708
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
5389
6709
|
if (!folderId) { reason = 'missing_folder_id'; break; }
|
|
5390
|
-
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders
|
|
6710
|
+
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !rtlOwn(nextDocuments.folders, folderId)) {
|
|
5391
6711
|
reason = 'folder_not_found';
|
|
5392
6712
|
break;
|
|
5393
6713
|
}
|
|
5394
|
-
const previousOrder = nextDocuments.folders
|
|
6714
|
+
const previousOrder = rtlOwn(nextDocuments.folders, folderId)
|
|
6715
|
+
? nextDocuments.folders[folderId]?.recordOrder || []
|
|
6716
|
+
: [];
|
|
5395
6717
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5396
|
-
const folderDocument = applied.documents.folders
|
|
6718
|
+
const folderDocument = rtlOwn(applied.documents.folders, folderId)
|
|
6719
|
+
? applied.documents.folders[folderId]
|
|
6720
|
+
: null;
|
|
5397
6721
|
if (!folderDocument) { reason = 'folder_not_found'; break; }
|
|
5398
6722
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5399
6723
|
nextDocuments.folders[folderId] = {
|
|
@@ -5413,27 +6737,37 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5413
6737
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
5414
6738
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
5415
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
|
+
}
|
|
5416
6750
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5417
6751
|
const appliedRoot = applied.documents.root;
|
|
5418
6752
|
if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
|
|
5419
6753
|
appliedRoot.folderOrder = rtlMergeOrder(
|
|
5420
6754
|
rtlPayloadOrder(operation, ['folderOrder', 'order']),
|
|
5421
6755
|
nextDocuments.root.folderOrder,
|
|
5422
|
-
normalizedLocalState.folderOrder
|
|
6756
|
+
...(allowContext ? [normalizedLocalState.folderOrder] : [])
|
|
5423
6757
|
);
|
|
5424
6758
|
} else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
|
|
5425
|
-
appliedRoot.groupOrder =
|
|
6759
|
+
appliedRoot.groupOrder = rtlMergeOpaqueOrder(
|
|
5426
6760
|
rtlPayloadOrder(operation, ['groupOrder', 'order']),
|
|
5427
6761
|
nextDocuments.root.groupOrder,
|
|
5428
|
-
normalizedLocalState.groupOrder
|
|
5429
|
-
)
|
|
6762
|
+
...(allowContext ? [normalizedLocalState.groupOrder] : [])
|
|
6763
|
+
);
|
|
5430
6764
|
}
|
|
5431
6765
|
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
5432
6766
|
changed = true;
|
|
5433
6767
|
break;
|
|
5434
6768
|
}
|
|
5435
6769
|
default:
|
|
5436
|
-
reason = 'unsupported_operation';
|
|
6770
|
+
if (!reason) reason = 'unsupported_operation';
|
|
5437
6771
|
}
|
|
5438
6772
|
if (applied && !reason && lifecycleTombstoneId) {
|
|
5439
6773
|
const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
|
|
@@ -5447,6 +6781,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5447
6781
|
id: operation.id || null,
|
|
5448
6782
|
type: operation.type || null,
|
|
5449
6783
|
applied: !reason,
|
|
6784
|
+
...(reason === 'already_deleted' ? {status: 'noop'} : {}),
|
|
5450
6785
|
reason
|
|
5451
6786
|
});
|
|
5452
6787
|
}
|
|
@@ -5468,6 +6803,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5468
6803
|
});
|
|
5469
6804
|
return {
|
|
5470
6805
|
requiresMigration: false,
|
|
6806
|
+
plannerMode: selectedPlannerMode,
|
|
5471
6807
|
previousDocuments,
|
|
5472
6808
|
nextDocuments,
|
|
5473
6809
|
changes,
|
|
@@ -5475,6 +6811,19 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5475
6811
|
};
|
|
5476
6812
|
};
|
|
5477
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
|
+
|
|
5478
6827
|
/**
|
|
5479
6828
|
* Counts all billable writes before a transaction reserves quota.
|
|
5480
6829
|
*/
|
|
@@ -5664,9 +7013,18 @@ export default {
|
|
|
5664
7013
|
isPendingChannelFolderId,
|
|
5665
7014
|
isValidChannelName,
|
|
5666
7015
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
7016
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
7017
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
5667
7018
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
7019
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
5668
7020
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5669
7021
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
7022
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
7023
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
7024
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
7025
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
7026
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
7027
|
+
toRecordTimeLabelCloudFailureError,
|
|
5670
7028
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
5671
7029
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
5672
7030
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
@@ -5676,6 +7034,11 @@ export default {
|
|
|
5676
7034
|
RTL_MAX_REQUEST_BYTES,
|
|
5677
7035
|
RTL_MAX_TARGET_WRITES,
|
|
5678
7036
|
RTL_TRASH_RETENTION_MS,
|
|
7037
|
+
RECORD_TIMELABEL_PLANNER_MODES,
|
|
7038
|
+
RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
|
|
7039
|
+
isRecordTimeLabelSafeDocumentId,
|
|
7040
|
+
normalizeRecordTimeLabelImmutableId,
|
|
7041
|
+
normalizeRecordTimeLabelPlannerId,
|
|
5679
7042
|
OPERATION_TYPES,
|
|
5680
7043
|
RECORD_TIMELABEL_SYNC_MODES,
|
|
5681
7044
|
RECORD_TIMELABEL_CLOUD_SCHEMAS,
|
|
@@ -5714,6 +7077,8 @@ export default {
|
|
|
5714
7077
|
extendFirestoreV2OperationReadPlanWithRecords,
|
|
5715
7078
|
extendFirestoreV2OperationReadPlanWithTrash,
|
|
5716
7079
|
planFirestoreV2OperationChanges,
|
|
7080
|
+
planRecordTimeLabelDeterministicOperationChanges,
|
|
7081
|
+
planFirestoreV2DeterministicOperationChanges,
|
|
5717
7082
|
estimateFirestoreV2WriteUnits,
|
|
5718
7083
|
normalizeRecordTimeLabelOperationResults,
|
|
5719
7084
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
@@ -5724,6 +7089,7 @@ export default {
|
|
|
5724
7089
|
flushPendingOperations,
|
|
5725
7090
|
mergeRemoteStateIntoLocal,
|
|
5726
7091
|
applyOperation,
|
|
7092
|
+
applyExplicitRecordUpdate,
|
|
5727
7093
|
applyRecordTimeLabelOperation,
|
|
5728
7094
|
createOperation,
|
|
5729
7095
|
createRecordTimeLabelSyncEngine,
|