@recordtimelabel/core 0.6.2 → 0.6.5
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 +31 -11
- package/package.json +1 -1
- package/src/changefeed.js +67 -8
- package/src/firestore-v2.js +11 -0
- package/src/index.js +1922 -390
- package/src/protocol.js +438 -104
package/src/index.js
CHANGED
|
@@ -1,33 +1,47 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
3
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
3
4
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
4
6
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5
7
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
8
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
9
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
6
10
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
7
11
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
8
12
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
9
13
|
buildRecordTimeLabelRequestId,
|
|
10
14
|
createRecordTimeLabelTransportFailureResults,
|
|
15
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
11
16
|
normalizeRecordTimeLabelCloudFailure,
|
|
12
17
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
13
18
|
normalizeRecordTimeLabelOperationResults,
|
|
19
|
+
normalizeRecordTimeLabelImmutableId,
|
|
20
|
+
normalizeRecordTimeLabelPlannerId,
|
|
14
21
|
toRecordTimeLabelCloudFailureError,
|
|
15
22
|
toRecordTimeLabelWireOperation
|
|
16
23
|
} from './protocol.js';
|
|
17
24
|
|
|
18
25
|
export {
|
|
19
26
|
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
27
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
20
28
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
29
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
21
30
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
22
31
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
32
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
33
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
23
34
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
24
35
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
25
36
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
26
37
|
buildRecordTimeLabelRequestId,
|
|
27
38
|
createRecordTimeLabelTransportFailureResults,
|
|
39
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
28
40
|
normalizeRecordTimeLabelCloudFailure,
|
|
29
41
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
30
42
|
normalizeRecordTimeLabelOperationResults,
|
|
43
|
+
normalizeRecordTimeLabelImmutableId,
|
|
44
|
+
normalizeRecordTimeLabelPlannerId,
|
|
31
45
|
toRecordTimeLabelCloudFailureError,
|
|
32
46
|
toRecordTimeLabelWireOperation
|
|
33
47
|
} from './protocol.js';
|
|
@@ -127,7 +141,7 @@ export {
|
|
|
127
141
|
selectBestMatchingTwitchVod
|
|
128
142
|
};
|
|
129
143
|
|
|
130
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
144
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.5';
|
|
131
145
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
132
146
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
133
147
|
'fifo-retry-fence',
|
|
@@ -137,7 +151,10 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
137
151
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
138
152
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
139
153
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
140
|
-
|
|
154
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
155
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
156
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
157
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS
|
|
141
158
|
]);
|
|
142
159
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
143
160
|
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
@@ -145,6 +162,11 @@ export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
|
|
|
145
162
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
146
163
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
147
164
|
export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
165
|
+
export const RECORD_TIMELABEL_PLANNER_MODES = Object.freeze({
|
|
166
|
+
DETERMINISTIC: 'deterministic',
|
|
167
|
+
LEGACY_CONTEXT: 'legacy-context'
|
|
168
|
+
});
|
|
169
|
+
export const RECORD_TIMELABEL_SAFE_ID_MAX_BYTES = 512;
|
|
148
170
|
|
|
149
171
|
export const OPERATION_TYPES = Object.freeze({
|
|
150
172
|
RECORD_CREATE: 'record.create',
|
|
@@ -195,6 +217,7 @@ export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
|
|
|
195
217
|
OWNER_MISMATCH: 'operation_owner_mismatch',
|
|
196
218
|
WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
|
|
197
219
|
ID_CONFLICT: 'operation_id_conflict',
|
|
220
|
+
INVALID_DOCUMENT_ID: 'invalid_document_id',
|
|
198
221
|
LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
|
|
199
222
|
LIFECYCLE_CONFLICT: 'lifecycle_conflict'
|
|
200
223
|
});
|
|
@@ -211,7 +234,20 @@ const clone = (value, seen = new WeakMap()) => {
|
|
|
211
234
|
const copy = Array.isArray(value) ? [] : {};
|
|
212
235
|
seen.set(value, copy);
|
|
213
236
|
Reflect.ownKeys(value).forEach((key) => {
|
|
214
|
-
|
|
237
|
+
// Assignment to the magic `__proto__` key invokes the legacy setter on a
|
|
238
|
+
// normal object. Define the data property explicitly so cloning untrusted
|
|
239
|
+
// document maps cannot alter the clone's prototype (or any global
|
|
240
|
+
// prototype through a later merge).
|
|
241
|
+
if (Array.isArray(copy) && key === 'length') {
|
|
242
|
+
copy.length = value.length;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
Object.defineProperty(copy, key, {
|
|
246
|
+
value: clone(value[key], seen),
|
|
247
|
+
enumerable: true,
|
|
248
|
+
configurable: true,
|
|
249
|
+
writable: true
|
|
250
|
+
});
|
|
215
251
|
});
|
|
216
252
|
return copy;
|
|
217
253
|
};
|
|
@@ -284,9 +320,21 @@ const normalizeId = (value) => {
|
|
|
284
320
|
return String(value).trim();
|
|
285
321
|
};
|
|
286
322
|
|
|
323
|
+
const RTL_RESERVED_DOCUMENT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
324
|
+
const RTL_SAFE_DOCUMENT_ID_MAX_BYTES = 512;
|
|
325
|
+
const rtlIsSafeDocumentId = (value) => {
|
|
326
|
+
// Document identity is a wire-level string. Never coerce numbers (or trim
|
|
327
|
+
// any other value) before applying the shared immutable-ID contract.
|
|
328
|
+
if (typeof value !== 'string') return false;
|
|
329
|
+
const normalized = normalizeRecordTimeLabelImmutableId(value);
|
|
330
|
+
return normalized !== null && !RTL_RESERVED_DOCUMENT_KEYS.has(normalized);
|
|
331
|
+
};
|
|
332
|
+
|
|
287
333
|
const safeFolderId = (folderId) => {
|
|
288
334
|
const normalized = normalizeId(folderId);
|
|
289
|
-
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized))
|
|
335
|
+
if (!normalized || !rtlIsSafeDocumentId(normalized) || VIRTUAL_FOLDER_IDS.has(normalized)) {
|
|
336
|
+
return DEFAULT_FOLDER_ID;
|
|
337
|
+
}
|
|
290
338
|
return normalized;
|
|
291
339
|
};
|
|
292
340
|
|
|
@@ -316,7 +364,7 @@ const normalizeTombstones = (value) => {
|
|
|
316
364
|
|
|
317
365
|
return entries.reduce((result, [rawId, rawTombstone]) => {
|
|
318
366
|
const id = normalizeId(rawId);
|
|
319
|
-
if (!id) return result;
|
|
367
|
+
if (!id || !rtlIsSafeDocumentId(id)) return result;
|
|
320
368
|
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
321
369
|
? { ...rawTombstone }
|
|
322
370
|
: {};
|
|
@@ -341,7 +389,8 @@ const normalizeTrashEntries = (value) => {
|
|
|
341
389
|
const id = normalizeId(rawId || rawEntry.id);
|
|
342
390
|
const kind = normalizeId(rawEntry.kind);
|
|
343
391
|
const entityId = normalizeId(rawEntry.entityId);
|
|
344
|
-
if (!id || !entityId || !
|
|
392
|
+
if (!id || !entityId || !rtlIsSafeDocumentId(id) || !rtlIsSafeDocumentId(entityId) ||
|
|
393
|
+
!new Set(['record', 'folder']).has(kind)) return result;
|
|
345
394
|
const deletedAt = toFiniteTimestamp(rawEntry.deletedAt);
|
|
346
395
|
const purgeAt = toFiniteTimestamp(rawEntry.purgeAt) ||
|
|
347
396
|
((deletedAt || Date.now()) + RTL_TRASH_RETENTION_MS);
|
|
@@ -366,7 +415,7 @@ export const getActiveTrashEntries = (value, now = Date.now()) => Object.values(
|
|
|
366
415
|
const mergeTombstones = (left = {}, right = {}) => {
|
|
367
416
|
const merged = { ...left };
|
|
368
417
|
Object.entries(right).forEach(([id, tombstone]) => {
|
|
369
|
-
const current = merged[id];
|
|
418
|
+
const current = rtlOwn(merged, id) ? merged[id] : null;
|
|
370
419
|
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
371
420
|
const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
|
|
372
421
|
if (!current || nextGeneration > currentGeneration ||
|
|
@@ -381,7 +430,7 @@ const mergeTombstones = (left = {}, right = {}) => {
|
|
|
381
430
|
const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
|
|
382
431
|
const merged = normalizeTrashEntries(remoteEntries);
|
|
383
432
|
Object.entries(normalizeTrashEntries(localEntries)).forEach(([id, entry]) => {
|
|
384
|
-
const current = merged[id];
|
|
433
|
+
const current = rtlOwn(merged, id) ? merged[id] : null;
|
|
385
434
|
const currentVersion = Number(current?.lifecycleGeneration || 0);
|
|
386
435
|
const nextVersion = Number(entry.lifecycleGeneration || 0);
|
|
387
436
|
if (!current || nextVersion > currentVersion ||
|
|
@@ -517,10 +566,37 @@ export const mergeRecords = (left, right) => {
|
|
|
517
566
|
}
|
|
518
567
|
});
|
|
519
568
|
|
|
520
|
-
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
521
569
|
return merged;
|
|
522
570
|
};
|
|
523
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Apply a user-authored record patch without using enrichment/LWW merge
|
|
574
|
+
* semantics. The latter intentionally keeps rich fields from either side,
|
|
575
|
+
* which is correct for remote snapshot reconciliation but would make an
|
|
576
|
+
* explicit `{hasVod: false}` or `{title: ''}` impossible to persist. An
|
|
577
|
+
* explicit patch is presence-based: omitted keys are retained, while every
|
|
578
|
+
* own key (including `undefined`, `null`, `false`, and the empty string) is
|
|
579
|
+
* authoritative for this operation.
|
|
580
|
+
*/
|
|
581
|
+
export const applyExplicitRecordUpdate = (record = {}, patch = {}, operationTime = Date.now()) => {
|
|
582
|
+
const current = record && typeof record === 'object' ? record : {};
|
|
583
|
+
const source = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
584
|
+
const next = {...current};
|
|
585
|
+
Object.keys(source).forEach((key) => {
|
|
586
|
+
if (RTL_RESERVED_DOCUMENT_KEYS.has(key)) return;
|
|
587
|
+
Object.defineProperty(next, key, {
|
|
588
|
+
value: source[key],
|
|
589
|
+
enumerable: true,
|
|
590
|
+
configurable: true,
|
|
591
|
+
writable: true
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
if (!Object.prototype.hasOwnProperty.call(source, 'updatedAt')) {
|
|
595
|
+
next.updatedAt = operationTime;
|
|
596
|
+
}
|
|
597
|
+
return next;
|
|
598
|
+
};
|
|
599
|
+
|
|
524
600
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
525
601
|
const source = records && typeof records === 'object' ? records : {};
|
|
526
602
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
@@ -533,7 +609,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
533
609
|
safeList.forEach((record, index) => {
|
|
534
610
|
if (!record || typeof record !== 'object') return;
|
|
535
611
|
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
536
|
-
if (!id) return;
|
|
612
|
+
if (!id || !rtlIsSafeDocumentId(id)) return;
|
|
537
613
|
|
|
538
614
|
const targetFolderId = resolveRecordFolderId(folderId, record);
|
|
539
615
|
|
|
@@ -549,7 +625,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
549
625
|
const folderTombstone = folderTombstones[targetFolderId];
|
|
550
626
|
if (folderTombstone) return;
|
|
551
627
|
|
|
552
|
-
if (!normalized
|
|
628
|
+
if (!rtlOwn(normalized, targetFolderId)) normalized[targetFolderId] = [];
|
|
553
629
|
|
|
554
630
|
const existing = placements.get(id);
|
|
555
631
|
if (!existing) {
|
|
@@ -598,7 +674,7 @@ const ensureFolders = (folders = []) => {
|
|
|
598
674
|
toArray(folders).forEach((folder) => {
|
|
599
675
|
if (!folder || typeof folder !== 'object') return;
|
|
600
676
|
const id = normalizeId(folder.id);
|
|
601
|
-
if (!id || seen.has(id)) return;
|
|
677
|
+
if (!id || !rtlIsSafeDocumentId(id) || seen.has(id)) return;
|
|
602
678
|
seen.add(id);
|
|
603
679
|
nextFolders.push({ ...folder, id });
|
|
604
680
|
});
|
|
@@ -625,10 +701,10 @@ const ensureFolders = (folders = []) => {
|
|
|
625
701
|
const ensureRecordFolders = (records, folders) => {
|
|
626
702
|
const nextRecords = { ...records };
|
|
627
703
|
folders.forEach((folder) => {
|
|
628
|
-
if (!nextRecords
|
|
704
|
+
if (!rtlOwn(nextRecords, folder.id)) nextRecords[folder.id] = [];
|
|
629
705
|
});
|
|
630
|
-
if (!nextRecords
|
|
631
|
-
if (!nextRecords
|
|
706
|
+
if (!rtlOwn(nextRecords, 'all')) nextRecords.all = [];
|
|
707
|
+
if (!rtlOwn(nextRecords, DEFAULT_FOLDER_ID)) nextRecords[DEFAULT_FOLDER_ID] = [];
|
|
632
708
|
nextRecords.all = [];
|
|
633
709
|
return nextRecords;
|
|
634
710
|
};
|
|
@@ -924,8 +1000,12 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
924
1000
|
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
925
1001
|
if (!record) return normalized;
|
|
926
1002
|
const recordId = normalizeId(record.id || payload.recordId);
|
|
927
|
-
if (!recordId) return normalized;
|
|
928
|
-
const
|
|
1003
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1004
|
+
const rawFolderId = normalizeId(payload.folderId || record.folderId);
|
|
1005
|
+
if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
|
|
1006
|
+
const tombstone = rtlOwn(nextState.deletedRecordTombstones, recordId)
|
|
1007
|
+
? nextState.deletedRecordTombstones[recordId]
|
|
1008
|
+
: null;
|
|
929
1009
|
if (tombstone) return normalized;
|
|
930
1010
|
|
|
931
1011
|
const folderId = safeFolderId(payload.folderId || record.folderId);
|
|
@@ -946,24 +1026,25 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
946
1026
|
|
|
947
1027
|
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
948
1028
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
949
|
-
if (!recordId) return normalized;
|
|
1029
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
950
1030
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
951
1031
|
if (!entry) return normalized;
|
|
952
1032
|
const patch = payload.patch || payload.record || {};
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
});
|
|
1033
|
+
if (Object.keys(patch).some((key) => RTL_RESERVED_DOCUMENT_KEYS.has(key))) {
|
|
1034
|
+
return normalized;
|
|
1035
|
+
}
|
|
1036
|
+
const updatedRecord = applyExplicitRecordUpdate(entry.record, patch, operationTime);
|
|
1037
|
+
updatedRecord.id = recordId;
|
|
959
1038
|
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
960
1039
|
break;
|
|
961
1040
|
}
|
|
962
1041
|
|
|
963
1042
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
964
1043
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
965
|
-
const
|
|
966
|
-
if (!recordId)
|
|
1044
|
+
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
1045
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId) ||
|
|
1046
|
+
!rtlIsSafeDocumentId(rawTargetFolderId)) return normalized;
|
|
1047
|
+
const targetFolderId = safeFolderId(rawTargetFolderId);
|
|
967
1048
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
968
1049
|
if (!entry) return normalized;
|
|
969
1050
|
const movedRecord = {
|
|
@@ -981,7 +1062,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
981
1062
|
}
|
|
982
1063
|
|
|
983
1064
|
case OPERATION_TYPES.RECORD_REORDER: {
|
|
984
|
-
const
|
|
1065
|
+
const rawFolderId = normalizeId(payload.folderId);
|
|
1066
|
+
if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
|
|
1067
|
+
const folderId = safeFolderId(rawFolderId);
|
|
985
1068
|
const existingFolderRecords = toArray(nextState.records?.[folderId]);
|
|
986
1069
|
const payloadRecords = toArray(payload.records);
|
|
987
1070
|
const recordsById = new Map();
|
|
@@ -1007,6 +1090,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1007
1090
|
? payload.recordIds
|
|
1008
1091
|
: payloadRecords.map((record) => getRecordId(record))
|
|
1009
1092
|
);
|
|
1093
|
+
if (orderedIds.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
|
|
1010
1094
|
if (orderedIds.length === 0) return normalized;
|
|
1011
1095
|
|
|
1012
1096
|
const seen = new Set();
|
|
@@ -1030,13 +1114,21 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1030
1114
|
|
|
1031
1115
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
1032
1116
|
const recordId = normalizeId(payload.recordId || payload.id);
|
|
1033
|
-
if (!recordId) return normalized;
|
|
1117
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1034
1118
|
const existingEntry = findRecordEntry(nextState.records, recordId);
|
|
1035
1119
|
const recordSnapshot = payload.record && typeof payload.record === 'object'
|
|
1036
1120
|
? {...payload.record, id: recordId}
|
|
1037
1121
|
: existingEntry?.record;
|
|
1038
1122
|
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
1039
|
-
if (!
|
|
1123
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1124
|
+
const existingTombstone = nextState.deletedRecordTombstones[recordId];
|
|
1125
|
+
if (!recordSnapshot && (nextState.trashEntries[trashEntryId] || existingTombstone)) {
|
|
1126
|
+
return normalized;
|
|
1127
|
+
}
|
|
1128
|
+
if (recordSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1129
|
+
Number(recordSnapshot.lifecycleGeneration || 0)) {
|
|
1130
|
+
return normalized;
|
|
1131
|
+
}
|
|
1040
1132
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1041
1133
|
const previousGeneration = Math.max(
|
|
1042
1134
|
Number(recordSnapshot?.lifecycleGeneration || 0),
|
|
@@ -1071,16 +1163,29 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1071
1163
|
}
|
|
1072
1164
|
|
|
1073
1165
|
case OPERATION_TYPES.RECORD_RESTORE: {
|
|
1166
|
+
const restoreRecordId = normalizeId(payload.recordId || payload.id);
|
|
1074
1167
|
const trashEntryId = normalizeId(payload.trashEntryId) ||
|
|
1075
|
-
`record:${
|
|
1076
|
-
|
|
1168
|
+
`record:${restoreRecordId}`;
|
|
1169
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1170
|
+
const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
|
|
1171
|
+
? nextState.trashEntries[trashEntryId]
|
|
1172
|
+
: null;
|
|
1077
1173
|
if (!trashEntry || trashEntry.kind !== 'record') return normalized;
|
|
1078
1174
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
1079
1175
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
1080
1176
|
if (expectedGeneration === null ||
|
|
1081
1177
|
expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
|
|
1082
1178
|
const recordId = normalizeId(trashEntry.entityId);
|
|
1083
|
-
if (!recordId || findRecordEntry(nextState.records, recordId))
|
|
1179
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId) || findRecordEntry(nextState.records, recordId)) {
|
|
1180
|
+
return normalized;
|
|
1181
|
+
}
|
|
1182
|
+
const lifecycleTombstone = rtlOwn(nextState.deletedRecordTombstones, recordId)
|
|
1183
|
+
? nextState.deletedRecordTombstones[recordId]
|
|
1184
|
+
: null;
|
|
1185
|
+
if (!lifecycleTombstone ||
|
|
1186
|
+
Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
|
|
1187
|
+
return normalized;
|
|
1188
|
+
}
|
|
1084
1189
|
const recordSnapshot = trashEntry.payload?.record;
|
|
1085
1190
|
if (!recordSnapshot) return normalized;
|
|
1086
1191
|
const originalFolderId = safeFolderId(trashEntry.originalFolderId);
|
|
@@ -1109,8 +1214,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1109
1214
|
case OPERATION_TYPES.FOLDER_CREATE: {
|
|
1110
1215
|
const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
|
|
1111
1216
|
const folderId = normalizeId(folder?.id || payload.folderId);
|
|
1112
|
-
if (!folderId) return normalized;
|
|
1113
|
-
if (nextState.deletedFolderTombstones
|
|
1217
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1218
|
+
if (rtlOwn(nextState.deletedFolderTombstones, folderId) &&
|
|
1219
|
+
nextState.deletedFolderTombstones[folderId]) return normalized;
|
|
1114
1220
|
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
1115
1221
|
const nextFolder = {
|
|
1116
1222
|
...(folder || {}),
|
|
@@ -1128,7 +1234,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1128
1234
|
|
|
1129
1235
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
1130
1236
|
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
1131
|
-
if (!folderId) return normalized;
|
|
1237
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1132
1238
|
const patch = payload.patch || payload.folder || {};
|
|
1133
1239
|
nextState.folders = nextState.folders.map((folder) => (
|
|
1134
1240
|
folder.id === folderId
|
|
@@ -1140,12 +1246,24 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1140
1246
|
|
|
1141
1247
|
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
1142
1248
|
const folderId = normalizeId(payload.folderId || payload.id);
|
|
1143
|
-
if (!folderId || folderId
|
|
1249
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId) ||
|
|
1250
|
+
folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
|
|
1144
1251
|
const folderSnapshot = payload.folder && typeof payload.folder === 'object'
|
|
1145
1252
|
? {...payload.folder, id: folderId}
|
|
1146
1253
|
: nextState.folders.find((folder) => folder.id === folderId);
|
|
1147
1254
|
const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
|
|
1148
|
-
|
|
1255
|
+
const existingTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
|
|
1256
|
+
? nextState.deletedFolderTombstones[folderId]
|
|
1257
|
+
: null;
|
|
1258
|
+
if (!folderSnapshot &&
|
|
1259
|
+
((rtlOwn(nextState.trashEntries, `folder:${folderId}`) &&
|
|
1260
|
+
nextState.trashEntries[`folder:${folderId}`]) || existingTombstone)) {
|
|
1261
|
+
return normalized;
|
|
1262
|
+
}
|
|
1263
|
+
if (folderSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1264
|
+
Number(folderSnapshot.lifecycleGeneration || 0)) {
|
|
1265
|
+
return normalized;
|
|
1266
|
+
}
|
|
1149
1267
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1150
1268
|
const previousGeneration = Math.max(
|
|
1151
1269
|
Number(folderSnapshot?.lifecycleGeneration || 0),
|
|
@@ -1154,6 +1272,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1154
1272
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1155
1273
|
if (folderSnapshot) {
|
|
1156
1274
|
const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
|
|
1275
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1157
1276
|
nextState.trashEntries[trashEntryId] = {
|
|
1158
1277
|
id: trashEntryId,
|
|
1159
1278
|
kind: 'folder',
|
|
@@ -1187,7 +1306,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1187
1306
|
};
|
|
1188
1307
|
folderRecords.forEach((record) => {
|
|
1189
1308
|
const recordId = getRecordId(record);
|
|
1190
|
-
if (!recordId) return;
|
|
1309
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return;
|
|
1191
1310
|
nextState.deletedRecordTombstones[recordId] = {
|
|
1192
1311
|
id: recordId,
|
|
1193
1312
|
folderId,
|
|
@@ -1201,16 +1320,28 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1201
1320
|
}
|
|
1202
1321
|
|
|
1203
1322
|
case OPERATION_TYPES.FOLDER_RESTORE: {
|
|
1323
|
+
const restoreFolderId = normalizeId(payload.folderId || payload.id);
|
|
1204
1324
|
const trashEntryId = normalizeId(payload.trashEntryId) ||
|
|
1205
|
-
`folder:${
|
|
1206
|
-
|
|
1325
|
+
`folder:${restoreFolderId}`;
|
|
1326
|
+
if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
|
|
1327
|
+
const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
|
|
1328
|
+
? nextState.trashEntries[trashEntryId]
|
|
1329
|
+
: null;
|
|
1207
1330
|
if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
|
|
1208
1331
|
if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
|
|
1209
1332
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
1210
1333
|
if (expectedGeneration === null ||
|
|
1211
1334
|
expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
|
|
1212
1335
|
const folderId = normalizeId(trashEntry.entityId);
|
|
1213
|
-
if (!folderId ||
|
|
1336
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId) ||
|
|
1337
|
+
nextState.folders.some((folder) => folder.id === folderId)) return normalized;
|
|
1338
|
+
const lifecycleTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
|
|
1339
|
+
? nextState.deletedFolderTombstones[folderId]
|
|
1340
|
+
: null;
|
|
1341
|
+
if (!lifecycleTombstone ||
|
|
1342
|
+
Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
|
|
1343
|
+
return normalized;
|
|
1344
|
+
}
|
|
1214
1345
|
const folderSnapshot = trashEntry.payload?.folder;
|
|
1215
1346
|
if (!folderSnapshot) return normalized;
|
|
1216
1347
|
const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
|
|
@@ -1299,6 +1430,7 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1299
1430
|
|
|
1300
1431
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
1301
1432
|
nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
|
|
1433
|
+
if (nextState.folderOrder.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
|
|
1302
1434
|
break;
|
|
1303
1435
|
|
|
1304
1436
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
@@ -1406,6 +1538,11 @@ export const mergeLocalRemote = ({
|
|
|
1406
1538
|
});
|
|
1407
1539
|
});
|
|
1408
1540
|
|
|
1541
|
+
// Pending operations are explicit local commands. Replay them against the
|
|
1542
|
+
// merged remote baseline in FIFO order; do not apply the remote document's
|
|
1543
|
+
// LWW timestamp as a second gate, or an offline update with an older
|
|
1544
|
+
// createdAt would be silently lost. `applyExplicitRecordUpdate` preserves
|
|
1545
|
+
// omitted properties while honoring present empty/false/null values.
|
|
1409
1546
|
toArray(pendingOps).forEach((operation) => {
|
|
1410
1547
|
mergedState = applyOperation(mergedState, operation);
|
|
1411
1548
|
});
|
|
@@ -1740,7 +1877,9 @@ const reorderV2RecordsByFolderRecordOrder = (records = {}, recordOrderByFolder =
|
|
|
1740
1877
|
|
|
1741
1878
|
const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
1742
1879
|
const recordId = normalizeId(record.id);
|
|
1743
|
-
if (!recordId) return null;
|
|
1880
|
+
if (!recordId || !rtlIsSafeDocumentId(recordId)) return null;
|
|
1881
|
+
const normalizedFolderId = normalizeId(folderId);
|
|
1882
|
+
if (normalizedFolderId && !rtlIsSafeDocumentId(normalizedFolderId)) return null;
|
|
1744
1883
|
const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
|
|
1745
1884
|
delete cleanRecord.pendingSync;
|
|
1746
1885
|
delete cleanRecord.syncAttempts;
|
|
@@ -1753,7 +1892,7 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
|
1753
1892
|
|
|
1754
1893
|
const cleanV2FolderDocument = (folder = {}, now) => {
|
|
1755
1894
|
const folderId = normalizeId(folder.id);
|
|
1756
|
-
if (!folderId) return null;
|
|
1895
|
+
if (!folderId || !rtlIsSafeDocumentId(folderId)) return null;
|
|
1757
1896
|
const {
|
|
1758
1897
|
schemaVersion,
|
|
1759
1898
|
recordOrder,
|
|
@@ -1771,7 +1910,7 @@ const cleanV2FolderDocument = (folder = {}, now) => {
|
|
|
1771
1910
|
|
|
1772
1911
|
const cleanV2OperationDocument = (operation = {}, now) => {
|
|
1773
1912
|
const operationId = normalizeId(operation.id);
|
|
1774
|
-
if (!operationId || !operation.type) return null;
|
|
1913
|
+
if (!operationId || !rtlIsSafeDocumentId(operationId) || !operation.type) return null;
|
|
1775
1914
|
return {
|
|
1776
1915
|
...operation,
|
|
1777
1916
|
id: operationId,
|
|
@@ -1783,7 +1922,8 @@ const cleanV2OperationDocument = (operation = {}, now) => {
|
|
|
1783
1922
|
const cleanV2TrashDocument = (entry = {}, now) => {
|
|
1784
1923
|
const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
|
|
1785
1924
|
const trashEntry = Object.values(normalized)[0];
|
|
1786
|
-
if (!trashEntry)
|
|
1925
|
+
if (!trashEntry || !rtlIsSafeDocumentId(trashEntry.id) ||
|
|
1926
|
+
!rtlIsSafeDocumentId(trashEntry.entityId)) return null;
|
|
1787
1927
|
return {
|
|
1788
1928
|
...trashEntry,
|
|
1789
1929
|
deletedAt: trashEntry.deletedAt || now,
|
|
@@ -1799,6 +1939,7 @@ const buildV2LifecycleTombstoneDocuments = (
|
|
|
1799
1939
|
const documents = {};
|
|
1800
1940
|
const append = (kind, tombstones) => {
|
|
1801
1941
|
Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
|
|
1942
|
+
if (!rtlIsSafeDocumentId(entityId)) return;
|
|
1802
1943
|
const id = `${kind}:${entityId}`;
|
|
1803
1944
|
documents[encodeURIComponent(id)] = {
|
|
1804
1945
|
id,
|
|
@@ -3010,6 +3151,168 @@ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
|
|
|
3010
3151
|
|
|
3011
3152
|
const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
3012
3153
|
|
|
3154
|
+
const rtlRawNonNegativeSafeInteger = (value) => (
|
|
3155
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
3156
|
+
);
|
|
3157
|
+
|
|
3158
|
+
const rtlRawSafeId = (value) => typeof value === 'string' && rtlIsSafeDocumentId(value);
|
|
3159
|
+
|
|
3160
|
+
const rtlRawFiniteTimestamp = (value) => (
|
|
3161
|
+
typeof value === 'number' && Number.isFinite(value)
|
|
3162
|
+
);
|
|
3163
|
+
|
|
3164
|
+
const rtlRawValidOpaqueGroupOrder = (value) => (
|
|
3165
|
+
typeof value === 'string' && value.length > 0 &&
|
|
3166
|
+
new TextEncoder().encode(value).byteLength <= RTL_SAFE_DOCUMENT_ID_MAX_BYTES &&
|
|
3167
|
+
!/[\u0000-\u001F\u007F-\u009F]/u.test(value)
|
|
3168
|
+
);
|
|
3169
|
+
|
|
3170
|
+
const rtlRawValidOrder = (value, {opaqueGroup = false} = {}) => (
|
|
3171
|
+
Array.isArray(value) && value.every((id) => (
|
|
3172
|
+
opaqueGroup ? rtlRawValidOpaqueGroupOrder(id) : rtlRawSafeId(id)
|
|
3173
|
+
))
|
|
3174
|
+
);
|
|
3175
|
+
|
|
3176
|
+
const rtlRawValidTombstoneTable = (value) => (
|
|
3177
|
+
rtlDurableIsObject(value) && Object.entries(value).every(([id, tombstone]) => (
|
|
3178
|
+
rtlRawSafeId(id) && rtlDurableIsObject(tombstone) &&
|
|
3179
|
+
(!Object.prototype.hasOwnProperty.call(tombstone, 'id') || rtlRawSafeId(tombstone.id)) &&
|
|
3180
|
+
Number.isSafeInteger(tombstone.lifecycleGeneration) && tombstone.lifecycleGeneration > 0 &&
|
|
3181
|
+
rtlRawFiniteTimestamp(tombstone.deletedAt)
|
|
3182
|
+
))
|
|
3183
|
+
);
|
|
3184
|
+
|
|
3185
|
+
const rtlRawValidTrashTable = (value) => (
|
|
3186
|
+
rtlDurableIsObject(value) && Object.entries(value).every(([id, entry]) => (
|
|
3187
|
+
rtlRawSafeId(id) && rtlDurableIsObject(entry) &&
|
|
3188
|
+
rtlRawSafeId(entry.id) && entry.id === id &&
|
|
3189
|
+
(entry.kind === 'record' || entry.kind === 'folder') &&
|
|
3190
|
+
rtlRawSafeId(entry.entityId) &&
|
|
3191
|
+
Number.isSafeInteger(entry.lifecycleGeneration) && entry.lifecycleGeneration > 0 &&
|
|
3192
|
+
rtlRawFiniteTimestamp(entry.deletedAt) && rtlRawFiniteTimestamp(entry.purgeAt) &&
|
|
3193
|
+
(!Object.prototype.hasOwnProperty.call(entry, 'payload') || rtlDurableIsObject(entry.payload))
|
|
3194
|
+
))
|
|
3195
|
+
);
|
|
3196
|
+
|
|
3197
|
+
const rtlHasCompleteBaselineState = (state) => {
|
|
3198
|
+
if (!rtlDurableIsObject(state) || ![
|
|
3199
|
+
'folders', 'records', 'groupOrder', 'folderOrder', 'settings', 'rtlSyncMeta',
|
|
3200
|
+
'deletedRecordTombstones', 'deletedFolderTombstones', 'trashEntries'
|
|
3201
|
+
].every((key) => Object.prototype.hasOwnProperty.call(state, key))) return false;
|
|
3202
|
+
if (!Array.isArray(state.folders) || !rtlDurableIsObject(state.records) ||
|
|
3203
|
+
!rtlRawValidOrder(state.groupOrder, {opaqueGroup: true}) || !rtlRawValidOrder(state.folderOrder) ||
|
|
3204
|
+
!rtlDurableIsObject(state.settings) || !rtlDurableIsObject(state.rtlSyncMeta) ||
|
|
3205
|
+
!rtlRawValidTombstoneTable(state.deletedRecordTombstones) ||
|
|
3206
|
+
!rtlRawValidTombstoneTable(state.deletedFolderTombstones) ||
|
|
3207
|
+
!rtlRawValidTrashTable(state.trashEntries)) return false;
|
|
3208
|
+
|
|
3209
|
+
const folderIds = new Set();
|
|
3210
|
+
for (const folder of state.folders) {
|
|
3211
|
+
if (!rtlDurableIsObject(folder) || !rtlRawSafeId(folder.id) || folderIds.has(folder.id) ||
|
|
3212
|
+
(Object.prototype.hasOwnProperty.call(folder, 'recordOrder') && !rtlRawValidOrder(folder.recordOrder))) {
|
|
3213
|
+
return false;
|
|
3214
|
+
}
|
|
3215
|
+
folderIds.add(folder.id);
|
|
3216
|
+
}
|
|
3217
|
+
const recordIds = new Set();
|
|
3218
|
+
for (const [folderId, records] of Object.entries(state.records)) {
|
|
3219
|
+
if (!rtlRawSafeId(folderId) || !Array.isArray(records)) return false;
|
|
3220
|
+
for (const record of records) {
|
|
3221
|
+
if (!rtlDurableIsObject(record) || !rtlRawSafeId(record.id) || recordIds.has(record.id)) {
|
|
3222
|
+
return false;
|
|
3223
|
+
}
|
|
3224
|
+
recordIds.add(record.id);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
return true;
|
|
3228
|
+
};
|
|
3229
|
+
|
|
3230
|
+
// Resume cursors and markers are opaque to Core, but any identity fields an
|
|
3231
|
+
// adapter persists alongside them are still part of the workspace fence. A
|
|
3232
|
+
// cursor from another UID/session/epoch must force a fresh bootstrap rather
|
|
3233
|
+
// than being silently adopted after normalization.
|
|
3234
|
+
const rtlResumeIdentityMatches = (value, captured, ownerUid, workspaceEpoch) => {
|
|
3235
|
+
if (value === null || value === undefined || !rtlDurableIsObject(value)) return false;
|
|
3236
|
+
const hasOwnerIdentity = ['ownerUid', 'uid', 'workspaceOwnerUid']
|
|
3237
|
+
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
3238
|
+
const hasEpochIdentity = ['workspaceEpoch', 'epoch']
|
|
3239
|
+
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
3240
|
+
const hasSessionIdentity = ['authSessionBinding']
|
|
3241
|
+
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
3242
|
+
if (!hasOwnerIdentity || !hasEpochIdentity || !hasSessionIdentity) return false;
|
|
3243
|
+
for (const key of ['ownerUid', 'uid', 'workspaceOwnerUid']) {
|
|
3244
|
+
if (Object.prototype.hasOwnProperty.call(value, key) &&
|
|
3245
|
+
rtlNormalizeUid(value[key]) !== rtlNormalizeUid(ownerUid)) {
|
|
3246
|
+
return false;
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
for (const key of ['workspaceEpoch', 'epoch']) {
|
|
3250
|
+
if (Object.prototype.hasOwnProperty.call(value, key) &&
|
|
3251
|
+
(!rtlRawNonNegativeSafeInteger(value[key]) ||
|
|
3252
|
+
value[key] !== workspaceEpoch)) {
|
|
3253
|
+
return false;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
// Legacy sessionToken/sessionTokenId markers are credentials or opaque
|
|
3257
|
+
// fences that cannot be safely persisted. They deliberately fail closed;
|
|
3258
|
+
// callers must bootstrap and receive a newly bound marker.
|
|
3259
|
+
if (Object.prototype.hasOwnProperty.call(value, 'sessionToken') ||
|
|
3260
|
+
Object.prototype.hasOwnProperty.call(value, 'sessionTokenId')) return false;
|
|
3261
|
+
for (const key of ['authSessionBinding']) {
|
|
3262
|
+
if (Object.prototype.hasOwnProperty.call(value, key) &&
|
|
3263
|
+
(captured?.authSessionBinding === undefined || captured?.authSessionBinding === null ||
|
|
3264
|
+
!Object.is(value[key], captured.authSessionBinding))) {
|
|
3265
|
+
return false;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
return true;
|
|
3269
|
+
};
|
|
3270
|
+
|
|
3271
|
+
const rtlResumeMarkerMatches = (value, captured, ownerUid, workspaceEpoch) =>
|
|
3272
|
+
rtlResumeIdentityMatches(value, captured, ownerUid, workspaceEpoch);
|
|
3273
|
+
|
|
3274
|
+
const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
|
|
3275
|
+
if (!rtlDurableIsObject(loaded) || loaded.schemaVersion !== RTL_DURABLE_SCHEMA_VERSION) {
|
|
3276
|
+
return false;
|
|
3277
|
+
}
|
|
3278
|
+
const ownerUid = rtlNormalizeUid(loaded.ownerUid);
|
|
3279
|
+
const expectedOwnerUid = rtlNormalizeUid(captured?.uid);
|
|
3280
|
+
if (!ownerUid || !expectedOwnerUid || ownerUid !== expectedOwnerUid) return false;
|
|
3281
|
+
if (!rtlRawNonNegativeSafeInteger(loaded.workspaceEpoch) ||
|
|
3282
|
+
(captured?.hasEpoch && loaded.workspaceEpoch !== captured.workspaceEpoch)) {
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
const baseline = loaded.remoteBaseline;
|
|
3286
|
+
if (!rtlDurableIsObject(baseline) ||
|
|
3287
|
+
!Object.prototype.hasOwnProperty.call(baseline, 'state') ||
|
|
3288
|
+
!Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
|
|
3289
|
+
!Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
|
|
3290
|
+
!rtlHasCompleteBaselineState(baseline.state) ||
|
|
3291
|
+
!rtlRawNonNegativeSafeInteger(baseline.revision)) {
|
|
3292
|
+
return false;
|
|
3293
|
+
}
|
|
3294
|
+
if (loaded.syncMeta !== undefined &&
|
|
3295
|
+
!rtlDurableIsObject(loaded.syncMeta)) return false;
|
|
3296
|
+
if (typeof baseline.changeCursor !== 'string' ||
|
|
3297
|
+
!rtlResumeIdentityMatches(
|
|
3298
|
+
loaded.syncMeta?.changeCursorBinding,
|
|
3299
|
+
captured,
|
|
3300
|
+
ownerUid,
|
|
3301
|
+
loaded.workspaceEpoch
|
|
3302
|
+
)) {
|
|
3303
|
+
return false;
|
|
3304
|
+
}
|
|
3305
|
+
const resumeMarker = loaded.syncMeta?.resumeMarker ??
|
|
3306
|
+
loaded.syncMeta?.resume ??
|
|
3307
|
+
loaded.syncMeta?.bootstrapMarker;
|
|
3308
|
+
return resumeMarker === undefined || rtlResumeMarkerMatches(
|
|
3309
|
+
resumeMarker,
|
|
3310
|
+
captured,
|
|
3311
|
+
ownerUid,
|
|
3312
|
+
loaded.workspaceEpoch
|
|
3313
|
+
);
|
|
3314
|
+
};
|
|
3315
|
+
|
|
3013
3316
|
const rtlToFiniteNumber = (value, fallback = 0) => {
|
|
3014
3317
|
const number = Number(value);
|
|
3015
3318
|
return Number.isFinite(number) ? number : fallback;
|
|
@@ -3035,17 +3338,74 @@ const rtlNormalizeWorkspaceEpoch = (value, fallback = 0) => {
|
|
|
3035
3338
|
|
|
3036
3339
|
const rtlCloneOperation = (operation) => clone(operation && typeof operation === 'object' ? operation : {});
|
|
3037
3340
|
|
|
3341
|
+
// Durable storage may contain adapter metadata at arbitrary nesting levels. Strip
|
|
3342
|
+
// credential-shaped keys case/separator-insensitively, while retaining the explicit
|
|
3343
|
+
// non-credential authSessionBinding used to bind a committed workspace to a session.
|
|
3344
|
+
const rtlIsCredentialKey = (key) => {
|
|
3345
|
+
const normalized = String(key).replace(/[\s_-]/g, '').toLowerCase();
|
|
3346
|
+
if (normalized === 'authsessionbinding') return false;
|
|
3347
|
+
return new Set([
|
|
3348
|
+
'sessiontoken', 'sessiontokenid', 'sessionid', 'authtoken', 'accesstoken',
|
|
3349
|
+
'refreshtoken', 'idtoken', 'authorization', 'credential', 'credentials',
|
|
3350
|
+
'firebasecredential', 'jwttoken', 'bearertoken', 'accesskey', 'refreshkey',
|
|
3351
|
+
'authsessionid', 'authsessionkey'
|
|
3352
|
+
]).has(normalized) || /(?:token|credential|authorization|secret)$/.test(normalized);
|
|
3353
|
+
};
|
|
3354
|
+
|
|
3038
3355
|
const rtlStripSessionTokens = (value) => {
|
|
3039
3356
|
if (Array.isArray(value)) return value.map((entry) => rtlStripSessionTokens(entry));
|
|
3040
3357
|
if (!rtlDurableIsObject(value)) return value;
|
|
3041
3358
|
const result = {};
|
|
3042
3359
|
Object.entries(value).forEach(([key, entry]) => {
|
|
3043
|
-
if (key
|
|
3044
|
-
result
|
|
3360
|
+
if (rtlIsCredentialKey(key)) return;
|
|
3361
|
+
Object.defineProperty(result, key, {
|
|
3362
|
+
value: rtlStripSessionTokens(entry),
|
|
3363
|
+
enumerable: true,
|
|
3364
|
+
configurable: true,
|
|
3365
|
+
writable: true
|
|
3366
|
+
});
|
|
3045
3367
|
});
|
|
3046
3368
|
return result;
|
|
3047
3369
|
};
|
|
3048
3370
|
|
|
3371
|
+
// Domain state and operation payloads are opaque to Core. They may legitimately
|
|
3372
|
+
// contain fields such as `designToken` or `session` that are part of the user's
|
|
3373
|
+
// data, so the session-secret scrubber is limited to Core-owned envelopes.
|
|
3374
|
+
const rtlSanitizeDurableOperation = (operation) => {
|
|
3375
|
+
const sanitized = rtlStripSessionTokens(operation);
|
|
3376
|
+
if (rtlDurableIsObject(operation) &&
|
|
3377
|
+
Object.prototype.hasOwnProperty.call(operation, 'payload')) {
|
|
3378
|
+
sanitized.payload = clone(operation.payload);
|
|
3379
|
+
}
|
|
3380
|
+
return sanitized;
|
|
3381
|
+
};
|
|
3382
|
+
|
|
3383
|
+
const rtlSanitizeDurableWorkspace = (workspace) => {
|
|
3384
|
+
const sanitized = rtlStripSessionTokens(workspace);
|
|
3385
|
+
if (!rtlDurableIsObject(workspace)) return sanitized;
|
|
3386
|
+
if (rtlDurableIsObject(workspace.remoteBaseline) &&
|
|
3387
|
+
Object.prototype.hasOwnProperty.call(workspace.remoteBaseline, 'state')) {
|
|
3388
|
+
sanitized.remoteBaseline = {
|
|
3389
|
+
...sanitized.remoteBaseline,
|
|
3390
|
+
state: clone(workspace.remoteBaseline.state)
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
if (Array.isArray(workspace.pendingOperations)) {
|
|
3394
|
+
sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
|
|
3395
|
+
}
|
|
3396
|
+
if (rtlDurableIsObject(workspace.rejectedOperations)) {
|
|
3397
|
+
sanitized.rejectedOperations = {...sanitized.rejectedOperations};
|
|
3398
|
+
Object.entries(workspace.rejectedOperations).forEach(([id, entry]) => {
|
|
3399
|
+
if (!rtlDurableIsObject(entry) || !rtlDurableIsObject(entry.operation)) return;
|
|
3400
|
+
sanitized.rejectedOperations[id] = {
|
|
3401
|
+
...sanitized.rejectedOperations[id],
|
|
3402
|
+
operation: rtlSanitizeDurableOperation(entry.operation)
|
|
3403
|
+
};
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
return sanitized;
|
|
3407
|
+
};
|
|
3408
|
+
|
|
3049
3409
|
const rtlNormalizeOperation = (operation = {}, {
|
|
3050
3410
|
client,
|
|
3051
3411
|
clientId,
|
|
@@ -3056,35 +3416,6 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3056
3416
|
const input = operation && typeof operation === 'object' ? operation : {};
|
|
3057
3417
|
const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
|
|
3058
3418
|
const normalizedPayload = payload;
|
|
3059
|
-
const trimPayloadId = (key) => {
|
|
3060
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload, key)) {
|
|
3061
|
-
normalizedPayload[key] = normalizeId(normalizedPayload[key]) || normalizedPayload[key];
|
|
3062
|
-
}
|
|
3063
|
-
};
|
|
3064
|
-
[
|
|
3065
|
-
'recordId', 'folderId', 'targetFolderId', 'trashEntryId', 'batchId', 'id',
|
|
3066
|
-
'clientInstanceId', 'instanceId'
|
|
3067
|
-
].forEach(trimPayloadId);
|
|
3068
|
-
['recordIds', 'folderOrder', 'groupOrder', 'expandedGroups', 'groupIds', 'ids'].forEach((key) => {
|
|
3069
|
-
if (Array.isArray(normalizedPayload[key])) {
|
|
3070
|
-
normalizedPayload[key] = normalizeIdList(normalizedPayload[key]);
|
|
3071
|
-
}
|
|
3072
|
-
});
|
|
3073
|
-
if (rtlDurableIsObject(normalizedPayload.record)) {
|
|
3074
|
-
normalizedPayload.record = clone(normalizedPayload.record);
|
|
3075
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'id')) {
|
|
3076
|
-
normalizedPayload.record.id = normalizeId(normalizedPayload.record.id) || normalizedPayload.record.id;
|
|
3077
|
-
}
|
|
3078
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'folderId')) {
|
|
3079
|
-
normalizedPayload.record.folderId = normalizeId(normalizedPayload.record.folderId) || normalizedPayload.record.folderId;
|
|
3080
|
-
}
|
|
3081
|
-
}
|
|
3082
|
-
if (rtlDurableIsObject(normalizedPayload.folder)) {
|
|
3083
|
-
normalizedPayload.folder = clone(normalizedPayload.folder);
|
|
3084
|
-
if (Object.prototype.hasOwnProperty.call(normalizedPayload.folder, 'id')) {
|
|
3085
|
-
normalizedPayload.folder.id = normalizeId(normalizedPayload.folder.id) || normalizedPayload.folder.id;
|
|
3086
|
-
}
|
|
3087
|
-
}
|
|
3088
3419
|
|
|
3089
3420
|
const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
|
|
3090
3421
|
const resolvedNow = Number.isFinite(operationNow)
|
|
@@ -3099,7 +3430,9 @@ const rtlNormalizeOperation = (operation = {}, {
|
|
|
3099
3430
|
|
|
3100
3431
|
return {
|
|
3101
3432
|
...rtlStripSessionTokens(input),
|
|
3102
|
-
|
|
3433
|
+
// Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
|
|
3434
|
+
// invalid whitespace or hostile ID instead of receiving a silently rewritten one.
|
|
3435
|
+
id: input.id === null || input.id === undefined ? generatedId : input.id,
|
|
3103
3436
|
type: typeof input.type === 'string' ? input.type.trim() : input.type,
|
|
3104
3437
|
payload: normalizedPayload,
|
|
3105
3438
|
clientId: resolvedClientId,
|
|
@@ -3132,10 +3465,12 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
|
3132
3465
|
const baseline = rtlDurableIsObject(value) ? value : {};
|
|
3133
3466
|
return {
|
|
3134
3467
|
state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
|
|
3135
|
-
revision:
|
|
3468
|
+
revision: rtlRawNonNegativeSafeInteger(baseline.revision) ? baseline.revision : 0,
|
|
3136
3469
|
changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
|
|
3137
3470
|
? null
|
|
3138
|
-
:
|
|
3471
|
+
: rtlDurableIsObject(baseline.changeCursor)
|
|
3472
|
+
? clone(baseline.changeCursor)
|
|
3473
|
+
: String(baseline.changeCursor)
|
|
3139
3474
|
};
|
|
3140
3475
|
};
|
|
3141
3476
|
|
|
@@ -3149,6 +3484,72 @@ const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) =>
|
|
|
3149
3484
|
syncMeta: {}
|
|
3150
3485
|
});
|
|
3151
3486
|
|
|
3487
|
+
const RTL_BOOTSTRAP_RESUME_META_KEYS = [
|
|
3488
|
+
'resumeMarker',
|
|
3489
|
+
'resume',
|
|
3490
|
+
'bootstrapMarker',
|
|
3491
|
+
'authSessionBinding',
|
|
3492
|
+
'changeCursorBinding'
|
|
3493
|
+
];
|
|
3494
|
+
|
|
3495
|
+
const rtlForceOperationIdentity = (operation, ownerUid, workspaceEpoch, options = {}) => ({
|
|
3496
|
+
...rtlNormalizePendingOperation(operation, {
|
|
3497
|
+
...options,
|
|
3498
|
+
ownerUid,
|
|
3499
|
+
workspaceEpoch
|
|
3500
|
+
}),
|
|
3501
|
+
ownerUid: rtlNormalizeUid(ownerUid),
|
|
3502
|
+
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0)
|
|
3503
|
+
});
|
|
3504
|
+
|
|
3505
|
+
const rtlClearBootstrapResumeMeta = (syncMeta) => {
|
|
3506
|
+
const next = rtlDurableIsObject(syncMeta) ? {...syncMeta} : {};
|
|
3507
|
+
RTL_BOOTSTRAP_RESUME_META_KEYS.forEach((key) => {
|
|
3508
|
+
delete next[key];
|
|
3509
|
+
});
|
|
3510
|
+
return next;
|
|
3511
|
+
};
|
|
3512
|
+
|
|
3513
|
+
const rtlRebindSameOwnerWorkspaceEpoch = (workspace, captured, now) => {
|
|
3514
|
+
const ownerUid = rtlNormalizeUid(captured?.uid);
|
|
3515
|
+
const workspaceEpoch = rtlNormalizeWorkspaceEpoch(captured?.workspaceEpoch, 0);
|
|
3516
|
+
const next = clone(workspace);
|
|
3517
|
+
next.ownerUid = ownerUid;
|
|
3518
|
+
next.workspaceEpoch = workspaceEpoch;
|
|
3519
|
+
next.pendingOperations = toArray(next.pendingOperations).map((operation) => (
|
|
3520
|
+
rtlForceOperationIdentity(operation, ownerUid, workspaceEpoch, {now})
|
|
3521
|
+
));
|
|
3522
|
+
if (rtlDurableIsObject(next.rejectedOperations)) {
|
|
3523
|
+
const rejected = {};
|
|
3524
|
+
Object.entries(next.rejectedOperations).forEach(([id, entry]) => {
|
|
3525
|
+
const value = rtlDurableIsObject(entry) ? {...entry} : entry;
|
|
3526
|
+
if (rtlDurableIsObject(value) && value.operation) {
|
|
3527
|
+
value.operation = rtlForceOperationIdentity(
|
|
3528
|
+
value.operation,
|
|
3529
|
+
ownerUid,
|
|
3530
|
+
workspaceEpoch,
|
|
3531
|
+
{now}
|
|
3532
|
+
);
|
|
3533
|
+
}
|
|
3534
|
+
Object.defineProperty(rejected, id, {
|
|
3535
|
+
value,
|
|
3536
|
+
enumerable: true,
|
|
3537
|
+
configurable: true,
|
|
3538
|
+
writable: true
|
|
3539
|
+
});
|
|
3540
|
+
});
|
|
3541
|
+
next.rejectedOperations = rejected;
|
|
3542
|
+
}
|
|
3543
|
+
next.remoteBaseline = {
|
|
3544
|
+
...(rtlDurableIsObject(next.remoteBaseline)
|
|
3545
|
+
? next.remoteBaseline
|
|
3546
|
+
: rtlNormalizeRemoteBaseline({})),
|
|
3547
|
+
changeCursor: null
|
|
3548
|
+
};
|
|
3549
|
+
next.syncMeta = rtlClearBootstrapResumeMeta(next.syncMeta);
|
|
3550
|
+
return next;
|
|
3551
|
+
};
|
|
3552
|
+
|
|
3152
3553
|
const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
3153
3554
|
const source = rtlDurableIsObject(input) ? input : {};
|
|
3154
3555
|
const legacy = !source.remoteBaseline && (
|
|
@@ -3187,7 +3588,12 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3187
3588
|
Object.entries(rejectedSource).forEach(([rawId, value]) => {
|
|
3188
3589
|
const id = normalizeId(rawId || value?.id || value?.operationId);
|
|
3189
3590
|
if (!id) return;
|
|
3190
|
-
rejectedOperations
|
|
3591
|
+
Object.defineProperty(rejectedOperations, id, {
|
|
3592
|
+
value: clone(value && typeof value === 'object' ? value : {reason: value}),
|
|
3593
|
+
enumerable: true,
|
|
3594
|
+
configurable: true,
|
|
3595
|
+
writable: true
|
|
3596
|
+
});
|
|
3191
3597
|
if (rejectedOperations[id].operation) {
|
|
3192
3598
|
rejectedOperations[id].operation = rtlNormalizePendingOperation(
|
|
3193
3599
|
rejectedOperations[id].operation,
|
|
@@ -3212,6 +3618,31 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3212
3618
|
};
|
|
3213
3619
|
|
|
3214
3620
|
const rtlOperationIdentityReason = (operation, workspace) => {
|
|
3621
|
+
const candidateIds = [
|
|
3622
|
+
operation?.id,
|
|
3623
|
+
operation?.payload?.id,
|
|
3624
|
+
operation?.payload?.recordId,
|
|
3625
|
+
operation?.payload?.folderId,
|
|
3626
|
+
operation?.payload?.targetFolderId,
|
|
3627
|
+
operation?.payload?.trashEntryId,
|
|
3628
|
+
operation?.payload?.record?.id,
|
|
3629
|
+
operation?.payload?.record?.folderId,
|
|
3630
|
+
operation?.payload?.folder?.id,
|
|
3631
|
+
...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
|
|
3632
|
+
...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
|
|
3633
|
+
];
|
|
3634
|
+
if (candidateIds.some((value) => (
|
|
3635
|
+
value !== undefined && value !== null &&
|
|
3636
|
+
!rtlIsSafeDocumentId(value)
|
|
3637
|
+
))) {
|
|
3638
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
|
|
3639
|
+
}
|
|
3640
|
+
const groupOrder = Array.isArray(operation?.payload?.groupOrder)
|
|
3641
|
+
? operation.payload.groupOrder
|
|
3642
|
+
: [];
|
|
3643
|
+
if (groupOrder.some((value) => !rtlIsSafeOpaqueGroupId(value))) {
|
|
3644
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
|
|
3645
|
+
}
|
|
3215
3646
|
const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
|
|
3216
3647
|
const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
|
|
3217
3648
|
if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
|
|
@@ -3336,7 +3767,8 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3336
3767
|
}
|
|
3337
3768
|
rejectedCount += 1;
|
|
3338
3769
|
const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
|
|
3339
|
-
rejected
|
|
3770
|
+
Object.defineProperty(rejected, rejectedId, {
|
|
3771
|
+
value: {
|
|
3340
3772
|
id: rejectedId,
|
|
3341
3773
|
operation: clone(operation),
|
|
3342
3774
|
status: 'rejected',
|
|
@@ -3351,7 +3783,11 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3351
3783
|
),
|
|
3352
3784
|
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
|
|
3353
3785
|
}
|
|
3354
|
-
|
|
3786
|
+
},
|
|
3787
|
+
enumerable: true,
|
|
3788
|
+
configurable: true,
|
|
3789
|
+
writable: true
|
|
3790
|
+
});
|
|
3355
3791
|
});
|
|
3356
3792
|
return {accepted, rejected, rejectedCount, deduplicatedCount};
|
|
3357
3793
|
};
|
|
@@ -3369,6 +3805,11 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3369
3805
|
Object.prototype.hasOwnProperty.call(current, 'sessionToken')
|
|
3370
3806
|
? current.sessionToken
|
|
3371
3807
|
: null;
|
|
3808
|
+
// Durable resume proofs use an explicit, non-credential auth binding. The
|
|
3809
|
+
// transport/session token remains an in-memory callback fence only.
|
|
3810
|
+
const authSessionBinding = current && typeof current === 'object'
|
|
3811
|
+
? (current.authSessionBinding ?? current.authSessionId ?? current.authSessionKey ?? null)
|
|
3812
|
+
: null;
|
|
3372
3813
|
const uid = rtlNormalizeUid(current && typeof current === 'object'
|
|
3373
3814
|
? (current.uid ?? current.ownerUid)
|
|
3374
3815
|
: null);
|
|
@@ -3380,6 +3821,9 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3380
3821
|
current,
|
|
3381
3822
|
hasSession: typeof session?.current === 'function',
|
|
3382
3823
|
sessionToken,
|
|
3824
|
+
authSessionBinding: typeof authSessionBinding === 'string' && authSessionBinding.trim()
|
|
3825
|
+
? authSessionBinding
|
|
3826
|
+
: null,
|
|
3383
3827
|
uid,
|
|
3384
3828
|
workspaceEpoch: hasEpoch
|
|
3385
3829
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
@@ -3405,18 +3849,54 @@ const rtlSessionContext = (captured, workspace, client) => ({
|
|
|
3405
3849
|
});
|
|
3406
3850
|
|
|
3407
3851
|
const rtlEnvelopePayload = (value) => {
|
|
3852
|
+
const normalizeRevisionAlias = (candidate) => {
|
|
3853
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
3854
|
+
return candidate;
|
|
3855
|
+
}
|
|
3856
|
+
if (
|
|
3857
|
+
!Object.prototype.hasOwnProperty.call(candidate, 'revision') &&
|
|
3858
|
+
Object.prototype.hasOwnProperty.call(candidate, 'remoteRevision')
|
|
3859
|
+
) {
|
|
3860
|
+
return {...candidate, revision: candidate.remoteRevision};
|
|
3861
|
+
}
|
|
3862
|
+
return candidate;
|
|
3863
|
+
};
|
|
3408
3864
|
if (value && typeof value === 'object') {
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3865
|
+
// A canonical failure may carry an authoritative baseline for an
|
|
3866
|
+
// operation-id conflict. It must win over compatibility state fields
|
|
3867
|
+
// left beside `failure`; otherwise a stale outer state can be applied
|
|
3868
|
+
// while the canonical failure is being handled.
|
|
3869
|
+
if (value.failure && typeof value.failure === 'object') {
|
|
3870
|
+
const nested = rtlEnvelopePayload(value.failure);
|
|
3871
|
+
if (nested) return nested;
|
|
3872
|
+
}
|
|
3873
|
+
if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
|
|
3874
|
+
if (value.baseline) return normalizeRevisionAlias(value.baseline);
|
|
3875
|
+
if (
|
|
3876
|
+
value.state !== undefined ||
|
|
3877
|
+
value.data !== undefined ||
|
|
3878
|
+
value.revision !== undefined ||
|
|
3879
|
+
value.remoteRevision !== undefined ||
|
|
3880
|
+
value.changeCursor !== undefined
|
|
3881
|
+
) {
|
|
3882
|
+
return normalizeRevisionAlias(value);
|
|
3883
|
+
}
|
|
3884
|
+
if (value.snapshot && typeof value.snapshot === 'object') {
|
|
3885
|
+
return normalizeRevisionAlias(value.snapshot);
|
|
3886
|
+
}
|
|
3887
|
+
if (value.error && typeof value.error === 'object') {
|
|
3888
|
+
const nested = rtlEnvelopePayload(value.error);
|
|
3889
|
+
if (nested) return nested;
|
|
3413
3890
|
}
|
|
3414
|
-
if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
|
|
3415
3891
|
// Adapters migrating from the v1 cloud port may still return a raw
|
|
3416
3892
|
// normalized state. Treat the presence of state-owned keys as an implicit
|
|
3417
3893
|
// baseline with the persisted revision.
|
|
3418
3894
|
if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
|
|
3419
|
-
return {
|
|
3895
|
+
return {
|
|
3896
|
+
state: value,
|
|
3897
|
+
revision: value.revision ?? value.remoteRevision,
|
|
3898
|
+
changeCursor: value.changeCursor
|
|
3899
|
+
};
|
|
3420
3900
|
}
|
|
3421
3901
|
}
|
|
3422
3902
|
return null;
|
|
@@ -3424,44 +3904,63 @@ const rtlEnvelopePayload = (value) => {
|
|
|
3424
3904
|
|
|
3425
3905
|
const rtlEnvelopeSuccess = (value) => {
|
|
3426
3906
|
if (!value || typeof value !== 'object') return false;
|
|
3427
|
-
if (
|
|
3907
|
+
if (
|
|
3908
|
+
value.success === false ||
|
|
3909
|
+
value.ok === false ||
|
|
3910
|
+
value.error ||
|
|
3911
|
+
value.failure ||
|
|
3912
|
+
value.code === 'error'
|
|
3913
|
+
) return false;
|
|
3428
3914
|
return value.success === true || value.ok === true || (
|
|
3429
3915
|
value.success === undefined && value.ok === undefined && !value.error
|
|
3430
3916
|
);
|
|
3431
3917
|
};
|
|
3432
3918
|
|
|
3433
|
-
const
|
|
3434
|
-
const
|
|
3435
|
-
|
|
3436
|
-
const
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3919
|
+
const rtlExplicitRevisionInfo = (value) => {
|
|
3920
|
+
const revisions = [];
|
|
3921
|
+
let invalid = false;
|
|
3922
|
+
const visited = new Set();
|
|
3923
|
+
const add = (source, key) => {
|
|
3924
|
+
if (!source || typeof source !== 'object' ||
|
|
3925
|
+
!Object.prototype.hasOwnProperty.call(source, key)) return;
|
|
3926
|
+
const raw = source[key];
|
|
3927
|
+
const numeric = typeof raw === 'number' ||
|
|
3928
|
+
(typeof raw === 'string' && raw.trim() !== '')
|
|
3929
|
+
? Number(raw)
|
|
3930
|
+
: NaN;
|
|
3931
|
+
if (!Number.isSafeInteger(numeric) || numeric < 0) {
|
|
3932
|
+
invalid = true;
|
|
3933
|
+
return;
|
|
3934
|
+
}
|
|
3935
|
+
revisions.push(numeric);
|
|
3936
|
+
};
|
|
3937
|
+
const visit = (source, depth = 0) => {
|
|
3938
|
+
if (!source || typeof source !== 'object' || visited.has(source) || depth > 8) return;
|
|
3939
|
+
visited.add(source);
|
|
3940
|
+
add(source, 'revision');
|
|
3941
|
+
add(source, 'remoteRevision');
|
|
3942
|
+
[
|
|
3943
|
+
source.remoteBaseline,
|
|
3944
|
+
source.baseline,
|
|
3945
|
+
source.snapshot,
|
|
3946
|
+
source.failure,
|
|
3947
|
+
source.error
|
|
3948
|
+
].forEach((nested) => visit(nested, depth + 1));
|
|
3949
|
+
};
|
|
3950
|
+
visit(value);
|
|
3951
|
+
const unique = [...new Set(revisions)];
|
|
3952
|
+
if (unique.length > 1) invalid = true;
|
|
3953
|
+
return {
|
|
3954
|
+
present: revisions.length > 0 || invalid,
|
|
3955
|
+
valid: !invalid,
|
|
3956
|
+
revision: unique[0] ?? null
|
|
3957
|
+
};
|
|
3457
3958
|
};
|
|
3458
3959
|
|
|
3459
|
-
const
|
|
3460
|
-
const error =
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
const retryAfter = Number(rawRetryAfter);
|
|
3464
|
-
return Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
|
|
3960
|
+
const rtlInvalidRevisionError = () => {
|
|
3961
|
+
const error = new Error('sync_protocol_invalid_revision');
|
|
3962
|
+
error.code = 'sync_protocol_invalid_revision';
|
|
3963
|
+
return error;
|
|
3465
3964
|
};
|
|
3466
3965
|
|
|
3467
3966
|
const rtlResultOperationId = (result = {}) => normalizeId(
|
|
@@ -3569,6 +4068,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3569
4068
|
|
|
3570
4069
|
const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
|
|
3571
4070
|
|
|
4071
|
+
const workspaceMatchesSession = (captured) => {
|
|
4072
|
+
if (!captured?.hasSession) return true;
|
|
4073
|
+
return rtlNormalizeUid(workspace.ownerUid) === captured.uid &&
|
|
4074
|
+
Number(workspace.workspaceEpoch) === Number(captured.workspaceEpoch);
|
|
4075
|
+
};
|
|
4076
|
+
|
|
3572
4077
|
const isCurrent = async (captured) => {
|
|
3573
4078
|
if (destroyed || !captured) return false;
|
|
3574
4079
|
if (typeof session?.isCurrent === 'function') {
|
|
@@ -3595,11 +4100,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3595
4100
|
|
|
3596
4101
|
const persist = async (candidate, captured) => {
|
|
3597
4102
|
if (!(await isCurrent(captured))) return false;
|
|
4103
|
+
const cursor = candidate?.remoteBaseline?.changeCursor;
|
|
4104
|
+
candidate.syncMeta = rtlDurableIsObject(candidate.syncMeta)
|
|
4105
|
+
? {...candidate.syncMeta}
|
|
4106
|
+
: {};
|
|
4107
|
+
if (cursor !== null && cursor !== undefined) {
|
|
4108
|
+
candidate.syncMeta.changeCursorBinding = {
|
|
4109
|
+
ownerUid: captured?.uid ?? null,
|
|
4110
|
+
uid: captured?.uid ?? null,
|
|
4111
|
+
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
|
|
4112
|
+
authSessionBinding: captured?.authSessionBinding ?? null
|
|
4113
|
+
};
|
|
4114
|
+
} else {
|
|
4115
|
+
delete candidate.syncMeta.changeCursorBinding;
|
|
4116
|
+
}
|
|
4117
|
+
const durableCandidate = rtlSanitizeDurableWorkspace(candidate);
|
|
3598
4118
|
// The second argument is an optional adapter-side fence. The public
|
|
3599
4119
|
// StoragePort remains compatible with save(workspace); adapters that can
|
|
3600
4120
|
// enforce an atomic session/epoch check should consume this context before
|
|
3601
4121
|
// mutating durable storage.
|
|
3602
|
-
await storage.save(clone(
|
|
4122
|
+
await storage.save(clone(durableCandidate), {
|
|
3603
4123
|
sessionToken: captured?.sessionToken ?? null,
|
|
3604
4124
|
uid: captured?.uid ?? null,
|
|
3605
4125
|
ownerUid: captured?.uid ?? null,
|
|
@@ -3618,7 +4138,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3618
4138
|
}
|
|
3619
4139
|
const nextBaseline = rtlNormalizeRemoteBaseline({
|
|
3620
4140
|
state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
|
|
3621
|
-
revision: Number.
|
|
4141
|
+
revision: Number.isSafeInteger(revision) && revision >= 0
|
|
4142
|
+
? revision
|
|
4143
|
+
: candidate.remoteBaseline.revision,
|
|
3622
4144
|
changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
|
|
3623
4145
|
? remote.changeCursor
|
|
3624
4146
|
: candidate.remoteBaseline.changeCursor
|
|
@@ -3638,18 +4160,18 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3638
4160
|
|
|
3639
4161
|
const normalizeBootstrapResponse = (response) => {
|
|
3640
4162
|
if (!rtlEnvelopeSuccess(response)) {
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
});
|
|
4163
|
+
const source = response && typeof response === 'object' ? response : {
|
|
4164
|
+
message: 'recordtimelabel_bootstrap_failed',
|
|
4165
|
+
code: 'recordtimelabel_bootstrap_failed',
|
|
4166
|
+
reason: 'recordtimelabel_bootstrap_failed'
|
|
4167
|
+
};
|
|
4168
|
+
throw toRecordTimeLabelCloudFailureError(source);
|
|
3648
4169
|
}
|
|
3649
4170
|
const remote = rtlEnvelopePayload(response);
|
|
3650
4171
|
const revision = Number(remote?.revision);
|
|
3651
4172
|
const state = remote?.state ?? remote?.data;
|
|
3652
|
-
if (!remote || !rtlDurableIsObject(state) ||
|
|
4173
|
+
if (!remote || !rtlDurableIsObject(state) ||
|
|
4174
|
+
!Number.isSafeInteger(revision) || revision < 0) {
|
|
3653
4175
|
throw toRecordTimeLabelCloudFailureError({
|
|
3654
4176
|
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
3655
4177
|
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
@@ -3703,7 +4225,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3703
4225
|
const rethrowClassifiedCloudFailure = (captured, error) => {
|
|
3704
4226
|
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
3705
4227
|
rememberAuthTransitionFailure(captured, failure);
|
|
3706
|
-
if (error?.class === failure.class) throw error;
|
|
4228
|
+
if (error instanceof Error && error?.class === failure.class) throw error;
|
|
3707
4229
|
throw toRecordTimeLabelCloudFailureError(failure);
|
|
3708
4230
|
};
|
|
3709
4231
|
|
|
@@ -3714,6 +4236,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3714
4236
|
error.code = 'recordtimelabel_bootstrap_revision_behind_required';
|
|
3715
4237
|
error.bootstrapRevision = Number(baseline?.revision);
|
|
3716
4238
|
error.minimumRevision = minimum;
|
|
4239
|
+
error.failure = normalizeRecordTimeLabelCloudFailure({
|
|
4240
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4241
|
+
code: error.code,
|
|
4242
|
+
reason: error.code,
|
|
4243
|
+
message: error.message
|
|
4244
|
+
});
|
|
3717
4245
|
throw error;
|
|
3718
4246
|
}
|
|
3719
4247
|
return baseline;
|
|
@@ -3880,7 +4408,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3880
4408
|
const epochMismatch = Boolean(
|
|
3881
4409
|
captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
|
|
3882
4410
|
);
|
|
3883
|
-
if (ownerMismatch
|
|
4411
|
+
if (ownerMismatch) {
|
|
4412
|
+
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
4413
|
+
}
|
|
4414
|
+
if (epochMismatch) {
|
|
4415
|
+
if (
|
|
4416
|
+
sourceOwnerUid &&
|
|
4417
|
+
sourceOwnerUid === currentUid &&
|
|
4418
|
+
Number(currentEpoch) > Number(loadedWorkspace.workspaceEpoch)
|
|
4419
|
+
) {
|
|
4420
|
+
return rtlRebindSameOwnerWorkspaceEpoch(loadedWorkspace, captured, now);
|
|
4421
|
+
}
|
|
3884
4422
|
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
3885
4423
|
}
|
|
3886
4424
|
const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
|
|
@@ -3939,103 +4477,556 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3939
4477
|
nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
|
|
3940
4478
|
});
|
|
3941
4479
|
|
|
3942
|
-
const
|
|
3943
|
-
|
|
4480
|
+
const failureOutcome = (failure, error, extra = {}) => ({
|
|
4481
|
+
success: false,
|
|
4482
|
+
failure: clone(failure),
|
|
4483
|
+
error,
|
|
4484
|
+
class: failure.class,
|
|
4485
|
+
status: failure.status,
|
|
4486
|
+
code: failure.code,
|
|
4487
|
+
reason: failure.reason,
|
|
4488
|
+
retryable: failure.retryable,
|
|
4489
|
+
retryAfterMs: failure.retryAfterMs,
|
|
4490
|
+
bootstrapRequired: failure.bootstrapRequired,
|
|
4491
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4492
|
+
...extra
|
|
4493
|
+
});
|
|
4494
|
+
|
|
4495
|
+
const persistHydrationBarrier = async (captured, extraMeta = {}) => {
|
|
4496
|
+
hydrationRequired = true;
|
|
3944
4497
|
const candidate = clone(workspace);
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
}
|
|
3950
|
-
if (
|
|
3951
|
-
return {success: true, ignored: true};
|
|
3952
|
-
}
|
|
3953
|
-
let baselineValue = remoteValue;
|
|
3954
|
-
const remoteState = remote?.state ?? remote?.data;
|
|
3955
|
-
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
3956
|
-
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
3957
|
-
if (typeof cloud?.bootstrap !== 'function') {
|
|
3958
|
-
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3959
|
-
}
|
|
3960
|
-
const context = rtlSessionContext(captured, candidate, client);
|
|
3961
|
-
baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
|
|
3962
|
-
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3963
|
-
}
|
|
3964
|
-
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
3965
|
-
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
3966
|
-
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
4498
|
+
candidate.syncMeta = {
|
|
4499
|
+
...candidate.syncMeta,
|
|
4500
|
+
hydrationRequired: true,
|
|
4501
|
+
...extraMeta
|
|
4502
|
+
};
|
|
4503
|
+
if (!(await persist(candidate, captured))) return false;
|
|
3967
4504
|
workspace = candidate;
|
|
3968
|
-
|
|
3969
|
-
return getSnapshot();
|
|
4505
|
+
return true;
|
|
3970
4506
|
};
|
|
3971
4507
|
|
|
3972
|
-
const
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
rejectPromise = reject;
|
|
3982
|
-
}),
|
|
3983
|
-
resolve(value = {}) {
|
|
3984
|
-
if (state.settled) return;
|
|
3985
|
-
state.settled = true;
|
|
3986
|
-
resolvePromise({
|
|
3987
|
-
success: true,
|
|
3988
|
-
...(value && typeof value === 'object' ? value : {}),
|
|
3989
|
-
generation
|
|
3990
|
-
});
|
|
3991
|
-
},
|
|
3992
|
-
reject(error) {
|
|
3993
|
-
if (state.settled) return;
|
|
3994
|
-
state.settled = true;
|
|
3995
|
-
rejectPromise(error);
|
|
3996
|
-
}
|
|
3997
|
-
};
|
|
3998
|
-
// Readiness is an opt-in host boundary. Keep a rejection observable to a
|
|
3999
|
-
// waiter without turning clients that have not adopted it yet into an
|
|
4000
|
-
// unhandled-rejection source.
|
|
4001
|
-
state.promise.catch(() => null);
|
|
4002
|
-
return state;
|
|
4508
|
+
const clearPersistedHydrationBarrier = (candidate) => {
|
|
4509
|
+
if (!rtlDurableIsObject(candidate?.syncMeta) ||
|
|
4510
|
+
!Object.prototype.hasOwnProperty.call(candidate.syncMeta, 'hydrationRequired')) {
|
|
4511
|
+
return candidate;
|
|
4512
|
+
}
|
|
4513
|
+
const nextMeta = {...candidate.syncMeta};
|
|
4514
|
+
delete nextMeta.hydrationRequired;
|
|
4515
|
+
candidate.syncMeta = nextMeta;
|
|
4516
|
+
return candidate;
|
|
4003
4517
|
};
|
|
4004
4518
|
|
|
4005
|
-
const
|
|
4006
|
-
|
|
4007
|
-
|
|
4519
|
+
const persistTerminalFailureBlock = async ({
|
|
4520
|
+
failure,
|
|
4521
|
+
error,
|
|
4522
|
+
captured,
|
|
4523
|
+
timestamp,
|
|
4524
|
+
identityRejectedCount,
|
|
4525
|
+
extra = {}
|
|
4526
|
+
}) => {
|
|
4527
|
+
const candidate = clone(workspace);
|
|
4528
|
+
candidate.syncMeta = {
|
|
4529
|
+
...candidate.syncMeta,
|
|
4530
|
+
lastSyncAttemptAt: timestamp,
|
|
4531
|
+
lastSyncError: failure.message || failure.code || 'terminal',
|
|
4532
|
+
lastSyncFailure: clone(failure),
|
|
4533
|
+
terminalFailureBlock: clone(failure)
|
|
4534
|
+
};
|
|
4535
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4536
|
+
workspace = candidate;
|
|
4537
|
+
return failureOutcome(failure, error, {
|
|
4538
|
+
protocolError: true,
|
|
4539
|
+
blocked: true,
|
|
4540
|
+
identityRejectedCount,
|
|
4541
|
+
...extra
|
|
4542
|
+
});
|
|
4008
4543
|
};
|
|
4009
4544
|
|
|
4010
|
-
const
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4545
|
+
const rebaseAfterBootstrapRequired = async ({
|
|
4546
|
+
failure,
|
|
4547
|
+
error,
|
|
4548
|
+
captured,
|
|
4549
|
+
context,
|
|
4550
|
+
timestamp,
|
|
4551
|
+
identityRejectedCount
|
|
4552
|
+
}) => {
|
|
4553
|
+
hydrationRequired = true;
|
|
4554
|
+
if (!(await persistHydrationBarrier(captured, {
|
|
4555
|
+
lastSyncAttemptAt: timestamp,
|
|
4556
|
+
lastSyncFailure: clone(failure),
|
|
4557
|
+
lastSyncError: failure.message || failure.code || 'bootstrap-required'
|
|
4558
|
+
}))) {
|
|
4559
|
+
return {success: false, reason: 'stale_session'};
|
|
4019
4560
|
}
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4561
|
+
if (typeof cloud?.bootstrap !== 'function') {
|
|
4562
|
+
return failureOutcome(failure, error, {
|
|
4563
|
+
bootstrapRequired: true,
|
|
4564
|
+
hydrationBarrier: true,
|
|
4565
|
+
identityRejectedCount
|
|
4566
|
+
});
|
|
4567
|
+
}
|
|
4568
|
+
let baseline;
|
|
4569
|
+
try {
|
|
4570
|
+
baseline = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
4571
|
+
context,
|
|
4572
|
+
bootstrapAttemptOptions('apply-failure-rebase')
|
|
4573
|
+
));
|
|
4574
|
+
} catch (bootstrapError) {
|
|
4575
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4576
|
+
rethrowClassifiedCloudFailure(captured, bootstrapError);
|
|
4577
|
+
}
|
|
4578
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4579
|
+
const candidate = clone(workspace);
|
|
4580
|
+
const applied = applyRemoteBaseline(candidate, baseline);
|
|
4581
|
+
if (applied.stale) {
|
|
4582
|
+
const staleFailure = normalizeRecordTimeLabelCloudFailure({
|
|
4583
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4584
|
+
code: 'bootstrap_revision_behind_required',
|
|
4585
|
+
reason: 'bootstrap_revision_behind_required',
|
|
4586
|
+
message: 'bootstrap_revision_behind_required'
|
|
4587
|
+
});
|
|
4588
|
+
return failureOutcome(staleFailure, toRecordTimeLabelCloudFailureError(staleFailure), {
|
|
4589
|
+
bootstrapRequired: true,
|
|
4590
|
+
hydrationBarrier: true,
|
|
4591
|
+
identityRejectedCount
|
|
4592
|
+
});
|
|
4593
|
+
}
|
|
4594
|
+
candidate.syncMeta = {...candidate.syncMeta};
|
|
4595
|
+
delete candidate.syncMeta.terminalFailureBlock;
|
|
4596
|
+
candidate.syncMeta.lastSyncFailure = clone(failure);
|
|
4597
|
+
candidate.syncMeta.lastSyncAttemptAt = timestamp;
|
|
4598
|
+
candidate.syncMeta.lastSyncError = null;
|
|
4599
|
+
clearPersistedHydrationBarrier(candidate);
|
|
4600
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4601
|
+
workspace = candidate;
|
|
4602
|
+
hydrationRequired = false;
|
|
4603
|
+
notify({type: 'hydration_rebased', failure: clone(failure)});
|
|
4604
|
+
return failureOutcome(failure, error, {
|
|
4605
|
+
bootstrapRequired: true,
|
|
4606
|
+
hydrationBarrier: false,
|
|
4607
|
+
rebaseCompleted: true,
|
|
4608
|
+
identityRejectedCount
|
|
4609
|
+
});
|
|
4610
|
+
};
|
|
4611
|
+
|
|
4612
|
+
const isAuthoritativeFailureBaseline = (value, minimumRevision, verifiedRevision = null) => {
|
|
4613
|
+
const remote = rtlEnvelopePayload(value);
|
|
4614
|
+
const revision = Number(remote?.revision ?? remote?.remoteRevision);
|
|
4615
|
+
const minimum = Number(minimumRevision);
|
|
4616
|
+
const verified = Number(verifiedRevision);
|
|
4617
|
+
return Boolean(
|
|
4618
|
+
remote &&
|
|
4619
|
+
rtlDurableIsObject(remote.state ?? remote.data) &&
|
|
4620
|
+
Number.isSafeInteger(revision) &&
|
|
4621
|
+
revision >= (Number.isFinite(minimum) ? minimum : 0) &&
|
|
4622
|
+
(verifiedRevision === null || verifiedRevision === undefined ||
|
|
4623
|
+
(Number.isSafeInteger(verified) && revision === verified))
|
|
4624
|
+
);
|
|
4625
|
+
};
|
|
4626
|
+
|
|
4627
|
+
const settleApplyFailure = async ({
|
|
4628
|
+
value,
|
|
4629
|
+
captured,
|
|
4630
|
+
context,
|
|
4631
|
+
ready,
|
|
4632
|
+
timestamp,
|
|
4633
|
+
identityRejectedCount
|
|
4634
|
+
}) => {
|
|
4635
|
+
const failure = normalizeRecordTimeLabelCloudFailure(value);
|
|
4636
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
4637
|
+
const responseRevisionInfo = rtlExplicitRevisionInfo(value);
|
|
4638
|
+
|
|
4639
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION) {
|
|
4640
|
+
// A stale response is never an acknowledgement for the captured
|
|
4641
|
+
// workspace. In particular, do not persist retry metadata or notify
|
|
4642
|
+
// listeners that may already be observing a newer session.
|
|
4643
|
+
return failureOutcome(failure, error, {
|
|
4644
|
+
stale: true,
|
|
4645
|
+
reason: 'stale_session',
|
|
4646
|
+
identityRejectedCount
|
|
4647
|
+
});
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
|
|
4651
|
+
rememberAuthTransitionFailure(captured, failure);
|
|
4652
|
+
throw error;
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
4656
|
+
return rebaseAfterBootstrapRequired({
|
|
4657
|
+
failure,
|
|
4658
|
+
error,
|
|
4659
|
+
captured,
|
|
4660
|
+
context,
|
|
4661
|
+
timestamp,
|
|
4662
|
+
identityRejectedCount
|
|
4663
|
+
});
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT) {
|
|
4667
|
+
const candidate = clone(workspace);
|
|
4668
|
+
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
4669
|
+
candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
|
|
4670
|
+
readyIds.has(operation.id)
|
|
4671
|
+
? makeRetryOperation(operation, failure, failure.retryAfterMs, timestamp)
|
|
4672
|
+
: operation
|
|
4673
|
+
));
|
|
4674
|
+
candidate.syncMeta = {
|
|
4675
|
+
...candidate.syncMeta,
|
|
4676
|
+
lastSyncAttemptAt: timestamp,
|
|
4677
|
+
lastSyncError: failure.message || failure.reason || failure.code || 'retryable',
|
|
4678
|
+
lastSyncFailure: clone(failure)
|
|
4679
|
+
};
|
|
4680
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4681
|
+
workspace = candidate;
|
|
4682
|
+
notify({
|
|
4683
|
+
type: 'sync_retry',
|
|
4684
|
+
reason: failure.code || failure.reason || 'retryable',
|
|
4685
|
+
failure: clone(failure)
|
|
4686
|
+
});
|
|
4687
|
+
const retryAt = Math.min(...candidate.pendingOperations
|
|
4688
|
+
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
4689
|
+
.map((operation) => Number(operation.nextRetryAt)));
|
|
4690
|
+
return failureOutcome(failure, error, {
|
|
4691
|
+
retryable: true,
|
|
4692
|
+
retryAfterMs: failure.retryAfterMs,
|
|
4693
|
+
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
4694
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4695
|
+
identityRejectedCount
|
|
4696
|
+
});
|
|
4697
|
+
}
|
|
4698
|
+
|
|
4699
|
+
// Only an explicit request/operation-id conflict is eligible for the
|
|
4700
|
+
// existing rejection-rebase path. A bulk operation is deliberately
|
|
4701
|
+
// excluded: quarantining one entry from a bulk job would acknowledge a
|
|
4702
|
+
// request whose server-side transaction did not run.
|
|
4703
|
+
const isBulkBatch = ready.some((operation) => (
|
|
4704
|
+
typeof RTL_BULK_OPERATION_TYPES !== 'undefined' &&
|
|
4705
|
+
RTL_BULK_OPERATION_TYPES.has(operation?.type)
|
|
4706
|
+
));
|
|
4707
|
+
const isOperationConflict = isRecordTimeLabelOperationConflictFailure(value);
|
|
4708
|
+
if (
|
|
4709
|
+
failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL &&
|
|
4710
|
+
isOperationConflict &&
|
|
4711
|
+
!isBulkBatch
|
|
4712
|
+
) {
|
|
4713
|
+
// A conflict rebase is only safe when every revision alias in the
|
|
4714
|
+
// response agrees. Reject malformed/conflicting revision metadata
|
|
4715
|
+
// before calling bootstrap or changing the ready outbox.
|
|
4716
|
+
if (!responseRevisionInfo.valid) {
|
|
4717
|
+
const protocolError = rtlInvalidRevisionError();
|
|
4718
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', protocolError);
|
|
4719
|
+
return persistTerminalFailureBlock({
|
|
4720
|
+
failure,
|
|
4721
|
+
error: protocolError,
|
|
4722
|
+
captured,
|
|
4723
|
+
timestamp,
|
|
4724
|
+
identityRejectedCount,
|
|
4725
|
+
extra: {protocolError: true}
|
|
4726
|
+
});
|
|
4727
|
+
}
|
|
4728
|
+
let freshBaseline = null;
|
|
4729
|
+
const currentRevisionValue = Number(workspace.remoteBaseline.revision);
|
|
4730
|
+
const currentRevision = Number.isSafeInteger(currentRevisionValue) && currentRevisionValue >= 0
|
|
4731
|
+
? currentRevisionValue
|
|
4732
|
+
: 0;
|
|
4733
|
+
const requiredRevision = Math.max(
|
|
4734
|
+
currentRevision,
|
|
4735
|
+
responseRevisionInfo.revision ?? 0
|
|
4736
|
+
);
|
|
4737
|
+
if (typeof cloud?.bootstrap === 'function') {
|
|
4738
|
+
try {
|
|
4739
|
+
freshBaseline = requireBootstrapRevision(
|
|
4740
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
4741
|
+
context,
|
|
4742
|
+
bootstrapAttemptOptions('rejection-rebase')
|
|
4743
|
+
)),
|
|
4744
|
+
requiredRevision
|
|
4745
|
+
);
|
|
4746
|
+
} catch (bootstrapError) {
|
|
4747
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4748
|
+
rethrowClassifiedCloudFailure(captured, bootstrapError);
|
|
4749
|
+
}
|
|
4750
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4751
|
+
} else if (isAuthoritativeFailureBaseline(
|
|
4752
|
+
value,
|
|
4753
|
+
requiredRevision,
|
|
4754
|
+
responseRevisionInfo.revision
|
|
4755
|
+
)) {
|
|
4756
|
+
freshBaseline = rtlEnvelopePayload(value);
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
// Preserve 1A1's fail-closed contract when no fresh/authoritative
|
|
4760
|
+
// baseline is available: nothing, including outbox metadata, changes.
|
|
4761
|
+
if (!freshBaseline) {
|
|
4762
|
+
return persistTerminalFailureBlock({
|
|
4763
|
+
failure,
|
|
4764
|
+
error,
|
|
4765
|
+
captured,
|
|
4766
|
+
timestamp,
|
|
4767
|
+
identityRejectedCount
|
|
4768
|
+
});
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
const candidate = clone(workspace);
|
|
4772
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
4773
|
+
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
4774
|
+
const rejectionResponse = rtlStripSessionTokens({
|
|
4775
|
+
...((value && typeof value === 'object') ? value : {}),
|
|
4776
|
+
failure: clone(failure)
|
|
4777
|
+
});
|
|
4778
|
+
const nextRejected = {...candidate.rejectedOperations};
|
|
4779
|
+
const rejectedResults = [];
|
|
4780
|
+
const completedOperations = [];
|
|
4781
|
+
ready.forEach((operation) => {
|
|
4782
|
+
const reason = failure.reason || failure.code || 'operation_id_conflict';
|
|
4783
|
+
Object.defineProperty(nextRejected, operation.id, {
|
|
4784
|
+
value: {
|
|
4785
|
+
id: operation.id,
|
|
4786
|
+
operation: clone(operation),
|
|
4787
|
+
status: 'rejected',
|
|
4788
|
+
reason,
|
|
4789
|
+
rejectedAt: timestamp,
|
|
4790
|
+
response: rejectionResponse
|
|
4791
|
+
},
|
|
4792
|
+
enumerable: true,
|
|
4793
|
+
configurable: true,
|
|
4794
|
+
writable: true
|
|
4795
|
+
});
|
|
4796
|
+
rejectedResults.push({
|
|
4797
|
+
id: operation.id,
|
|
4798
|
+
operationId: operation.id,
|
|
4799
|
+
status: 'rejected',
|
|
4800
|
+
applied: false,
|
|
4801
|
+
reason,
|
|
4802
|
+
code: failure.code,
|
|
4803
|
+
class: failure.class,
|
|
4804
|
+
statusCode: failure.status
|
|
4805
|
+
});
|
|
4806
|
+
completedOperations.push({operation, status: 'rejected'});
|
|
4807
|
+
});
|
|
4808
|
+
candidate.pendingOperations = candidate.pendingOperations.filter((operation) => (
|
|
4809
|
+
!readyIds.has(operation.id)
|
|
4810
|
+
));
|
|
4811
|
+
candidate.rejectedOperations = nextRejected;
|
|
4812
|
+
candidate.syncMeta = {
|
|
4813
|
+
...candidate.syncMeta,
|
|
4814
|
+
lastSyncedAt: timestamp,
|
|
4815
|
+
lastSyncAttemptAt: timestamp,
|
|
4816
|
+
lastSyncError: null,
|
|
4817
|
+
lastSyncFailure: clone(failure),
|
|
4818
|
+
lastAcknowledgedRevision: Math.max(
|
|
4819
|
+
rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
|
|
4820
|
+
Number(candidate.remoteBaseline.revision)
|
|
4821
|
+
)
|
|
4822
|
+
};
|
|
4823
|
+
rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
|
|
4824
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4825
|
+
workspace = candidate;
|
|
4826
|
+
notify({
|
|
4827
|
+
type: 'synced',
|
|
4828
|
+
operations: clone(rejectedResults),
|
|
4829
|
+
failure: clone(failure)
|
|
4830
|
+
});
|
|
4831
|
+
return {
|
|
4832
|
+
success: true,
|
|
4833
|
+
failure: clone(failure),
|
|
4834
|
+
error,
|
|
4835
|
+
class: failure.class,
|
|
4836
|
+
status: failure.status,
|
|
4837
|
+
code: failure.code,
|
|
4838
|
+
rejectedCount: ready.length,
|
|
4839
|
+
syncedCount: 0,
|
|
4840
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4841
|
+
identityRejectedCount
|
|
4842
|
+
};
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
// Generic terminal failures are diagnostics only. In particular, do
|
|
4846
|
+
// not remove an entire ready batch merely because an old gateway returned
|
|
4847
|
+
// a non-retryable envelope. Block the FIFO explicitly so callers cannot
|
|
4848
|
+
// accidentally resend an unlocatable terminal batch forever.
|
|
4849
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
|
|
4850
|
+
return persistTerminalFailureBlock({
|
|
4851
|
+
failure,
|
|
4852
|
+
error,
|
|
4853
|
+
captured,
|
|
4854
|
+
timestamp,
|
|
4855
|
+
identityRejectedCount
|
|
4856
|
+
});
|
|
4857
|
+
};
|
|
4858
|
+
|
|
4859
|
+
const processRemote = async (remoteValue, captured) => {
|
|
4860
|
+
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
4861
|
+
const candidate = clone(workspace);
|
|
4862
|
+
const remote = rtlEnvelopePayload(remoteValue);
|
|
4863
|
+
const remoteRevision = Number(remote?.revision);
|
|
4864
|
+
if (!Number.isSafeInteger(remoteRevision) || remoteRevision < 0) {
|
|
4865
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4866
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
4867
|
+
code: 'invalid_remote_revision',
|
|
4868
|
+
reason: 'invalid_remote_revision',
|
|
4869
|
+
message: 'invalid_remote_revision'
|
|
4870
|
+
});
|
|
4871
|
+
return {
|
|
4872
|
+
success: false,
|
|
4873
|
+
protocolError: true,
|
|
4874
|
+
reason: 'invalid_remote_revision',
|
|
4875
|
+
failure
|
|
4876
|
+
};
|
|
4877
|
+
}
|
|
4878
|
+
if (remoteRevision <= candidate.remoteBaseline.revision) {
|
|
4879
|
+
return {success: true, ignored: true};
|
|
4880
|
+
}
|
|
4881
|
+
let baselineValue = remoteValue;
|
|
4882
|
+
const remoteState = remote?.state ?? remote?.data;
|
|
4883
|
+
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
4884
|
+
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
4885
|
+
if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
|
|
4886
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4887
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
4888
|
+
code: 'revision_gap',
|
|
4889
|
+
reason: 'revision_gap',
|
|
4890
|
+
message: 'revision_gap'
|
|
4891
|
+
});
|
|
4892
|
+
return {
|
|
4893
|
+
success: false,
|
|
4894
|
+
reason: 'revision_gap',
|
|
4895
|
+
bootstrapRequired: true,
|
|
4896
|
+
failure,
|
|
4897
|
+
error: toRecordTimeLabelCloudFailureError(failure)
|
|
4898
|
+
};
|
|
4899
|
+
}
|
|
4900
|
+
const context = rtlSessionContext(captured, candidate, client);
|
|
4901
|
+
baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
|
|
4902
|
+
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
4903
|
+
}
|
|
4904
|
+
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
4905
|
+
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
4906
|
+
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
4907
|
+
workspace = candidate;
|
|
4908
|
+
notify({type: 'remote_merged'});
|
|
4909
|
+
return getSnapshot();
|
|
4910
|
+
};
|
|
4911
|
+
|
|
4912
|
+
const createRemoteReadyState = () => {
|
|
4913
|
+
const generation = ++remoteReadyGeneration;
|
|
4914
|
+
let resolvePromise;
|
|
4915
|
+
let rejectPromise;
|
|
4916
|
+
const state = {
|
|
4917
|
+
generation,
|
|
4918
|
+
settled: false,
|
|
4919
|
+
promise: new Promise((resolve, reject) => {
|
|
4920
|
+
resolvePromise = resolve;
|
|
4921
|
+
rejectPromise = reject;
|
|
4922
|
+
}),
|
|
4923
|
+
resolve(value = {}) {
|
|
4924
|
+
if (state.settled) return;
|
|
4925
|
+
state.settled = true;
|
|
4926
|
+
resolvePromise({
|
|
4927
|
+
success: true,
|
|
4928
|
+
...(value && typeof value === 'object' ? value : {})
|
|
4929
|
+
});
|
|
4930
|
+
},
|
|
4931
|
+
reject(error) {
|
|
4932
|
+
if (state.settled) return;
|
|
4933
|
+
state.settled = true;
|
|
4934
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
4935
|
+
let canonicalError = error;
|
|
4936
|
+
if (error instanceof Error) {
|
|
4937
|
+
Object.assign(error, {
|
|
4938
|
+
schemaVersion: failure.schemaVersion,
|
|
4939
|
+
class: failure.class,
|
|
4940
|
+
code: failure.code,
|
|
4941
|
+
reason: failure.reason,
|
|
4942
|
+
status: failure.status,
|
|
4943
|
+
retryable: failure.retryable,
|
|
4944
|
+
retryAfterMs: failure.retryAfterMs,
|
|
4945
|
+
bootstrapRequired: failure.bootstrapRequired,
|
|
4946
|
+
requestId: failure.requestId
|
|
4947
|
+
});
|
|
4948
|
+
if (!error.failure) {
|
|
4949
|
+
Object.defineProperty(error, 'failure', {
|
|
4950
|
+
value: failure,
|
|
4951
|
+
enumerable: false,
|
|
4952
|
+
configurable: true
|
|
4953
|
+
});
|
|
4954
|
+
}
|
|
4955
|
+
} else {
|
|
4956
|
+
canonicalError = toRecordTimeLabelCloudFailureError(failure);
|
|
4957
|
+
}
|
|
4958
|
+
rejectPromise(canonicalError);
|
|
4959
|
+
}
|
|
4960
|
+
};
|
|
4961
|
+
// Readiness is an opt-in host boundary. Keep a rejection observable to a
|
|
4962
|
+
// waiter without turning clients that have not adopted it yet into an
|
|
4963
|
+
// unhandled-rejection source.
|
|
4964
|
+
state.promise.catch(() => null);
|
|
4965
|
+
return state;
|
|
4966
|
+
};
|
|
4967
|
+
|
|
4968
|
+
const staleRemoteReady = (state, reason = 'stale_session') => {
|
|
4969
|
+
if (!state || state.settled) return;
|
|
4970
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
4971
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
|
|
4972
|
+
code: reason,
|
|
4973
|
+
reason
|
|
4974
|
+
});
|
|
4975
|
+
state.resolve({success: false, stale: true, reason, failure});
|
|
4976
|
+
};
|
|
4977
|
+
|
|
4978
|
+
const isCurrentRemoteReady = (state) => (
|
|
4979
|
+
!destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
|
|
4980
|
+
);
|
|
4981
|
+
|
|
4982
|
+
const stopCloudSubscription = ({settleReady = true} = {}) => {
|
|
4983
|
+
if (typeof unsubscribeCloud === 'function') {
|
|
4984
|
+
try { unsubscribeCloud(); } catch (error) {
|
|
4985
|
+
logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
unsubscribeCloud = null;
|
|
4989
|
+
subscriptionContext = null;
|
|
4990
|
+
if (settleReady) staleRemoteReady(remoteReadyState);
|
|
4991
|
+
};
|
|
4992
|
+
|
|
4993
|
+
const resetForSessionIdentity = (captured) => {
|
|
4994
|
+
stopCloudSubscription();
|
|
4995
|
+
const sameOwner = Boolean(
|
|
4996
|
+
rtlNormalizeUid(workspace.ownerUid) &&
|
|
4997
|
+
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(captured?.uid)
|
|
4998
|
+
);
|
|
4999
|
+
const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
|
|
5000
|
+
workspace = sameOwner && epochIncreased
|
|
5001
|
+
? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
|
|
5002
|
+
: rtlEmptyDurableWorkspace({
|
|
5003
|
+
ownerUid: captured?.uid,
|
|
5004
|
+
workspaceEpoch: captured?.workspaceEpoch
|
|
5005
|
+
});
|
|
5006
|
+
initialized = false;
|
|
5007
|
+
hydrationRequired = true;
|
|
5008
|
+
notify({type: 'session_changed'});
|
|
5009
|
+
};
|
|
5010
|
+
|
|
5011
|
+
const startCloudSubscription = (captured, candidate) => {
|
|
5012
|
+
stopCloudSubscription();
|
|
5013
|
+
const readyState = createRemoteReadyState();
|
|
5014
|
+
remoteReadyState = readyState;
|
|
5015
|
+
if (!initialized || hydrationRequired) {
|
|
5016
|
+
readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
|
|
5017
|
+
return;
|
|
4032
5018
|
}
|
|
4033
5019
|
if (!captured?.uid) {
|
|
4034
5020
|
readyState.resolve({skipped: true, reason: 'anonymous'});
|
|
4035
5021
|
return;
|
|
4036
5022
|
}
|
|
4037
5023
|
if (typeof cloud?.subscribe !== 'function') {
|
|
4038
|
-
readyState.
|
|
5024
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5025
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5026
|
+
code: 'remote_provider_missing',
|
|
5027
|
+
reason: 'remote_provider_missing',
|
|
5028
|
+
message: 'remote_provider_missing'
|
|
5029
|
+
}));
|
|
4039
5030
|
return;
|
|
4040
5031
|
}
|
|
4041
5032
|
const context = rtlSessionContext(captured, candidate, client);
|
|
@@ -4043,24 +5034,100 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4043
5034
|
let subscriptionReturned = false;
|
|
4044
5035
|
let adapterReadyExpected = false;
|
|
4045
5036
|
let firstCallbackResult = null;
|
|
5037
|
+
let firstRootObservation = null;
|
|
5038
|
+
let latestRootObservationRevision = null;
|
|
5039
|
+
let adapterCatchUpProof = null;
|
|
5040
|
+
const settleReadyIfProven = () => {
|
|
5041
|
+
if (!isCurrentRemoteReady(readyState) || readyState.settled ||
|
|
5042
|
+
!firstRootObservation || (adapterReadyExpected && !adapterCatchUpProof)) {
|
|
5043
|
+
return;
|
|
5044
|
+
}
|
|
5045
|
+
const baselineRevision = Number.isSafeInteger(workspace.remoteBaseline.revision)
|
|
5046
|
+
? workspace.remoteBaseline.revision
|
|
5047
|
+
: 0;
|
|
5048
|
+
const proof = adapterCatchUpProof || {};
|
|
5049
|
+
const revision = Number.isSafeInteger(Number(proof.revision))
|
|
5050
|
+
? Number(proof.revision)
|
|
5051
|
+
: baselineRevision;
|
|
5052
|
+
const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
|
|
5053
|
+
? Number(proof.caughtUpToRevision)
|
|
5054
|
+
: revision;
|
|
5055
|
+
if (revision < 0 || caughtUpToRevision < 0) {
|
|
5056
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5057
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5058
|
+
code: 'invalid_remote_ready_revision',
|
|
5059
|
+
reason: 'invalid_remote_ready_revision',
|
|
5060
|
+
message: 'invalid_remote_ready_revision'
|
|
5061
|
+
}));
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
5064
|
+
const observedRevision = Number.isSafeInteger(latestRootObservationRevision)
|
|
5065
|
+
? latestRootObservationRevision
|
|
5066
|
+
: baselineRevision;
|
|
5067
|
+
const requiredRevision = Math.max(baselineRevision, observedRevision);
|
|
5068
|
+
if (revision < requiredRevision || caughtUpToRevision < requiredRevision) {
|
|
5069
|
+
readyState.reject(toRecordTimeLabelCloudFailureError({
|
|
5070
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5071
|
+
code: 'stale_remote_ready_revision',
|
|
5072
|
+
reason: 'stale_remote_ready_revision',
|
|
5073
|
+
message: 'stale_remote_ready_revision'
|
|
5074
|
+
}));
|
|
5075
|
+
return;
|
|
5076
|
+
}
|
|
5077
|
+
// A proof for a revision the root listener has not processed yet must
|
|
5078
|
+
// remain behind the barrier. A later root observation retries settling.
|
|
5079
|
+
if (revision > requiredRevision || caughtUpToRevision > requiredRevision) return;
|
|
5080
|
+
readyState.resolve({
|
|
5081
|
+
sessionKey: proof.sessionKey ??
|
|
5082
|
+
context.sessionKey ??
|
|
5083
|
+
captured.sessionToken ??
|
|
5084
|
+
captured.uid ??
|
|
5085
|
+
null,
|
|
5086
|
+
revision,
|
|
5087
|
+
caughtUpToRevision
|
|
5088
|
+
});
|
|
5089
|
+
};
|
|
5090
|
+
const handleRootObservation = async (result, observedRevision) => {
|
|
5091
|
+
if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
|
|
5092
|
+
if (result?.stale === true || result?.reason === 'stale_session') return;
|
|
5093
|
+
if (result?.success === false) {
|
|
5094
|
+
readyState.reject(toRecordTimeLabelCloudFailureError(
|
|
5095
|
+
result.failure || {
|
|
5096
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5097
|
+
code: result.reason || 'remote_initial_observation_failed',
|
|
5098
|
+
reason: result.reason || 'remote_initial_observation_failed'
|
|
5099
|
+
}
|
|
5100
|
+
));
|
|
5101
|
+
return;
|
|
5102
|
+
}
|
|
5103
|
+
firstRootObservation = result || {success: true};
|
|
5104
|
+
if (Number.isSafeInteger(observedRevision) && observedRevision >= 0) {
|
|
5105
|
+
latestRootObservationRevision = Math.max(
|
|
5106
|
+
latestRootObservationRevision ?? 0,
|
|
5107
|
+
observedRevision
|
|
5108
|
+
);
|
|
5109
|
+
}
|
|
5110
|
+
if (!adapterReadyExpected) {
|
|
5111
|
+
adapterCatchUpProof = {
|
|
5112
|
+
success: true,
|
|
5113
|
+
sessionKey: captured.sessionToken ?? captured.uid ?? null,
|
|
5114
|
+
revision: workspace.remoteBaseline.revision,
|
|
5115
|
+
caughtUpToRevision: workspace.remoteBaseline.revision
|
|
5116
|
+
};
|
|
5117
|
+
}
|
|
5118
|
+
settleReadyIfProven();
|
|
5119
|
+
};
|
|
4046
5120
|
const onRemote = (remoteValue) => {
|
|
4047
5121
|
if (destroyed || !isCurrentRemoteReady(readyState)) {
|
|
4048
5122
|
return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
|
|
4049
5123
|
}
|
|
5124
|
+
const observedRevisionValue = Number(rtlEnvelopePayload(remoteValue)?.revision);
|
|
5125
|
+
const observedRevision = Number.isSafeInteger(observedRevisionValue) && observedRevisionValue >= 0
|
|
5126
|
+
? observedRevisionValue
|
|
5127
|
+
: null;
|
|
4050
5128
|
const callbackResult = enqueue(() => processRemote(remoteValue, captured));
|
|
4051
5129
|
if (!firstCallbackResult) firstCallbackResult = callbackResult;
|
|
4052
|
-
callbackResult.then((result) => {
|
|
4053
|
-
if (
|
|
4054
|
-
subscriptionReturned &&
|
|
4055
|
-
!adapterReadyExpected &&
|
|
4056
|
-
isCurrentRemoteReady(readyState)
|
|
4057
|
-
) {
|
|
4058
|
-
readyState.resolve({
|
|
4059
|
-
revision: workspace.remoteBaseline.revision,
|
|
4060
|
-
result
|
|
4061
|
-
});
|
|
4062
|
-
}
|
|
4063
|
-
}).catch((error) => {
|
|
5130
|
+
callbackResult.then((result) => handleRootObservation(result, observedRevision)).catch((error) => {
|
|
4064
5131
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
4065
5132
|
readyState.reject(error);
|
|
4066
5133
|
}
|
|
@@ -4076,29 +5143,52 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4076
5143
|
logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
|
|
4077
5144
|
return;
|
|
4078
5145
|
}
|
|
4079
|
-
unsubscribeCloud = typeof disposer === 'function'
|
|
5146
|
+
unsubscribeCloud = typeof disposer === 'function'
|
|
5147
|
+
? disposer
|
|
5148
|
+
: (typeof disposer?.unsubscribe === 'function' ? () => disposer.unsubscribe() : null);
|
|
5149
|
+
const onSubscriptionError = (error) => {
|
|
5150
|
+
if (!isCurrentRemoteReady(readyState)) return;
|
|
5151
|
+
stopCloudSubscription({settleReady: false});
|
|
5152
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
5153
|
+
readyState.reject(toRecordTimeLabelCloudFailureError(failure));
|
|
5154
|
+
notify({type: 'remote_error', failure: clone(failure)});
|
|
5155
|
+
};
|
|
5156
|
+
try {
|
|
5157
|
+
if (typeof disposer?.setErrorHandler === 'function') {
|
|
5158
|
+
disposer.setErrorHandler(onSubscriptionError);
|
|
5159
|
+
} else if (typeof disposer?.onError === 'function') {
|
|
5160
|
+
disposer.onError(onSubscriptionError);
|
|
5161
|
+
}
|
|
5162
|
+
} catch (error) {
|
|
5163
|
+
onSubscriptionError(error);
|
|
5164
|
+
return;
|
|
5165
|
+
}
|
|
4080
5166
|
adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
|
|
4081
5167
|
subscriptionReturned = true;
|
|
4082
5168
|
if (adapterReadyExpected) {
|
|
4083
5169
|
Promise.resolve(disposer.ready).then((result) => {
|
|
4084
5170
|
if (!isCurrentRemoteReady(readyState)) return;
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
5171
|
+
if (result?.success === false) {
|
|
5172
|
+
readyState.reject(toRecordTimeLabelCloudFailureError(
|
|
5173
|
+
result.failure || {
|
|
5174
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5175
|
+
code: result.reason || 'remote_initial_observation_failed',
|
|
5176
|
+
reason: result.reason || 'remote_initial_observation_failed'
|
|
5177
|
+
}
|
|
5178
|
+
));
|
|
5179
|
+
} else {
|
|
5180
|
+
adapterCatchUpProof = result && typeof result === 'object'
|
|
5181
|
+
? {...result, success: true}
|
|
5182
|
+
: {success: true};
|
|
5183
|
+
settleReadyIfProven();
|
|
5184
|
+
}
|
|
4089
5185
|
}).catch((error) => {
|
|
4090
5186
|
if (isCurrentRemoteReady(readyState) && !readyState.settled) {
|
|
4091
5187
|
readyState.reject(error);
|
|
4092
5188
|
}
|
|
4093
5189
|
});
|
|
4094
5190
|
} else if (firstCallbackResult) {
|
|
4095
|
-
firstCallbackResult.then((
|
|
4096
|
-
if (!isCurrentRemoteReady(readyState)) return;
|
|
4097
|
-
readyState.resolve({
|
|
4098
|
-
revision: workspace.remoteBaseline.revision,
|
|
4099
|
-
result
|
|
4100
|
-
});
|
|
4101
|
-
}).catch(() => null);
|
|
5191
|
+
firstCallbackResult.then(() => settleReadyIfProven()).catch(() => null);
|
|
4102
5192
|
}
|
|
4103
5193
|
};
|
|
4104
5194
|
|
|
@@ -4114,10 +5204,8 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4114
5204
|
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
4115
5205
|
const loadedBaseline = loaded?.remoteBaseline;
|
|
4116
5206
|
const committedRevision = Number(loadedBaseline?.revision);
|
|
4117
|
-
const hasValidCommittedBaseline =
|
|
4118
|
-
|
|
4119
|
-
Number.isFinite(committedRevision) &&
|
|
4120
|
-
committedRevision > 0;
|
|
5207
|
+
const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
|
|
5208
|
+
const persistedHydrationRequired = loaded?.syncMeta?.hydrationRequired === true;
|
|
4121
5209
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
4122
5210
|
const identityChecked = rtlQuarantineOperations(
|
|
4123
5211
|
candidate,
|
|
@@ -4129,12 +5217,22 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4129
5217
|
const context = rtlSessionContext(captured, candidate, client);
|
|
4130
5218
|
|
|
4131
5219
|
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
5220
|
+
!persistedHydrationRequired &&
|
|
4132
5221
|
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
4133
5222
|
hasValidCommittedBaseline &&
|
|
4134
5223
|
candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
|
|
4135
5224
|
candidate.remoteBaseline.revision === committedRevision &&
|
|
4136
5225
|
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
5226
|
+
let obtainedFreshBaseline = canReuseCommitted;
|
|
4137
5227
|
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
5228
|
+
// Do not carry untrusted resume state into a fresh hydration attempt.
|
|
5229
|
+
// Only the bootstrap response may establish the next cursor/marker.
|
|
5230
|
+
candidate.remoteBaseline.changeCursor = null;
|
|
5231
|
+
candidate.syncMeta = {...candidate.syncMeta};
|
|
5232
|
+
['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding', 'changeCursorBinding']
|
|
5233
|
+
.forEach((key) => {
|
|
5234
|
+
delete candidate.syncMeta[key];
|
|
5235
|
+
});
|
|
4138
5236
|
let bootstrap;
|
|
4139
5237
|
try {
|
|
4140
5238
|
bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
|
|
@@ -4143,14 +5241,41 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4143
5241
|
));
|
|
4144
5242
|
} catch (error) {
|
|
4145
5243
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5244
|
+
if (persistedHydrationRequired) {
|
|
5245
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5246
|
+
await persist(candidate, captured);
|
|
5247
|
+
}
|
|
4146
5248
|
rethrowClassifiedCloudFailure(captured, error);
|
|
4147
5249
|
}
|
|
4148
5250
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
4149
|
-
applyRemoteBaseline(candidate, bootstrap);
|
|
5251
|
+
const applied = applyRemoteBaseline(candidate, bootstrap);
|
|
5252
|
+
if (applied.stale) {
|
|
5253
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5254
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5255
|
+
workspace = candidate;
|
|
5256
|
+
initialized = true;
|
|
5257
|
+
hydrationRequired = true;
|
|
5258
|
+
notify({type: 'initialized'});
|
|
5259
|
+
return getSnapshot();
|
|
5260
|
+
}
|
|
5261
|
+
delete candidate.syncMeta.terminalFailureBlock;
|
|
5262
|
+
clearPersistedHydrationBarrier(candidate);
|
|
5263
|
+
obtainedFreshBaseline = true;
|
|
5264
|
+
}
|
|
5265
|
+
|
|
5266
|
+
if (persistedHydrationRequired && !obtainedFreshBaseline) {
|
|
5267
|
+
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5268
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5269
|
+
workspace = candidate;
|
|
5270
|
+
initialized = true;
|
|
5271
|
+
hydrationRequired = true;
|
|
5272
|
+
notify({type: 'initialized'});
|
|
5273
|
+
return getSnapshot();
|
|
4150
5274
|
}
|
|
4151
5275
|
|
|
4152
5276
|
// Always persist the normalized durable shape before subscribing. This
|
|
4153
5277
|
// also makes legacy migration atomic from the engine's point of view.
|
|
5278
|
+
clearPersistedHydrationBarrier(candidate);
|
|
4154
5279
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
4155
5280
|
workspace = candidate;
|
|
4156
5281
|
initialized = true;
|
|
@@ -4168,14 +5293,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4168
5293
|
startCloudSubscription(current, workspace);
|
|
4169
5294
|
return {success: true, tokenRefreshed: true};
|
|
4170
5295
|
}
|
|
4171
|
-
|
|
4172
|
-
workspace = rtlEmptyDurableWorkspace({
|
|
4173
|
-
ownerUid: current.uid,
|
|
4174
|
-
workspaceEpoch: current.workspaceEpoch
|
|
4175
|
-
});
|
|
4176
|
-
initialized = false;
|
|
4177
|
-
hydrationRequired = true;
|
|
4178
|
-
notify({type: 'session_changed'});
|
|
5296
|
+
resetForSessionIdentity(current);
|
|
4179
5297
|
return {success: true, sessionChanged: true};
|
|
4180
5298
|
}).catch((error) => {
|
|
4181
5299
|
logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
|
|
@@ -4191,7 +5309,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4191
5309
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
4192
5310
|
}
|
|
4193
5311
|
const captured = capture();
|
|
5312
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
4194
5313
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5314
|
+
if (!workspaceMatchesSession(captured)) {
|
|
5315
|
+
resetForSessionIdentity(captured);
|
|
5316
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5317
|
+
}
|
|
4195
5318
|
const input = Array.isArray(operations)
|
|
4196
5319
|
? operations
|
|
4197
5320
|
: (operations && typeof operations === 'object' ? [operations] : []);
|
|
@@ -4272,11 +5395,25 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4272
5395
|
};
|
|
4273
5396
|
|
|
4274
5397
|
const syncInternal = async (reason) => {
|
|
5398
|
+
const captured = capture();
|
|
5399
|
+
throwIfAuthTransitionLatched(captured);
|
|
4275
5400
|
if (!initialized || hydrationRequired) {
|
|
4276
5401
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
4277
5402
|
}
|
|
4278
|
-
const captured = capture();
|
|
4279
5403
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
5404
|
+
if (!workspaceMatchesSession(captured)) {
|
|
5405
|
+
resetForSessionIdentity(captured);
|
|
5406
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5407
|
+
}
|
|
5408
|
+
const blockedFailure = workspace.syncMeta?.terminalFailureBlock;
|
|
5409
|
+
if (blockedFailure) {
|
|
5410
|
+
const failure = normalizeRecordTimeLabelCloudFailure(blockedFailure);
|
|
5411
|
+
return failureOutcome(
|
|
5412
|
+
failure,
|
|
5413
|
+
toRecordTimeLabelCloudFailureError(failure),
|
|
5414
|
+
{protocolError: true, blocked: true, identityRejectedCount: 0}
|
|
5415
|
+
);
|
|
5416
|
+
}
|
|
4280
5417
|
const timestamp = now();
|
|
4281
5418
|
const identityChecked = rtlQuarantineOperations(
|
|
4282
5419
|
workspace,
|
|
@@ -4328,7 +5465,22 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4328
5465
|
};
|
|
4329
5466
|
}
|
|
4330
5467
|
if (typeof cloud?.applyOperations !== 'function') {
|
|
4331
|
-
|
|
5468
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5469
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5470
|
+
code: 'missing_cloud_apply_operations',
|
|
5471
|
+
reason: 'missing_cloud_apply_operations',
|
|
5472
|
+
message: 'missing_cloud_apply_operations'
|
|
5473
|
+
});
|
|
5474
|
+
return {
|
|
5475
|
+
success: false,
|
|
5476
|
+
failure,
|
|
5477
|
+
error: toRecordTimeLabelCloudFailureError(failure),
|
|
5478
|
+
class: failure.class,
|
|
5479
|
+
code: failure.code,
|
|
5480
|
+
reason: failure.reason,
|
|
5481
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5482
|
+
identityRejectedCount
|
|
5483
|
+
};
|
|
4332
5484
|
}
|
|
4333
5485
|
const context = rtlSessionContext(captured, workspace, client);
|
|
4334
5486
|
const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
|
|
@@ -4350,59 +5502,79 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4350
5502
|
}, context);
|
|
4351
5503
|
} catch (error) {
|
|
4352
5504
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
};
|
|
5505
|
+
return settleApplyFailure({
|
|
5506
|
+
value: error,
|
|
5507
|
+
captured,
|
|
5508
|
+
context,
|
|
5509
|
+
ready,
|
|
5510
|
+
timestamp,
|
|
5511
|
+
identityRejectedCount
|
|
5512
|
+
});
|
|
4362
5513
|
}
|
|
4363
5514
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4364
5515
|
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
5516
|
+
const hasCanonicalFailure = Boolean(
|
|
5517
|
+
response &&
|
|
5518
|
+
typeof response === 'object' &&
|
|
5519
|
+
Object.prototype.hasOwnProperty.call(response, 'failure') &&
|
|
5520
|
+
response.failure != null
|
|
5521
|
+
);
|
|
5522
|
+
if (hasCanonicalFailure || !rtlEnvelopeSuccess(response)) {
|
|
5523
|
+
// An explicit operationResults array remains a supported legacy
|
|
5524
|
+
// response shape, but a canonical `failure` is authoritative even if a
|
|
5525
|
+
// gateway accidentally includes an empty/partial result array beside it.
|
|
5526
|
+
if (hasCanonicalFailure || !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults')) {
|
|
5527
|
+
return settleApplyFailure({
|
|
5528
|
+
value: response,
|
|
5529
|
+
captured,
|
|
5530
|
+
context,
|
|
5531
|
+
ready,
|
|
5532
|
+
timestamp,
|
|
5533
|
+
identityRejectedCount
|
|
5534
|
+
});
|
|
4372
5535
|
}
|
|
4373
|
-
const retryAfterMs = rtlRetryAfterMs(response);
|
|
4374
|
-
const candidate = clone(workspace);
|
|
4375
|
-
const byId = new Set(ready.map((operation) => operation.id));
|
|
4376
|
-
candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
|
|
4377
|
-
byId.has(operation.id)
|
|
4378
|
-
? makeRetryOperation(operation, response, retryAfterMs, timestamp)
|
|
4379
|
-
: operation
|
|
4380
|
-
));
|
|
4381
|
-
candidate.syncMeta = {
|
|
4382
|
-
...candidate.syncMeta,
|
|
4383
|
-
lastSyncAttemptAt: timestamp,
|
|
4384
|
-
lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
|
|
4385
|
-
};
|
|
4386
|
-
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
4387
|
-
workspace = candidate;
|
|
4388
|
-
notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
|
|
4389
|
-
const retryAt = Math.min(...candidate.pendingOperations
|
|
4390
|
-
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
4391
|
-
.map((operation) => Number(operation.nextRetryAt)));
|
|
4392
|
-
return {
|
|
4393
|
-
success: false,
|
|
4394
|
-
retryable: true,
|
|
4395
|
-
retryAfterMs,
|
|
4396
|
-
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
4397
|
-
pendingCount: workspace.pendingOperations.length,
|
|
4398
|
-
identityRejectedCount
|
|
4399
|
-
};
|
|
4400
5536
|
}
|
|
4401
5537
|
|
|
5538
|
+
const responseRevisionInfo = rtlExplicitRevisionInfo(response);
|
|
5539
|
+
if (!responseRevisionInfo.valid) {
|
|
5540
|
+
const protocolError = rtlInvalidRevisionError();
|
|
5541
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5542
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5543
|
+
code: protocolError.code,
|
|
5544
|
+
reason: protocolError.code,
|
|
5545
|
+
message: protocolError.message
|
|
5546
|
+
});
|
|
5547
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
5548
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
5549
|
+
return persistTerminalFailureBlock({
|
|
5550
|
+
failure,
|
|
5551
|
+
error,
|
|
5552
|
+
captured,
|
|
5553
|
+
timestamp,
|
|
5554
|
+
identityRejectedCount,
|
|
5555
|
+
extra: {protocolError: true}
|
|
5556
|
+
});
|
|
5557
|
+
}
|
|
5558
|
+
const responseBaseline = rtlEnvelopePayload(response);
|
|
5559
|
+
const responseRevision = responseRevisionInfo.revision;
|
|
4402
5560
|
const parsed = normalizeOperationResults(response, wireOperations);
|
|
4403
5561
|
if (parsed.error) {
|
|
4404
|
-
|
|
4405
|
-
|
|
5562
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5563
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5564
|
+
code: parsed.error.code || 'protocol_error',
|
|
5565
|
+
reason: parsed.error.reason || parsed.error.code || 'protocol_error',
|
|
5566
|
+
message: parsed.error.message || parsed.error.code || 'protocol_error'
|
|
5567
|
+
});
|
|
5568
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
5569
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
5570
|
+
return persistTerminalFailureBlock({
|
|
5571
|
+
failure,
|
|
5572
|
+
error,
|
|
5573
|
+
captured,
|
|
5574
|
+
timestamp,
|
|
5575
|
+
identityRejectedCount,
|
|
5576
|
+
extra: {protocolError: true}
|
|
5577
|
+
});
|
|
4406
5578
|
}
|
|
4407
5579
|
let sawRetryable = false;
|
|
4408
5580
|
for (const result of parsed.results) {
|
|
@@ -4412,7 +5584,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4412
5584
|
const error = new Error('sync_protocol_fifo_retry_barrier_violation');
|
|
4413
5585
|
error.code = 'sync_protocol_fifo_retry_barrier_violation';
|
|
4414
5586
|
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
|
|
4415
|
-
|
|
5587
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5588
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5589
|
+
code: error.code,
|
|
5590
|
+
reason: error.code,
|
|
5591
|
+
message: error.message
|
|
5592
|
+
});
|
|
5593
|
+
return persistTerminalFailureBlock({
|
|
5594
|
+
failure,
|
|
5595
|
+
error,
|
|
5596
|
+
captured,
|
|
5597
|
+
timestamp,
|
|
5598
|
+
identityRejectedCount,
|
|
5599
|
+
extra: {protocolError: true}
|
|
5600
|
+
});
|
|
4416
5601
|
}
|
|
4417
5602
|
}
|
|
4418
5603
|
const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
|
|
@@ -4440,50 +5625,124 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4440
5625
|
nextPending.push(makeRetryOperation(operation, result, null, timestamp));
|
|
4441
5626
|
} else if (result.status === 'rejected') {
|
|
4442
5627
|
completedOperations.push({operation, status: result.status});
|
|
4443
|
-
nextRejected
|
|
5628
|
+
Object.defineProperty(nextRejected, operation.id, {
|
|
5629
|
+
value: {
|
|
4444
5630
|
id: operation.id,
|
|
4445
5631
|
operation: clone(operation),
|
|
4446
5632
|
status: 'rejected',
|
|
4447
5633
|
reason: result.reason ?? result.error ?? result.code ?? 'rejected',
|
|
4448
5634
|
rejectedAt: timestamp,
|
|
4449
5635
|
response: rtlStripSessionTokens(result)
|
|
4450
|
-
|
|
5636
|
+
},
|
|
5637
|
+
enumerable: true,
|
|
5638
|
+
configurable: true,
|
|
5639
|
+
writable: true
|
|
5640
|
+
});
|
|
4451
5641
|
}
|
|
4452
5642
|
}
|
|
4453
|
-
appliedOperations.forEach((operation) => {
|
|
4454
|
-
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
4455
|
-
applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
|
|
4456
|
-
);
|
|
4457
|
-
});
|
|
4458
|
-
const responseRevision = Number(response.revision ?? response.remoteRevision);
|
|
4459
|
-
const responseBaseline = rtlEnvelopePayload(response);
|
|
4460
5643
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
4461
|
-
|
|
4462
|
-
|
|
5644
|
+
const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
|
|
5645
|
+
Number.isSafeInteger(responseRevision)
|
|
5646
|
+
? {...responseBaseline, revision: responseRevision}
|
|
5647
|
+
: responseBaseline;
|
|
5648
|
+
const currentBaselineRevision = Number(candidate.remoteBaseline.revision);
|
|
5649
|
+
const hasAllAppliedResults = parsed.results.length > 0 &&
|
|
5650
|
+
parsed.results.every((result) => result.status === 'applied');
|
|
5651
|
+
const responseIsStale = Number.isFinite(responseRevision) &&
|
|
5652
|
+
responseRevision <= currentBaselineRevision;
|
|
5653
|
+
const responseIsExactNext = Number.isFinite(responseRevision) &&
|
|
5654
|
+
responseRevision === currentBaselineRevision + 1;
|
|
5655
|
+
const responseIsForwardGap = Number.isFinite(responseRevision) &&
|
|
5656
|
+
responseRevision > currentBaselineRevision + 1;
|
|
5657
|
+
|
|
5658
|
+
const hasAuthoritativeFullBaseline = !responseIsStale &&
|
|
5659
|
+
rtlDurableIsObject(responseState) &&
|
|
5660
|
+
Number.isSafeInteger(responseRevision) &&
|
|
5661
|
+
responseRevision > currentBaselineRevision;
|
|
5662
|
+
if (
|
|
5663
|
+
!responseIsStale &&
|
|
5664
|
+
rejectedCount > 0 &&
|
|
5665
|
+
typeof cloud?.bootstrap !== 'function' &&
|
|
5666
|
+
!hasAuthoritativeFullBaseline
|
|
5667
|
+
) {
|
|
5668
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5669
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
5670
|
+
code: 'recordtimelabel_bootstrap_required',
|
|
5671
|
+
reason: 'recordtimelabel_bootstrap_required',
|
|
5672
|
+
message: 'recordtimelabel_bootstrap_required'
|
|
5673
|
+
});
|
|
5674
|
+
return {
|
|
5675
|
+
success: false,
|
|
5676
|
+
bootstrapRequired: true,
|
|
5677
|
+
failure,
|
|
5678
|
+
error: toRecordTimeLabelCloudFailureError(failure),
|
|
5679
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5680
|
+
identityRejectedCount
|
|
5681
|
+
};
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5684
|
+
// A receipt replay can arrive after realtime has already promoted the
|
|
5685
|
+
// authoritative state. ACK completion is still durable, but replaying
|
|
5686
|
+
// its operations would apply settings/order changes a second time.
|
|
5687
|
+
let requiresAuthoritativeRecovery = (responseIsForwardGap && rejectedCount === 0) ||
|
|
5688
|
+
(responseIsExactNext && !hasAllAppliedResults && rejectedCount === 0);
|
|
5689
|
+
if (
|
|
5690
|
+
responseIsForwardGap &&
|
|
5691
|
+
rejectedCount === 0 &&
|
|
5692
|
+
typeof cloud?.bootstrap !== 'function' &&
|
|
5693
|
+
typeof cloud?.catchUp !== 'function'
|
|
5694
|
+
) {
|
|
5695
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5696
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
|
|
5697
|
+
code: 'recordtimelabel_bootstrap_required',
|
|
5698
|
+
reason: 'recordtimelabel_bootstrap_required',
|
|
5699
|
+
message: 'recordtimelabel_bootstrap_required'
|
|
5700
|
+
});
|
|
5701
|
+
return {
|
|
5702
|
+
success: false,
|
|
5703
|
+
bootstrapRequired: true,
|
|
5704
|
+
failure,
|
|
5705
|
+
error: toRecordTimeLabelCloudFailureError(failure),
|
|
5706
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5707
|
+
identityRejectedCount
|
|
5708
|
+
};
|
|
5709
|
+
}
|
|
5710
|
+
if (requiresAuthoritativeRecovery) {
|
|
5711
|
+
const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
|
|
5712
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
5713
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
5714
|
+
} else if (!responseIsStale && rtlDurableIsObject(responseState)) {
|
|
5715
|
+
// A same-transaction full baseline is authoritative at the exact next
|
|
5716
|
+
// revision. Older receipt payloads are intentionally ignored above.
|
|
5717
|
+
applyRemoteBaseline(candidate, responseBaselineForApply);
|
|
4463
5718
|
} else if (
|
|
5719
|
+
!responseIsStale &&
|
|
5720
|
+
responseIsExactNext &&
|
|
4464
5721
|
rejectedCount === 0 &&
|
|
4465
5722
|
!sawRetryable &&
|
|
4466
|
-
|
|
4467
|
-
appliedOperations.length > 0 &&
|
|
4468
|
-
Number.isFinite(responseRevision) &&
|
|
4469
|
-
responseRevision === candidate.remoteBaseline.revision + 1
|
|
5723
|
+
hasAllAppliedResults
|
|
4470
5724
|
) {
|
|
4471
|
-
// The v2
|
|
4472
|
-
//
|
|
4473
|
-
|
|
5725
|
+
// The compact v2 ACK omits state. Promote only a complete all-applied
|
|
5726
|
+
// transaction at exactly the next revision; a gap must be recovered.
|
|
5727
|
+
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
5728
|
+
appliedOperations.reduce(
|
|
5729
|
+
(state, operation) => applyRecordTimeLabelOperation(state, operation),
|
|
5730
|
+
candidate.remoteBaseline.state
|
|
5731
|
+
)
|
|
5732
|
+
);
|
|
4474
5733
|
candidate.remoteBaseline.revision = responseRevision;
|
|
4475
5734
|
if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
|
|
4476
5735
|
candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
|
|
4477
5736
|
}
|
|
4478
|
-
} else if (
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
5737
|
+
} else if (!responseIsStale && !Number.isFinite(responseRevision)) {
|
|
5738
|
+
// Legacy envelopes without a revision have no stale-receipt ordering
|
|
5739
|
+
// signal. Preserve their historical local promotion compatibility.
|
|
5740
|
+
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
5741
|
+
appliedOperations.reduce(
|
|
5742
|
+
(state, operation) => applyRecordTimeLabelOperation(state, operation),
|
|
5743
|
+
candidate.remoteBaseline.state
|
|
5744
|
+
)
|
|
5745
|
+
);
|
|
4487
5746
|
}
|
|
4488
5747
|
candidate.pendingOperations = nextPending;
|
|
4489
5748
|
candidate.rejectedOperations = nextRejected;
|
|
@@ -4654,7 +5913,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4654
5913
|
const engine = {
|
|
4655
5914
|
init() {
|
|
4656
5915
|
return enqueue(async () => {
|
|
4657
|
-
|
|
5916
|
+
const captured = capture();
|
|
5917
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
5918
|
+
if (initialized && !hydrationRequired && workspaceMatchesSession(captured)) {
|
|
5919
|
+
return getSnapshot();
|
|
5920
|
+
}
|
|
5921
|
+
if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
|
|
5922
|
+
resetForSessionIdentity(captured);
|
|
5923
|
+
}
|
|
4658
5924
|
return initialize();
|
|
4659
5925
|
});
|
|
4660
5926
|
},
|
|
@@ -4677,10 +5943,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4677
5943
|
|
|
4678
5944
|
waitForRemoteReady() {
|
|
4679
5945
|
if (destroyed) {
|
|
5946
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5947
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
|
|
5948
|
+
code: 'stale_session',
|
|
5949
|
+
reason: 'stale_session'
|
|
5950
|
+
});
|
|
4680
5951
|
return Promise.resolve({
|
|
4681
5952
|
success: false,
|
|
4682
5953
|
stale: true,
|
|
4683
5954
|
reason: 'stale_session',
|
|
5955
|
+
failure,
|
|
4684
5956
|
generation: remoteReadyState.generation
|
|
4685
5957
|
});
|
|
4686
5958
|
}
|
|
@@ -4884,20 +6156,45 @@ const RTL_BULK_OPERATION_TYPES = new Set([
|
|
|
4884
6156
|
]);
|
|
4885
6157
|
const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
|
|
4886
6158
|
|
|
4887
|
-
const
|
|
4888
|
-
const
|
|
4889
|
-
|
|
4890
|
-
);
|
|
4891
|
-
const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
4892
|
-
operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
|
|
4893
|
-
);
|
|
4894
|
-
const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
|
|
4895
|
-
operation?.payload?.trashEntryId || operation?.payload?.id
|
|
6159
|
+
export const isRecordTimeLabelSafeDocumentId = rtlIsSafeDocumentId;
|
|
6160
|
+
const rtlOwn = (object, key) => Boolean(
|
|
6161
|
+
object && Object.prototype.hasOwnProperty.call(object, key)
|
|
4896
6162
|
);
|
|
6163
|
+
|
|
6164
|
+
const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
|
|
6165
|
+
const rtlFirstPresent = (object, keys) => {
|
|
6166
|
+
for (const key of keys) {
|
|
6167
|
+
if (Object.prototype.hasOwnProperty.call(object || {}, key)) return object[key];
|
|
6168
|
+
}
|
|
6169
|
+
return undefined;
|
|
6170
|
+
};
|
|
6171
|
+
const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
6172
|
+
'recordId', 'id'
|
|
6173
|
+
]) ?? operation?.payload?.record?.id ?? null;
|
|
6174
|
+
const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
6175
|
+
'targetFolderId', 'folderId', 'id'
|
|
6176
|
+
]) ?? operation?.payload?.folder?.id ?? operation?.payload?.record?.folderId ?? null;
|
|
6177
|
+
const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
|
|
6178
|
+
'trashEntryId', 'id'
|
|
6179
|
+
]) ?? null;
|
|
4897
6180
|
const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
|
|
4898
6181
|
encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
|
|
4899
6182
|
);
|
|
6183
|
+
const rtlGetLifecycleTombstone = (documents, kind, entityId) => {
|
|
6184
|
+
const logicalId = `${kind}:${normalizeId(entityId)}`;
|
|
6185
|
+
const aliases = lifecycleTombstoneKeyAliases(logicalId);
|
|
6186
|
+
const collection = documents?.lifecycleTombstones || {};
|
|
6187
|
+
for (const alias of aliases) {
|
|
6188
|
+
if (rtlOwn(collection, alias)) return collection[alias];
|
|
6189
|
+
}
|
|
6190
|
+
const rootField = kind === 'folder' ? 'deletedFolderTombstones' : 'deletedRecordTombstones';
|
|
6191
|
+
const rootCollection = documents?.root?.[rootField] || {};
|
|
6192
|
+
return rtlOwn(rootCollection, normalizeId(entityId))
|
|
6193
|
+
? rootCollection[normalizeId(entityId)]
|
|
6194
|
+
: null;
|
|
6195
|
+
};
|
|
4900
6196
|
const rtlCloneDocuments = (documents = {}) => ({
|
|
6197
|
+
...clone(documents || {}),
|
|
4901
6198
|
root: clone(documents.root || null),
|
|
4902
6199
|
records: clone(documents.records || {}),
|
|
4903
6200
|
folders: clone(documents.folders || {}),
|
|
@@ -4906,6 +6203,24 @@ const rtlCloneDocuments = (documents = {}) => ({
|
|
|
4906
6203
|
ops: {}
|
|
4907
6204
|
});
|
|
4908
6205
|
const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
|
|
6206
|
+
const rtlIsSafeOpaqueGroupId = (value) => (
|
|
6207
|
+
typeof value === 'string' && value.length > 0 &&
|
|
6208
|
+
new TextEncoder().encode(value).byteLength <= RECORD_TIMELABEL_SAFE_ID_MAX_BYTES &&
|
|
6209
|
+
!/[\u0000-\u001F\u007F-\u009F]/u.test(value)
|
|
6210
|
+
);
|
|
6211
|
+
const rtlHasUnsafeOrderId = (order, {opaqueGroup = false} = {}) => toArray(order).some((id) => (
|
|
6212
|
+
opaqueGroup ? !rtlIsSafeOpaqueGroupId(id) : !rtlIsSafeDocumentId(id)
|
|
6213
|
+
));
|
|
6214
|
+
const rtlMergeOpaqueOrder = (...orders) => {
|
|
6215
|
+
const seen = new Set();
|
|
6216
|
+
const result = [];
|
|
6217
|
+
orders.flatMap((order) => toArray(order)).forEach((id) => {
|
|
6218
|
+
if (!rtlIsSafeOpaqueGroupId(id) || seen.has(id)) return;
|
|
6219
|
+
seen.add(id);
|
|
6220
|
+
result.push(id);
|
|
6221
|
+
});
|
|
6222
|
+
return result;
|
|
6223
|
+
};
|
|
4909
6224
|
/**
|
|
4910
6225
|
* Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
|
|
4911
6226
|
* so the planner must not drop ids on a prefix guess either.
|
|
@@ -4929,12 +6244,13 @@ const rtlHasInvalidPayloadValue = (value, depth = 0) => {
|
|
|
4929
6244
|
};
|
|
4930
6245
|
|
|
4931
6246
|
const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
4932
|
-
const rtlValidateIdList = (value, index, field, errors) => {
|
|
6247
|
+
const rtlValidateIdList = (value, index, field, errors, {opaqueGroup = false} = {}) => {
|
|
4933
6248
|
if (!Array.isArray(value)) {
|
|
4934
6249
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
4935
6250
|
return;
|
|
4936
6251
|
}
|
|
4937
|
-
|
|
6252
|
+
const isValid = opaqueGroup ? rtlIsSafeOpaqueGroupId : rtlIsSafeDocumentId;
|
|
6253
|
+
if (value.some((id) => !isValid(id))) {
|
|
4938
6254
|
errors.push(`operation_${index}_invalid_${field}`);
|
|
4939
6255
|
}
|
|
4940
6256
|
};
|
|
@@ -4954,8 +6270,13 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
4954
6270
|
break;
|
|
4955
6271
|
case OPERATION_TYPES.RECORD_MOVE:
|
|
4956
6272
|
if (!recordId) errors.push(`operation_${index}_missing_record_id`);
|
|
4957
|
-
|
|
4958
|
-
|
|
6273
|
+
{
|
|
6274
|
+
const targetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6275
|
+
if (targetFolderId === undefined || targetFolderId === null) {
|
|
6276
|
+
errors.push(`operation_${index}_missing_target_folder_id`);
|
|
6277
|
+
} else if (!rtlIsSafeDocumentId(targetFolderId)) {
|
|
6278
|
+
errors.push(`operation_${index}_invalid_target_folder_id`);
|
|
6279
|
+
}
|
|
4959
6280
|
}
|
|
4960
6281
|
break;
|
|
4961
6282
|
case OPERATION_TYPES.RECORD_DELETE:
|
|
@@ -5003,14 +6324,21 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
5003
6324
|
rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
|
|
5004
6325
|
break;
|
|
5005
6326
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
5006
|
-
rtlValidateIdList(
|
|
6327
|
+
rtlValidateIdList(
|
|
6328
|
+
payload.groupOrder || payload.order,
|
|
6329
|
+
index,
|
|
6330
|
+
'group_order',
|
|
6331
|
+
errors,
|
|
6332
|
+
{opaqueGroup: true}
|
|
6333
|
+
);
|
|
5007
6334
|
break;
|
|
5008
6335
|
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
5009
6336
|
rtlValidateIdList(
|
|
5010
6337
|
payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
|
|
5011
6338
|
index,
|
|
5012
6339
|
'expanded_groups',
|
|
5013
|
-
errors
|
|
6340
|
+
errors,
|
|
6341
|
+
{opaqueGroup: true}
|
|
5014
6342
|
);
|
|
5015
6343
|
break;
|
|
5016
6344
|
case OPERATION_TYPES.SETTINGS_UPDATE:
|
|
@@ -5029,9 +6357,10 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
|
|
|
5029
6357
|
export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
|
|
5030
6358
|
const errors = [];
|
|
5031
6359
|
const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
|
|
5032
|
-
toArray(body?.client?.capabilities).
|
|
5033
|
-
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
5034
|
-
|
|
6360
|
+
toArray(body?.client?.capabilities).some((capability) => (
|
|
6361
|
+
capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE ||
|
|
6362
|
+
capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1
|
|
6363
|
+
));
|
|
5035
6364
|
let requestBytes = 0;
|
|
5036
6365
|
try {
|
|
5037
6366
|
requestBytes = rtlByteLength(JSON.stringify(body));
|
|
@@ -5042,7 +6371,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
5042
6371
|
if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
|
|
5043
6372
|
errors.push('unsupported_protocol_version');
|
|
5044
6373
|
}
|
|
5045
|
-
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(
|
|
6374
|
+
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(body.requestId) ||
|
|
6375
|
+
!rtlIsSafeDocumentId(body.requestId)) {
|
|
5046
6376
|
errors.push('invalid_request_id');
|
|
5047
6377
|
}
|
|
5048
6378
|
if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
|
|
@@ -5056,8 +6386,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
5056
6386
|
}
|
|
5057
6387
|
const operationIds = new Set();
|
|
5058
6388
|
operations.forEach((operation, index) => {
|
|
5059
|
-
const id =
|
|
5060
|
-
if (!id || id.length > 200) errors.push(`operation_${index}_invalid_id`);
|
|
6389
|
+
const id = operation?.id;
|
|
6390
|
+
if (!rtlIsSafeDocumentId(id) || id.length > 200) errors.push(`operation_${index}_invalid_id`);
|
|
5061
6391
|
if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
|
|
5062
6392
|
operationIds.add(id);
|
|
5063
6393
|
if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
|
|
@@ -5083,12 +6413,16 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
|
|
|
5083
6413
|
if (!toFiniteTimestamp(operation?.createdAt)) {
|
|
5084
6414
|
errors.push(`operation_${index}_invalid_created_at`);
|
|
5085
6415
|
}
|
|
5086
|
-
if (rtlOperationRecordId(operation)
|
|
6416
|
+
if (rtlOperationRecordId(operation) && !rtlIsSafeDocumentId(rtlOperationRecordId(operation))) {
|
|
5087
6417
|
errors.push(`operation_${index}_invalid_record_id`);
|
|
5088
6418
|
}
|
|
5089
|
-
if (rtlOperationFolderId(operation)
|
|
6419
|
+
if (rtlOperationFolderId(operation) && !rtlIsSafeDocumentId(rtlOperationFolderId(operation))) {
|
|
5090
6420
|
errors.push(`operation_${index}_invalid_folder_id`);
|
|
5091
6421
|
}
|
|
6422
|
+
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
6423
|
+
if (trashEntryId && !rtlIsSafeDocumentId(trashEntryId)) {
|
|
6424
|
+
errors.push(`operation_${index}_invalid_trash_entry_id`);
|
|
6425
|
+
}
|
|
5092
6426
|
});
|
|
5093
6427
|
|
|
5094
6428
|
return {ok: errors.length === 0, errors, requestBytes, operations};
|
|
@@ -5124,13 +6458,35 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
5124
6458
|
if (recordId) {
|
|
5125
6459
|
recordIds.add(recordId);
|
|
5126
6460
|
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
6461
|
+
trashEntryIds.add(normalizeId(payload.trashEntryId) || `record:${recordId}`);
|
|
5127
6462
|
}
|
|
5128
6463
|
break;
|
|
5129
6464
|
}
|
|
5130
6465
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
6466
|
+
case OPERATION_TYPES.FOLDER_RESTORE:
|
|
5131
6467
|
case OPERATION_TYPES.TRASH_PURGE: {
|
|
5132
6468
|
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
5133
6469
|
if (trashEntryId) trashEntryIds.add(trashEntryId);
|
|
6470
|
+
const explicitEntityId = normalizeId(
|
|
6471
|
+
payload.recordId || payload.folderId || payload.entityId
|
|
6472
|
+
);
|
|
6473
|
+
const inferredKind = operation.type === OPERATION_TYPES.FOLDER_RESTORE
|
|
6474
|
+
? 'folder'
|
|
6475
|
+
: operation.type === OPERATION_TYPES.RECORD_RESTORE
|
|
6476
|
+
? 'record'
|
|
6477
|
+
: null;
|
|
6478
|
+
let decodedTrashEntryId = trashEntryId;
|
|
6479
|
+
try { decodedTrashEntryId = decodeURIComponent(trashEntryId || ''); } catch { /* keep raw */ }
|
|
6480
|
+
const inferredEntityId = explicitEntityId || (
|
|
6481
|
+
inferredKind && decodedTrashEntryId?.startsWith(`${inferredKind}:`)
|
|
6482
|
+
? normalizeId(decodedTrashEntryId.slice(inferredKind.length + 1))
|
|
6483
|
+
: ''
|
|
6484
|
+
);
|
|
6485
|
+
if (inferredKind && inferredEntityId) {
|
|
6486
|
+
lifecycleTombstoneIds.add(
|
|
6487
|
+
rtlLifecycleTombstoneDocumentId(inferredKind, inferredEntityId)
|
|
6488
|
+
);
|
|
6489
|
+
}
|
|
5134
6490
|
break;
|
|
5135
6491
|
}
|
|
5136
6492
|
case OPERATION_TYPES.RECORD_REORDER:
|
|
@@ -5219,7 +6575,8 @@ const rtlApplyOperationToPartialDocuments = ({
|
|
|
5219
6575
|
|
|
5220
6576
|
const rtlCanRestoreLocalFolder = (root, folder) => {
|
|
5221
6577
|
if (!folder?.id) return false;
|
|
5222
|
-
const
|
|
6578
|
+
const tombstones = root?.deletedFolderTombstones;
|
|
6579
|
+
const tombstone = rtlOwn(tombstones, folder.id) ? tombstones[folder.id] : null;
|
|
5223
6580
|
return !tombstone;
|
|
5224
6581
|
};
|
|
5225
6582
|
|
|
@@ -5230,8 +6587,9 @@ const rtlBuildLocalFolderDocument = (folder, now) => {
|
|
|
5230
6587
|
}, {now}).folders[folder.id] || null;
|
|
5231
6588
|
};
|
|
5232
6589
|
|
|
5233
|
-
const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
5234
|
-
if (folders
|
|
6590
|
+
const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowContext = false}) => {
|
|
6591
|
+
if (rtlOwn(folders, folderId)) return folders[folderId];
|
|
6592
|
+
if (!allowContext) return null;
|
|
5235
6593
|
const localFolder = toArray(localState?.folders)
|
|
5236
6594
|
.find((folder) => folder?.id === folderId);
|
|
5237
6595
|
if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
|
|
@@ -5246,7 +6604,19 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
|
5246
6604
|
|
|
5247
6605
|
const rtlSetRoot = (target, source) => {
|
|
5248
6606
|
Object.keys(target).forEach((key) => delete target[key]);
|
|
5249
|
-
|
|
6607
|
+
const next = clone(source || {});
|
|
6608
|
+
// `Object.assign` invokes the legacy `__proto__` setter on a normal target.
|
|
6609
|
+
// Define each own key as data instead, so malformed legacy/root documents
|
|
6610
|
+
// cannot mutate the candidate's prototype while the planner is rebuilding
|
|
6611
|
+
// its immutable document map.
|
|
6612
|
+
Reflect.ownKeys(next).forEach((key) => {
|
|
6613
|
+
Object.defineProperty(target, key, {
|
|
6614
|
+
value: next[key],
|
|
6615
|
+
enumerable: true,
|
|
6616
|
+
configurable: true,
|
|
6617
|
+
writable: true
|
|
6618
|
+
});
|
|
6619
|
+
});
|
|
5250
6620
|
};
|
|
5251
6621
|
|
|
5252
6622
|
/**
|
|
@@ -5257,22 +6627,48 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5257
6627
|
operations,
|
|
5258
6628
|
localState = {},
|
|
5259
6629
|
now = Date.now(),
|
|
5260
|
-
requireLifecycleGeneration = true
|
|
6630
|
+
requireLifecycleGeneration = true,
|
|
6631
|
+
plannerMode = RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT
|
|
5261
6632
|
} = {}) => {
|
|
6633
|
+
const selectedPlannerMode = plannerMode === RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
|
|
6634
|
+
? RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
|
|
6635
|
+
: RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
|
|
6636
|
+
const allowContext = selectedPlannerMode === RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
|
|
5262
6637
|
const previousDocuments = rtlCloneDocuments(documents);
|
|
5263
6638
|
const nextDocuments = rtlCloneDocuments(documents);
|
|
5264
6639
|
const preservedExpandedGroups = previousDocuments.root &&
|
|
5265
6640
|
Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
|
|
5266
6641
|
? clone(previousDocuments.root.expandedGroups)
|
|
5267
6642
|
: undefined;
|
|
5268
|
-
|
|
6643
|
+
// The deterministic planner deliberately does not inspect mutable local
|
|
6644
|
+
// context. Keep the old context-assisted behaviour behind an explicit
|
|
6645
|
+
// legacy mode so rolling clients can continue to use it while gateways
|
|
6646
|
+
// migrate to the stricter input contract.
|
|
6647
|
+
const normalizedLocalState = allowContext ? normalizeState(localState || {}) : {};
|
|
5269
6648
|
const operationResults = [];
|
|
5270
6649
|
let changed = false;
|
|
5271
6650
|
if (!nextDocuments.root) {
|
|
5272
|
-
return {
|
|
6651
|
+
return {
|
|
6652
|
+
requiresMigration: true,
|
|
6653
|
+
plannerMode: selectedPlannerMode,
|
|
6654
|
+
previousDocuments,
|
|
6655
|
+
nextDocuments,
|
|
6656
|
+
operationResults
|
|
6657
|
+
};
|
|
5273
6658
|
}
|
|
5274
6659
|
|
|
5275
6660
|
for (const operation of toArray(operations).filter(Boolean)) {
|
|
6661
|
+
const operationId = operation?.id;
|
|
6662
|
+
if (!rtlIsSafeDocumentId(operationId) || operationId.length > 200) {
|
|
6663
|
+
operationResults.push({
|
|
6664
|
+
id: operationId ?? null,
|
|
6665
|
+
type: operation?.type ?? null,
|
|
6666
|
+
applied: false,
|
|
6667
|
+
status: 'rejected',
|
|
6668
|
+
reason: 'invalid_document_id'
|
|
6669
|
+
});
|
|
6670
|
+
continue;
|
|
6671
|
+
}
|
|
5276
6672
|
if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
5277
6673
|
operationResults.push({
|
|
5278
6674
|
id: operation.id || null,
|
|
@@ -5287,21 +6683,33 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5287
6683
|
const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
|
|
5288
6684
|
const recordId = rtlOperationRecordId(operation);
|
|
5289
6685
|
const folderId = rtlOperationFolderId(operation);
|
|
6686
|
+
const trashEntryId = rtlOperationTrashEntryId(operation);
|
|
5290
6687
|
let applied = null;
|
|
5291
6688
|
let reason = null;
|
|
5292
6689
|
let lifecycleTombstoneId = null;
|
|
5293
6690
|
|
|
5294
|
-
|
|
6691
|
+
// Never use prototype-inherited values as document snapshots. Apart
|
|
6692
|
+
// from being incorrect for IDs such as "constructor", doing so would
|
|
6693
|
+
// allow user-controlled Firestore IDs to reach unsafe map writes.
|
|
6694
|
+
if ((recordId && !rtlIsSafeDocumentId(recordId)) ||
|
|
6695
|
+
(folderId && !rtlIsSafeDocumentId(folderId)) ||
|
|
6696
|
+
(trashEntryId && !rtlIsSafeDocumentId(trashEntryId))) {
|
|
6697
|
+
reason = 'invalid_document_id';
|
|
6698
|
+
}
|
|
6699
|
+
|
|
6700
|
+
switch (reason ? null : operation.type) {
|
|
5295
6701
|
case OPERATION_TYPES.RECORD_CREATE: {
|
|
5296
6702
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
5297
|
-
if (nextDocuments.records
|
|
6703
|
+
if (rtlOwn(nextDocuments.records, recordId)) { reason = 'already_exists'; break; }
|
|
5298
6704
|
const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
|
|
5299
6705
|
applied = rtlApplyOperationToPartialDocuments({
|
|
5300
6706
|
...nextDocuments,
|
|
5301
6707
|
operation: {...operation, payload: {...payload, folderId: targetFolderId}},
|
|
5302
6708
|
now: operationNow
|
|
5303
6709
|
});
|
|
5304
|
-
const recordDocument = applied.documents.records
|
|
6710
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6711
|
+
? applied.documents.records[recordId]
|
|
6712
|
+
: null;
|
|
5305
6713
|
if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
|
|
5306
6714
|
const candidateRoot = clone(applied.documents.root);
|
|
5307
6715
|
const candidateFolders = clone(nextDocuments.folders);
|
|
@@ -5310,7 +6718,8 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5310
6718
|
folders: candidateFolders,
|
|
5311
6719
|
localState: normalizedLocalState,
|
|
5312
6720
|
folderId: targetFolderId,
|
|
5313
|
-
now: operationNow
|
|
6721
|
+
now: operationNow,
|
|
6722
|
+
allowContext
|
|
5314
6723
|
});
|
|
5315
6724
|
if (!targetFolder) {
|
|
5316
6725
|
reason = 'folder_not_found';
|
|
@@ -5324,13 +6733,17 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5324
6733
|
break;
|
|
5325
6734
|
}
|
|
5326
6735
|
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
5327
|
-
const existing = nextDocuments.records
|
|
6736
|
+
const existing = rtlOwn(nextDocuments.records, recordId)
|
|
6737
|
+
? nextDocuments.records[recordId]
|
|
6738
|
+
: null;
|
|
5328
6739
|
if (!recordId || !existing) {
|
|
5329
6740
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
5330
6741
|
break;
|
|
5331
6742
|
}
|
|
5332
6743
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5333
|
-
const recordDocument = applied.documents.records
|
|
6744
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6745
|
+
? applied.documents.records[recordId]
|
|
6746
|
+
: null;
|
|
5334
6747
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
5335
6748
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5336
6749
|
nextDocuments.records[recordId] = recordDocument;
|
|
@@ -5338,19 +6751,32 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5338
6751
|
break;
|
|
5339
6752
|
}
|
|
5340
6753
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
5341
|
-
const existing = nextDocuments.records
|
|
6754
|
+
const existing = rtlOwn(nextDocuments.records, recordId)
|
|
6755
|
+
? nextDocuments.records[recordId]
|
|
6756
|
+
: null;
|
|
5342
6757
|
if (!recordId || !existing) {
|
|
5343
6758
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
5344
6759
|
break;
|
|
5345
6760
|
}
|
|
6761
|
+
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6762
|
+
if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
|
|
6763
|
+
reason = 'missing_target_folder_id';
|
|
6764
|
+
break;
|
|
6765
|
+
}
|
|
6766
|
+
if (!rtlIsSafeDocumentId(rawTargetFolderId)) {
|
|
6767
|
+
reason = 'invalid_document_id';
|
|
6768
|
+
break;
|
|
6769
|
+
}
|
|
5346
6770
|
const sourceFolderId = safeFolderId(existing.folderId);
|
|
5347
|
-
const targetFolderId =
|
|
6771
|
+
const targetFolderId = rawTargetFolderId;
|
|
5348
6772
|
applied = rtlApplyOperationToPartialDocuments({
|
|
5349
6773
|
...nextDocuments,
|
|
5350
6774
|
operation: {...operation, payload: {...payload, targetFolderId}},
|
|
5351
6775
|
now: operationNow
|
|
5352
6776
|
});
|
|
5353
|
-
const recordDocument = applied.documents.records
|
|
6777
|
+
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6778
|
+
? applied.documents.records[recordId]
|
|
6779
|
+
: null;
|
|
5354
6780
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
5355
6781
|
const candidateRoot = clone(applied.documents.root);
|
|
5356
6782
|
const candidateFolders = clone(nextDocuments.folders);
|
|
@@ -5359,10 +6785,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5359
6785
|
folders: candidateFolders,
|
|
5360
6786
|
localState: normalizedLocalState,
|
|
5361
6787
|
folderId: targetFolderId,
|
|
5362
|
-
now: operationNow
|
|
6788
|
+
now: operationNow,
|
|
6789
|
+
allowContext
|
|
5363
6790
|
});
|
|
5364
6791
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
5365
|
-
if (candidateFolders
|
|
6792
|
+
if (rtlOwn(candidateFolders, sourceFolderId)) {
|
|
5366
6793
|
candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
5367
6794
|
candidateFolders[sourceFolderId].recordOrder
|
|
5368
6795
|
).filter((id) => id !== recordId);
|
|
@@ -5379,8 +6806,14 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5379
6806
|
}
|
|
5380
6807
|
case OPERATION_TYPES.RECORD_REORDER: {
|
|
5381
6808
|
const targetFolderId = safeFolderId(payload.folderId);
|
|
5382
|
-
const targetFolder = nextDocuments.folders
|
|
6809
|
+
const targetFolder = rtlOwn(nextDocuments.folders, targetFolderId)
|
|
6810
|
+
? nextDocuments.folders[targetFolderId]
|
|
6811
|
+
: null;
|
|
5383
6812
|
const requested = rtlPayloadOrder(operation, ['recordIds']);
|
|
6813
|
+
if (rtlHasUnsafeOrderId(requested)) {
|
|
6814
|
+
reason = 'invalid_document_id';
|
|
6815
|
+
break;
|
|
6816
|
+
}
|
|
5384
6817
|
if (!targetFolder || requested.length === 0) {
|
|
5385
6818
|
reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
|
|
5386
6819
|
break;
|
|
@@ -5394,14 +6827,50 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5394
6827
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
5395
6828
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
5396
6829
|
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
|
|
5397
|
-
const
|
|
6830
|
+
const existingRecord = rtlOwn(nextDocuments.records, recordId)
|
|
6831
|
+
? nextDocuments.records[recordId]
|
|
6832
|
+
: null;
|
|
6833
|
+
const operationTrashEntryId = trashEntryId || `record:${recordId}`;
|
|
6834
|
+
const existingTrashEntry = rtlOwn(nextDocuments.trash, operationTrashEntryId)
|
|
6835
|
+
? nextDocuments.trash[operationTrashEntryId]
|
|
6836
|
+
: null;
|
|
6837
|
+
const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
|
|
6838
|
+
if (!existingRecord) {
|
|
6839
|
+
const hasTrash = Boolean(existingTrashEntry);
|
|
6840
|
+
const hasTombstone = Boolean(existingTombstone);
|
|
6841
|
+
const trashGeneration = Number(existingTrashEntry?.lifecycleGeneration || 0);
|
|
6842
|
+
const tombstoneGeneration = Number(existingTombstone?.lifecycleGeneration || 0);
|
|
6843
|
+
const validTrashGeneration = Number.isSafeInteger(trashGeneration) && trashGeneration > 0;
|
|
6844
|
+
const validTombstoneGeneration = Number.isSafeInteger(tombstoneGeneration) &&
|
|
6845
|
+
tombstoneGeneration > 0;
|
|
6846
|
+
if (hasTrash && hasTombstone) {
|
|
6847
|
+
reason = validTrashGeneration && validTombstoneGeneration &&
|
|
6848
|
+
trashGeneration === tombstoneGeneration
|
|
6849
|
+
? 'already_deleted'
|
|
6850
|
+
: 'lifecycle_conflict';
|
|
6851
|
+
} else if (hasTombstone) {
|
|
6852
|
+
reason = validTombstoneGeneration ? 'already_deleted' : 'lifecycle_conflict';
|
|
6853
|
+
} else if (hasTrash) {
|
|
6854
|
+
reason = 'lifecycle_conflict';
|
|
6855
|
+
} else {
|
|
6856
|
+
reason = 'record_not_found';
|
|
6857
|
+
}
|
|
6858
|
+
break;
|
|
6859
|
+
}
|
|
6860
|
+
if (existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
6861
|
+
Number(existingRecord.lifecycleGeneration || 0)) {
|
|
6862
|
+
reason = 'already_deleted';
|
|
6863
|
+
break;
|
|
6864
|
+
}
|
|
6865
|
+
const sourceFolderId = safeFolderId(existingRecord.folderId);
|
|
5398
6866
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5399
6867
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5400
|
-
const
|
|
5401
|
-
|
|
5402
|
-
|
|
6868
|
+
const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
|
|
6869
|
+
? applied.documents.trash[operationTrashEntryId]
|
|
6870
|
+
: null;
|
|
6871
|
+
if (trashDocument) nextDocuments.trash[operationTrashEntryId] = trashDocument;
|
|
5403
6872
|
delete nextDocuments.records[recordId];
|
|
5404
|
-
if (nextDocuments.folders
|
|
6873
|
+
if (rtlOwn(nextDocuments.folders, sourceFolderId)) {
|
|
5405
6874
|
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
5406
6875
|
nextDocuments.folders[sourceFolderId].recordOrder
|
|
5407
6876
|
).filter((id) => id !== recordId);
|
|
@@ -5410,8 +6879,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5410
6879
|
break;
|
|
5411
6880
|
}
|
|
5412
6881
|
case OPERATION_TYPES.RECORD_RESTORE: {
|
|
5413
|
-
const
|
|
5414
|
-
const trashEntry = nextDocuments.trash
|
|
6882
|
+
const restoreTrashEntryId = trashEntryId;
|
|
6883
|
+
const trashEntry = rtlOwn(nextDocuments.trash, restoreTrashEntryId)
|
|
6884
|
+
? nextDocuments.trash[restoreTrashEntryId]
|
|
6885
|
+
: null;
|
|
5415
6886
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
5416
6887
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
5417
6888
|
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
@@ -5427,11 +6898,23 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5427
6898
|
break;
|
|
5428
6899
|
}
|
|
5429
6900
|
const restoredRecordId = normalizeId(trashEntry.entityId);
|
|
5430
|
-
if (!restoredRecordId || nextDocuments.records
|
|
6901
|
+
if (!restoredRecordId || rtlOwn(nextDocuments.records, restoredRecordId)) {
|
|
5431
6902
|
reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
|
|
5432
6903
|
break;
|
|
5433
6904
|
}
|
|
5434
6905
|
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
|
|
6906
|
+
if (requireLifecycleGeneration !== false) {
|
|
6907
|
+
const lifecycleTombstone = rtlGetLifecycleTombstone(
|
|
6908
|
+
nextDocuments,
|
|
6909
|
+
'record',
|
|
6910
|
+
restoredRecordId
|
|
6911
|
+
);
|
|
6912
|
+
if (!lifecycleTombstone ||
|
|
6913
|
+
Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
|
|
6914
|
+
reason = 'lifecycle_conflict';
|
|
6915
|
+
break;
|
|
6916
|
+
}
|
|
6917
|
+
}
|
|
5435
6918
|
// Explicit legacy compatibility is planner-local. It does not change the
|
|
5436
6919
|
// original operation, its wire bytes, or the strict durable reducer contract.
|
|
5437
6920
|
const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
|
|
@@ -5442,11 +6925,16 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5442
6925
|
operation: reducerOperation,
|
|
5443
6926
|
now: operationNow
|
|
5444
6927
|
});
|
|
5445
|
-
const restoredRecord = applied.documents.records
|
|
6928
|
+
const restoredRecord = rtlOwn(applied.documents.records, restoredRecordId)
|
|
6929
|
+
? applied.documents.records[restoredRecordId]
|
|
6930
|
+
: null;
|
|
5446
6931
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
5447
6932
|
const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
|
|
5448
|
-
const targetFolder = nextDocuments.folders
|
|
5449
|
-
nextDocuments.folders[
|
|
6933
|
+
const targetFolder = (rtlOwn(nextDocuments.folders, targetFolderId)
|
|
6934
|
+
? nextDocuments.folders[targetFolderId]
|
|
6935
|
+
: null) || (rtlOwn(nextDocuments.folders, DEFAULT_FOLDER_ID)
|
|
6936
|
+
? nextDocuments.folders[DEFAULT_FOLDER_ID]
|
|
6937
|
+
: null);
|
|
5450
6938
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
5451
6939
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5452
6940
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
@@ -5462,8 +6950,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5462
6950
|
break;
|
|
5463
6951
|
}
|
|
5464
6952
|
case OPERATION_TYPES.TRASH_PURGE: {
|
|
5465
|
-
const
|
|
5466
|
-
const trashEntry = nextDocuments.trash
|
|
6953
|
+
const purgeTrashEntryId = trashEntryId;
|
|
6954
|
+
const trashEntry = rtlOwn(nextDocuments.trash, purgeTrashEntryId)
|
|
6955
|
+
? nextDocuments.trash[purgeTrashEntryId]
|
|
6956
|
+
: null;
|
|
5467
6957
|
if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
|
|
5468
6958
|
const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
|
|
5469
6959
|
if (requireLifecycleGeneration !== false && expectedGeneration === null) {
|
|
@@ -5474,20 +6964,24 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5474
6964
|
reason = 'lifecycle_conflict';
|
|
5475
6965
|
break;
|
|
5476
6966
|
}
|
|
5477
|
-
delete nextDocuments.trash[
|
|
6967
|
+
delete nextDocuments.trash[purgeTrashEntryId];
|
|
5478
6968
|
changed = true;
|
|
5479
6969
|
break;
|
|
5480
6970
|
}
|
|
5481
6971
|
case OPERATION_TYPES.FOLDER_CREATE:
|
|
5482
6972
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
5483
6973
|
if (!folderId) { reason = 'missing_folder_id'; break; }
|
|
5484
|
-
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders
|
|
6974
|
+
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !rtlOwn(nextDocuments.folders, folderId)) {
|
|
5485
6975
|
reason = 'folder_not_found';
|
|
5486
6976
|
break;
|
|
5487
6977
|
}
|
|
5488
|
-
const previousOrder = nextDocuments.folders
|
|
6978
|
+
const previousOrder = rtlOwn(nextDocuments.folders, folderId)
|
|
6979
|
+
? nextDocuments.folders[folderId]?.recordOrder || []
|
|
6980
|
+
: [];
|
|
5489
6981
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5490
|
-
const folderDocument = applied.documents.folders
|
|
6982
|
+
const folderDocument = rtlOwn(applied.documents.folders, folderId)
|
|
6983
|
+
? applied.documents.folders[folderId]
|
|
6984
|
+
: null;
|
|
5491
6985
|
if (!folderDocument) { reason = 'folder_not_found'; break; }
|
|
5492
6986
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
5493
6987
|
nextDocuments.folders[folderId] = {
|
|
@@ -5507,27 +7001,37 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5507
7001
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
5508
7002
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
5509
7003
|
case OPERATION_TYPES.SETTINGS_UPDATE: {
|
|
7004
|
+
if (operation.type === OPERATION_TYPES.FOLDER_REORDER &&
|
|
7005
|
+
rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['folderOrder', 'order']))) {
|
|
7006
|
+
reason = 'invalid_document_id';
|
|
7007
|
+
break;
|
|
7008
|
+
}
|
|
7009
|
+
if (operation.type === OPERATION_TYPES.GROUP_REORDER &&
|
|
7010
|
+
rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['groupOrder', 'order']), {opaqueGroup: true})) {
|
|
7011
|
+
reason = 'invalid_document_id';
|
|
7012
|
+
break;
|
|
7013
|
+
}
|
|
5510
7014
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
5511
7015
|
const appliedRoot = applied.documents.root;
|
|
5512
7016
|
if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
|
|
5513
7017
|
appliedRoot.folderOrder = rtlMergeOrder(
|
|
5514
7018
|
rtlPayloadOrder(operation, ['folderOrder', 'order']),
|
|
5515
7019
|
nextDocuments.root.folderOrder,
|
|
5516
|
-
normalizedLocalState.folderOrder
|
|
7020
|
+
...(allowContext ? [normalizedLocalState.folderOrder] : [])
|
|
5517
7021
|
);
|
|
5518
7022
|
} else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
|
|
5519
|
-
appliedRoot.groupOrder =
|
|
7023
|
+
appliedRoot.groupOrder = rtlMergeOpaqueOrder(
|
|
5520
7024
|
rtlPayloadOrder(operation, ['groupOrder', 'order']),
|
|
5521
7025
|
nextDocuments.root.groupOrder,
|
|
5522
|
-
normalizedLocalState.groupOrder
|
|
5523
|
-
)
|
|
7026
|
+
...(allowContext ? [normalizedLocalState.groupOrder] : [])
|
|
7027
|
+
);
|
|
5524
7028
|
}
|
|
5525
7029
|
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
5526
7030
|
changed = true;
|
|
5527
7031
|
break;
|
|
5528
7032
|
}
|
|
5529
7033
|
default:
|
|
5530
|
-
reason = 'unsupported_operation';
|
|
7034
|
+
if (!reason) reason = 'unsupported_operation';
|
|
5531
7035
|
}
|
|
5532
7036
|
if (applied && !reason && lifecycleTombstoneId) {
|
|
5533
7037
|
const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
|
|
@@ -5541,6 +7045,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5541
7045
|
id: operation.id || null,
|
|
5542
7046
|
type: operation.type || null,
|
|
5543
7047
|
applied: !reason,
|
|
7048
|
+
...(reason === 'already_deleted' ? {status: 'noop'} : {}),
|
|
5544
7049
|
reason
|
|
5545
7050
|
});
|
|
5546
7051
|
}
|
|
@@ -5562,6 +7067,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5562
7067
|
});
|
|
5563
7068
|
return {
|
|
5564
7069
|
requiresMigration: false,
|
|
7070
|
+
plannerMode: selectedPlannerMode,
|
|
5565
7071
|
previousDocuments,
|
|
5566
7072
|
nextDocuments,
|
|
5567
7073
|
changes,
|
|
@@ -5569,6 +7075,19 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
5569
7075
|
};
|
|
5570
7076
|
};
|
|
5571
7077
|
|
|
7078
|
+
// Dedicated API boundary for gateways: deterministic planning cannot inspect
|
|
7079
|
+
// mutable host/local context and always uses the immutable-ID contract.
|
|
7080
|
+
export const planRecordTimeLabelDeterministicOperationChanges = (options = {}) => (
|
|
7081
|
+
planFirestoreV2OperationChanges({
|
|
7082
|
+
...options,
|
|
7083
|
+
plannerMode: RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC,
|
|
7084
|
+
localState: {}
|
|
7085
|
+
})
|
|
7086
|
+
);
|
|
7087
|
+
// Canonical name used by the Firestore gateway capability probe.
|
|
7088
|
+
export const planFirestoreV2DeterministicOperationChanges =
|
|
7089
|
+
planRecordTimeLabelDeterministicOperationChanges;
|
|
7090
|
+
|
|
5572
7091
|
/**
|
|
5573
7092
|
* Counts all billable writes before a transaction reserves quota.
|
|
5574
7093
|
*/
|
|
@@ -5759,10 +7278,15 @@ export default {
|
|
|
5759
7278
|
isValidChannelName,
|
|
5760
7279
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5761
7280
|
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
7281
|
+
RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
|
|
5762
7282
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
7283
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
|
|
5763
7284
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5764
7285
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
7286
|
+
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
7287
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
5765
7288
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
7289
|
+
isRecordTimeLabelOperationConflictFailure,
|
|
5766
7290
|
normalizeRecordTimeLabelCloudFailure,
|
|
5767
7291
|
toRecordTimeLabelCloudFailureError,
|
|
5768
7292
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
@@ -5774,6 +7298,11 @@ export default {
|
|
|
5774
7298
|
RTL_MAX_REQUEST_BYTES,
|
|
5775
7299
|
RTL_MAX_TARGET_WRITES,
|
|
5776
7300
|
RTL_TRASH_RETENTION_MS,
|
|
7301
|
+
RECORD_TIMELABEL_PLANNER_MODES,
|
|
7302
|
+
RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
|
|
7303
|
+
isRecordTimeLabelSafeDocumentId,
|
|
7304
|
+
normalizeRecordTimeLabelImmutableId,
|
|
7305
|
+
normalizeRecordTimeLabelPlannerId,
|
|
5777
7306
|
OPERATION_TYPES,
|
|
5778
7307
|
RECORD_TIMELABEL_SYNC_MODES,
|
|
5779
7308
|
RECORD_TIMELABEL_CLOUD_SCHEMAS,
|
|
@@ -5812,6 +7341,8 @@ export default {
|
|
|
5812
7341
|
extendFirestoreV2OperationReadPlanWithRecords,
|
|
5813
7342
|
extendFirestoreV2OperationReadPlanWithTrash,
|
|
5814
7343
|
planFirestoreV2OperationChanges,
|
|
7344
|
+
planRecordTimeLabelDeterministicOperationChanges,
|
|
7345
|
+
planFirestoreV2DeterministicOperationChanges,
|
|
5815
7346
|
estimateFirestoreV2WriteUnits,
|
|
5816
7347
|
normalizeRecordTimeLabelOperationResults,
|
|
5817
7348
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
@@ -5822,6 +7353,7 @@ export default {
|
|
|
5822
7353
|
flushPendingOperations,
|
|
5823
7354
|
mergeRemoteStateIntoLocal,
|
|
5824
7355
|
applyOperation,
|
|
7356
|
+
applyExplicitRecordUpdate,
|
|
5825
7357
|
applyRecordTimeLabelOperation,
|
|
5826
7358
|
createOperation,
|
|
5827
7359
|
createRecordTimeLabelSyncEngine,
|