@recordtimelabel/core 0.6.11 → 0.6.13
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 +2 -2
- package/package.json +1 -1
- package/src/changefeed.js +44 -19
- package/src/index.js +329 -48
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
|
|
|
17
17
|
"@recordtimelabel/core": "file:../recordtimelabel-core"
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The release target is `0.6.
|
|
20
|
+
For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The release target is `0.6.13`; verify that its registry tarball and lockfile integrity are available before updating consumers:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.6.
|
|
23
|
+
"@recordtimelabel/core": "0.6.13"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
|
package/package.json
CHANGED
package/src/changefeed.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {normalizeRecordTimeLabelImmutableId} from './protocol.js';
|
|
2
|
+
|
|
1
3
|
export const FIRESTORE_V2_BOOTSTRAP_REASONS = Object.freeze({
|
|
2
4
|
REVISION_GAP: 'revision-gap',
|
|
3
5
|
REVISION_DUPLICATE: 'revision-duplicate',
|
|
@@ -8,16 +10,27 @@ export const FIRESTORE_V2_BOOTSTRAP_REASONS = Object.freeze({
|
|
|
8
10
|
INVALID_CHANGE_CONFLICT: 'invalid-change-conflict'
|
|
9
11
|
});
|
|
10
12
|
|
|
11
|
-
const toId = (value) =>
|
|
12
|
-
const toIdList = (value) =>
|
|
13
|
+
const toId = (value) => normalizeRecordTimeLabelImmutableId(value);
|
|
14
|
+
const toIdList = (value) => {
|
|
15
|
+
if (!Array.isArray(value)) return {ids: [], invalid: false};
|
|
16
|
+
const ids = [];
|
|
17
|
+
let invalid = false;
|
|
18
|
+
value.forEach((entry) => {
|
|
19
|
+
const id = toId(entry);
|
|
20
|
+
if (!id) invalid = true;
|
|
21
|
+
else ids.push(id);
|
|
22
|
+
});
|
|
23
|
+
return {ids, invalid};
|
|
24
|
+
};
|
|
13
25
|
|
|
14
26
|
const decodeLifecycleTombstoneId = (value) => {
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) return '';
|
|
28
|
+
const id = value;
|
|
17
29
|
try {
|
|
18
|
-
|
|
30
|
+
const decoded = decodeURIComponent(id);
|
|
31
|
+
return normalizeRecordTimeLabelImmutableId(decoded) || '';
|
|
19
32
|
} catch {
|
|
20
|
-
return
|
|
33
|
+
return '';
|
|
21
34
|
}
|
|
22
35
|
};
|
|
23
36
|
|
|
@@ -29,7 +42,7 @@ export const lifecycleTombstoneDocumentKey = (value) => {
|
|
|
29
42
|
export const lifecycleTombstoneKeyAliases = (value) => {
|
|
30
43
|
const logicalId = decodeLifecycleTombstoneId(value);
|
|
31
44
|
if (!logicalId) return [];
|
|
32
|
-
return [...new Set([
|
|
45
|
+
return [...new Set([value, logicalId, encodeURIComponent(logicalId)].filter(Boolean))];
|
|
33
46
|
};
|
|
34
47
|
|
|
35
48
|
const cloneDocuments = (documents = {}) => ({
|
|
@@ -164,7 +177,7 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
|
|
|
164
177
|
['trash', change?.changedTrashEntryIds, 'changed'],
|
|
165
178
|
['trash', change?.deletedTrashEntryIds, 'deleted']
|
|
166
179
|
].forEach(([kind, ids, mutation]) => {
|
|
167
|
-
toIdList(ids).forEach((id) => finalMutations[kind].set(id, {index, mutation}));
|
|
180
|
+
toIdList(ids).ids.forEach((id) => finalMutations[kind].set(id, {index, mutation}));
|
|
168
181
|
});
|
|
169
182
|
});
|
|
170
183
|
const isShadowedByLaterDelete = (kind, id, index) => {
|
|
@@ -188,22 +201,34 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
|
|
|
188
201
|
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
|
|
189
202
|
}
|
|
190
203
|
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
204
|
+
const changedRecordsResult = toIdList(change.changedRecordIds);
|
|
205
|
+
const deletedRecordsResult = toIdList(change.deletedRecordIds);
|
|
206
|
+
const changedFoldersResult = toIdList(change.changedFolderIds);
|
|
207
|
+
const deletedFoldersResult = toIdList(change.deletedFolderIds);
|
|
208
|
+
const changedTrashResult = toIdList(change.changedTrashEntryIds);
|
|
209
|
+
const deletedTrashResult = toIdList(change.deletedTrashEntryIds);
|
|
210
|
+
const changedRecords = changedRecordsResult.ids;
|
|
211
|
+
const deletedRecords = deletedRecordsResult.ids;
|
|
212
|
+
const changedFolders = changedFoldersResult.ids;
|
|
213
|
+
const deletedFolders = deletedFoldersResult.ids;
|
|
214
|
+
const changedTrash = changedTrashResult.ids;
|
|
215
|
+
const deletedTrash = deletedTrashResult.ids;
|
|
197
216
|
const lifecycleUpserts = Array.isArray(change.lifecycleTombstoneUpserts)
|
|
198
217
|
? change.lifecycleTombstoneUpserts
|
|
199
218
|
: [];
|
|
200
|
-
const
|
|
219
|
+
const lifecycleDeletesResult = toIdList(change.deletedLifecycleTombstoneIds);
|
|
220
|
+
const lifecycleDeletes = lifecycleDeletesResult.ids;
|
|
221
|
+
const lifecycleUpsertKeys = lifecycleUpserts.map((entry) => lifecycleTombstoneDocumentKey(entry?.id));
|
|
222
|
+
const hasInvalidId = [changedRecordsResult, deletedRecordsResult, changedFoldersResult,
|
|
223
|
+
deletedFoldersResult, changedTrashResult, deletedTrashResult, lifecycleDeletesResult]
|
|
224
|
+
.some((result) => result.invalid) || lifecycleUpsertKeys.some((key) => !key);
|
|
225
|
+
if (hasInvalidId) {
|
|
226
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.INVALID_CHANGE_CONFLICT, inputCache);
|
|
227
|
+
}
|
|
201
228
|
const lifecycleUpsertIds = lifecycleUpserts
|
|
202
|
-
.map((entry) => lifecycleTombstoneDocumentKey(entry?.id))
|
|
203
|
-
.filter(Boolean);
|
|
229
|
+
.map((entry) => lifecycleTombstoneDocumentKey(entry?.id));
|
|
204
230
|
const lifecycleDeleteIds = lifecycleDeletes
|
|
205
|
-
.map((id) => lifecycleTombstoneDocumentKey(id))
|
|
206
|
-
.filter(Boolean);
|
|
231
|
+
.map((id) => lifecycleTombstoneDocumentKey(id));
|
|
207
232
|
|
|
208
233
|
if (
|
|
209
234
|
hasConflict(changedRecords, deletedRecords) ||
|
package/src/index.js
CHANGED
|
@@ -147,7 +147,7 @@ export {
|
|
|
147
147
|
selectBestMatchingTwitchVod
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
150
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.13';
|
|
151
151
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
152
152
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
153
153
|
'fifo-retry-fence',
|
|
@@ -604,6 +604,13 @@ export const applyExplicitRecordUpdate = (record = {}, patch = {}, operationTime
|
|
|
604
604
|
return next;
|
|
605
605
|
};
|
|
606
606
|
|
|
607
|
+
// lifecycleGeneration is a Core-owned fence. Payload snapshots are hints
|
|
608
|
+
// only; they must never be able to lower a live entity or tombstone fence.
|
|
609
|
+
const rtlMaxLifecycleGeneration = (...values) => values.reduce((max, value) => {
|
|
610
|
+
const generation = Number(value?.lifecycleGeneration ?? value);
|
|
611
|
+
return Number.isSafeInteger(generation) && generation > max ? generation : max;
|
|
612
|
+
}, 0);
|
|
613
|
+
|
|
607
614
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
608
615
|
const source = records && typeof records === 'object' ? records : {};
|
|
609
616
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
@@ -1021,6 +1028,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1021
1028
|
id: recordId,
|
|
1022
1029
|
updatedAt: record.updatedAt || operationTime
|
|
1023
1030
|
};
|
|
1031
|
+
cleanedRecord.lifecycleGeneration = rtlMaxLifecycleGeneration(
|
|
1032
|
+
record, nextState.deletedRecordTombstones[recordId]
|
|
1033
|
+
);
|
|
1024
1034
|
delete cleanedRecord.folderId;
|
|
1025
1035
|
|
|
1026
1036
|
nextState.records = removeRecordById(nextState.records, recordId);
|
|
@@ -1036,12 +1046,15 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1036
1046
|
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1037
1047
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
1038
1048
|
if (!entry) return normalized;
|
|
1039
|
-
const patch = payload.patch || payload.record || {};
|
|
1049
|
+
const patch = {...(payload.patch || payload.record || {})};
|
|
1050
|
+
delete patch.lifecycleGeneration;
|
|
1040
1051
|
if (Object.keys(patch).some((key) => RTL_RESERVED_DOCUMENT_KEYS.has(key))) {
|
|
1041
1052
|
return normalized;
|
|
1042
1053
|
}
|
|
1043
1054
|
const updatedRecord = applyExplicitRecordUpdate(entry.record, patch, operationTime);
|
|
1044
1055
|
updatedRecord.id = recordId;
|
|
1056
|
+
updatedRecord.lifecycleGeneration = rtlMaxLifecycleGeneration(entry.record,
|
|
1057
|
+
nextState.deletedRecordTombstones[recordId]);
|
|
1045
1058
|
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
1046
1059
|
break;
|
|
1047
1060
|
}
|
|
@@ -1060,6 +1073,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1060
1073
|
id: recordId,
|
|
1061
1074
|
updatedAt: payload.updatedAt || operationTime
|
|
1062
1075
|
};
|
|
1076
|
+
movedRecord.lifecycleGeneration = rtlMaxLifecycleGeneration(entry.record,
|
|
1077
|
+
nextState.deletedRecordTombstones[recordId]);
|
|
1063
1078
|
nextState.records = removeRecordById(nextState.records, recordId);
|
|
1064
1079
|
nextState.records[targetFolderId] = [
|
|
1065
1080
|
...toArray(nextState.records[targetFolderId]),
|
|
@@ -1133,13 +1148,13 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1133
1148
|
return normalized;
|
|
1134
1149
|
}
|
|
1135
1150
|
if (recordSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1136
|
-
|
|
1151
|
+
rtlMaxLifecycleGeneration(recordSnapshot, existingEntry?.record)) {
|
|
1137
1152
|
return normalized;
|
|
1138
1153
|
}
|
|
1139
1154
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1140
|
-
const previousGeneration =
|
|
1141
|
-
|
|
1142
|
-
|
|
1155
|
+
const previousGeneration = rtlMaxLifecycleGeneration(
|
|
1156
|
+
existingEntry?.record, recordSnapshot,
|
|
1157
|
+
nextState.deletedRecordTombstones[recordId]
|
|
1143
1158
|
);
|
|
1144
1159
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1145
1160
|
if (recordSnapshot) {
|
|
@@ -1256,10 +1271,14 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1256
1271
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
1257
1272
|
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
1258
1273
|
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1259
|
-
const patch = payload.patch || payload.folder || {};
|
|
1274
|
+
const patch = {...(payload.patch || payload.folder || {})};
|
|
1275
|
+
delete patch.lifecycleGeneration;
|
|
1260
1276
|
nextState.folders = nextState.folders.map((folder) => (
|
|
1261
1277
|
folder.id === folderId
|
|
1262
|
-
? { ...folder, ...patch, id: folderId,
|
|
1278
|
+
? { ...folder, ...patch, id: folderId,
|
|
1279
|
+
lifecycleGeneration: rtlMaxLifecycleGeneration(folder,
|
|
1280
|
+
nextState.deletedFolderTombstones[folderId]),
|
|
1281
|
+
updatedAt: patch.updatedAt || operationTime }
|
|
1263
1282
|
: folder
|
|
1264
1283
|
));
|
|
1265
1284
|
break;
|
|
@@ -1273,6 +1292,14 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1273
1292
|
? {...payload.folder, id: folderId}
|
|
1274
1293
|
: nextState.folders.find((folder) => folder.id === folderId);
|
|
1275
1294
|
const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
|
|
1295
|
+
const liveRecordsById = new Map(toArray(nextState.records[folderId])
|
|
1296
|
+
.map((record) => [getRecordId(record), record]));
|
|
1297
|
+
folderRecords.forEach((record) => {
|
|
1298
|
+
const recordId = getRecordId(record);
|
|
1299
|
+
record.lifecycleGeneration = rtlMaxLifecycleGeneration(
|
|
1300
|
+
record, liveRecordsById.get(recordId), nextState.deletedRecordTombstones[recordId]
|
|
1301
|
+
);
|
|
1302
|
+
});
|
|
1276
1303
|
const existingTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
|
|
1277
1304
|
? nextState.deletedFolderTombstones[folderId]
|
|
1278
1305
|
: null;
|
|
@@ -1282,13 +1309,14 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1282
1309
|
return normalized;
|
|
1283
1310
|
}
|
|
1284
1311
|
if (folderSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1285
|
-
|
|
1312
|
+
rtlMaxLifecycleGeneration(folderSnapshot,
|
|
1313
|
+
nextState.folders.find((folder) => folder.id === folderId))) {
|
|
1286
1314
|
return normalized;
|
|
1287
1315
|
}
|
|
1288
1316
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1289
|
-
const previousGeneration =
|
|
1290
|
-
|
|
1291
|
-
|
|
1317
|
+
const previousGeneration = rtlMaxLifecycleGeneration(
|
|
1318
|
+
nextState.folders.find((folder) => folder.id === folderId), folderSnapshot,
|
|
1319
|
+
nextState.deletedFolderTombstones[folderId]
|
|
1292
1320
|
);
|
|
1293
1321
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1294
1322
|
if (folderSnapshot) {
|
|
@@ -1372,7 +1400,16 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1372
1400
|
lifecycleGeneration,
|
|
1373
1401
|
updatedAt: operationTime
|
|
1374
1402
|
});
|
|
1375
|
-
|
|
1403
|
+
const restorableRecords = toArray(trashEntry.payload?.records).filter((record) => {
|
|
1404
|
+
const recordId = getRecordId(record);
|
|
1405
|
+
if (!recordId) return false;
|
|
1406
|
+
const tombstone = nextState.deletedRecordTombstones[recordId];
|
|
1407
|
+
// A child tombstone may have advanced after the folder delete. It is
|
|
1408
|
+
// no longer proven to belong to this restore and must be preserved.
|
|
1409
|
+
return tombstone && Number(tombstone.lifecycleGeneration) ===
|
|
1410
|
+
Number(record.lifecycleGeneration || 0);
|
|
1411
|
+
});
|
|
1412
|
+
nextState.records[folderId] = restorableRecords.map((record) => ({
|
|
1376
1413
|
...record,
|
|
1377
1414
|
lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1,
|
|
1378
1415
|
updatedAt: operationTime
|
|
@@ -1925,6 +1962,12 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
|
1925
1962
|
const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
|
|
1926
1963
|
delete cleanRecord.pendingSync;
|
|
1927
1964
|
delete cleanRecord.syncAttempts;
|
|
1965
|
+
// Keep the zero fence explicit so deterministic and incremental planners
|
|
1966
|
+
// serialize a newly-created record identically.
|
|
1967
|
+
cleanRecord.lifecycleGeneration = Number.isSafeInteger(Number(cleanRecord.lifecycleGeneration)) &&
|
|
1968
|
+
Number(cleanRecord.lifecycleGeneration) >= 0
|
|
1969
|
+
? Number(cleanRecord.lifecycleGeneration)
|
|
1970
|
+
: 0;
|
|
1928
1971
|
return {
|
|
1929
1972
|
...cleanRecord,
|
|
1930
1973
|
updatedAt: cleanRecord.updatedAt || cleanRecord.createdAt || now,
|
|
@@ -3477,6 +3520,9 @@ const rtlSanitizeDurableWorkspace = (workspace) => {
|
|
|
3477
3520
|
state: clone(workspace.remoteBaseline.state)
|
|
3478
3521
|
};
|
|
3479
3522
|
}
|
|
3523
|
+
if (rtlDurableIsObject(workspace.localState)) {
|
|
3524
|
+
sanitized.localState = clone(workspace.localState);
|
|
3525
|
+
}
|
|
3480
3526
|
if (Array.isArray(workspace.pendingOperations)) {
|
|
3481
3527
|
sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
|
|
3482
3528
|
}
|
|
@@ -3561,6 +3607,22 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
|
3561
3607
|
};
|
|
3562
3608
|
};
|
|
3563
3609
|
|
|
3610
|
+
// Full-state baseline ingress is fail-closed. Normalization is intentionally
|
|
3611
|
+
// only used after this admission check; otherwise missing collections would
|
|
3612
|
+
// be silently replaced with empty defaults and could erase committed data.
|
|
3613
|
+
const rtlIsAdmissibleRemoteBaseline = (value) => {
|
|
3614
|
+
if (!rtlDurableIsObject(value)) return false;
|
|
3615
|
+
const state = value.state ?? value.data;
|
|
3616
|
+
if (!rtlHasCompleteBaselineState(state)) return false;
|
|
3617
|
+
if (!rtlRawNonNegativeSafeInteger(value.revision)) return false;
|
|
3618
|
+
if (Object.prototype.hasOwnProperty.call(value, 'changeCursor')) {
|
|
3619
|
+
const cursor = value.changeCursor;
|
|
3620
|
+
if (cursor !== null && cursor !== undefined &&
|
|
3621
|
+
typeof cursor !== 'string' && !rtlDurableIsObject(cursor)) return false;
|
|
3622
|
+
}
|
|
3623
|
+
return true;
|
|
3624
|
+
};
|
|
3625
|
+
|
|
3564
3626
|
const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) => ({
|
|
3565
3627
|
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
3566
3628
|
ownerUid: rtlNormalizeUid(ownerUid),
|
|
@@ -3648,7 +3710,8 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3648
3710
|
source.workspaceEpoch ?? options.workspaceEpoch,
|
|
3649
3711
|
options.workspaceEpoch
|
|
3650
3712
|
);
|
|
3651
|
-
const
|
|
3713
|
+
const localOnly = source.localOnly === true;
|
|
3714
|
+
const baseline = localOnly ? null : legacy
|
|
3652
3715
|
? rtlNormalizeRemoteBaseline({
|
|
3653
3716
|
state: source.state || {},
|
|
3654
3717
|
revision: source.revision ?? source.syncMeta?.revision,
|
|
@@ -3692,7 +3755,11 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3692
3755
|
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
3693
3756
|
ownerUid,
|
|
3694
3757
|
workspaceEpoch,
|
|
3695
|
-
|
|
3758
|
+
...(localOnly
|
|
3759
|
+
? {localOnly: true, localState: normalizeRecordTimeLabelDomainState(
|
|
3760
|
+
source.localState ?? source.state ?? {}
|
|
3761
|
+
)}
|
|
3762
|
+
: {remoteBaseline: baseline}),
|
|
3696
3763
|
pendingOperations,
|
|
3697
3764
|
rejectedOperations,
|
|
3698
3765
|
syncMeta: {
|
|
@@ -3821,7 +3888,9 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3821
3888
|
}
|
|
3822
3889
|
});
|
|
3823
3890
|
let lifecycleState = operations === workspace?.pendingOperations
|
|
3824
|
-
? normalizeRecordTimeLabelDomainState(
|
|
3891
|
+
? normalizeRecordTimeLabelDomainState(
|
|
3892
|
+
workspace?.localOnly ? workspace?.localState || {} : workspace?.remoteBaseline?.state || {}
|
|
3893
|
+
)
|
|
3825
3894
|
: rtlDeriveDurableVisibleState(workspace);
|
|
3826
3895
|
if (operations !== workspace?.pendingOperations) {
|
|
3827
3896
|
toArray(workspace?.pendingOperations).forEach((operation) => {
|
|
@@ -3897,13 +3966,22 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3897
3966
|
const authSessionBinding = current && typeof current === 'object'
|
|
3898
3967
|
? (current.authSessionBinding ?? current.authSessionId ?? current.authSessionKey ?? null)
|
|
3899
3968
|
: null;
|
|
3900
|
-
const
|
|
3901
|
-
|
|
3969
|
+
const rawLocalOnly = Boolean(current && typeof current === 'object' && current.localOnly === true);
|
|
3970
|
+
const uid = rawLocalOnly
|
|
3971
|
+
? null
|
|
3972
|
+
: rtlNormalizeUid(current && typeof current === 'object'
|
|
3973
|
+
? (current.uid ?? current.ownerUid)
|
|
3974
|
+
: null);
|
|
3975
|
+
const retainedOwnerUid = rtlNormalizeUid(current && typeof current === 'object'
|
|
3976
|
+
? (current.retainedOwnerUid ?? current.ownerUid ?? current.uid)
|
|
3902
3977
|
: null);
|
|
3903
3978
|
const rawEpoch = current && typeof current === 'object'
|
|
3904
3979
|
? (current.workspaceEpoch ?? current.epoch)
|
|
3905
3980
|
: undefined;
|
|
3906
3981
|
const hasEpoch = rawEpoch !== undefined && rawEpoch !== null && Number.isFinite(Number(rawEpoch));
|
|
3982
|
+
const localOnly = rawLocalOnly;
|
|
3983
|
+
const allowHydrationDispatch = Boolean(current && typeof current === 'object' &&
|
|
3984
|
+
current.allowHydrationDispatch === true);
|
|
3907
3985
|
return {
|
|
3908
3986
|
current,
|
|
3909
3987
|
hasSession: typeof session?.current === 'function',
|
|
@@ -3912,6 +3990,9 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3912
3990
|
? authSessionBinding
|
|
3913
3991
|
: null,
|
|
3914
3992
|
uid,
|
|
3993
|
+
retainedOwnerUid,
|
|
3994
|
+
localOnly,
|
|
3995
|
+
allowHydrationDispatch,
|
|
3915
3996
|
workspaceEpoch: hasEpoch
|
|
3916
3997
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
3917
3998
|
: rtlNormalizeWorkspaceEpoch(fallbackEpoch, 0),
|
|
@@ -4059,7 +4140,9 @@ const rtlIsStaleRevision = (revision, baselineRevision) => (
|
|
|
4059
4140
|
);
|
|
4060
4141
|
|
|
4061
4142
|
const rtlDeriveDurableVisibleState = (workspace) => {
|
|
4062
|
-
let state = normalizeRecordTimeLabelDomainState(
|
|
4143
|
+
let state = normalizeRecordTimeLabelDomainState(
|
|
4144
|
+
workspace?.localOnly ? workspace?.localState || {} : workspace?.remoteBaseline?.state || {}
|
|
4145
|
+
);
|
|
4063
4146
|
toArray(workspace?.pendingOperations).forEach((operation) => {
|
|
4064
4147
|
state = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
4065
4148
|
? state
|
|
@@ -4158,7 +4241,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4158
4241
|
|
|
4159
4242
|
const workspaceMatchesSession = (captured) => {
|
|
4160
4243
|
if (!captured?.hasSession) return true;
|
|
4161
|
-
|
|
4244
|
+
const effectiveUid = captured.localOnly
|
|
4245
|
+
? (captured.retainedOwnerUid || captured.uid)
|
|
4246
|
+
: captured.uid;
|
|
4247
|
+
return rtlNormalizeUid(workspace.ownerUid) === effectiveUid &&
|
|
4162
4248
|
Number(workspace.workspaceEpoch) === Number(captured.workspaceEpoch);
|
|
4163
4249
|
};
|
|
4164
4250
|
|
|
@@ -4166,9 +4252,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4166
4252
|
if (destroyed || !captured) return false;
|
|
4167
4253
|
if (typeof session?.isCurrent === 'function') {
|
|
4168
4254
|
try {
|
|
4255
|
+
const effectiveUid = captured.localOnly
|
|
4256
|
+
? (captured.retainedOwnerUid || captured.uid)
|
|
4257
|
+
: captured.uid;
|
|
4169
4258
|
return Boolean(await session.isCurrent(
|
|
4170
4259
|
captured.sessionToken,
|
|
4171
|
-
|
|
4260
|
+
effectiveUid,
|
|
4172
4261
|
captured.workspaceEpoch
|
|
4173
4262
|
));
|
|
4174
4263
|
} catch {
|
|
@@ -4220,6 +4309,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4220
4309
|
const applyRemoteBaseline = (candidate, remoteValue) => {
|
|
4221
4310
|
const remote = rtlEnvelopePayload(remoteValue);
|
|
4222
4311
|
if (!remote) return {changed: false, stale: false};
|
|
4312
|
+
if ((remote.state ?? remote.data) !== undefined &&
|
|
4313
|
+
!rtlIsAdmissibleRemoteBaseline(remote)) {
|
|
4314
|
+
return {changed: false, stale: false, invalid: true};
|
|
4315
|
+
}
|
|
4223
4316
|
const revision = Number(remote.revision);
|
|
4224
4317
|
if (rtlIsStaleRevision(revision, candidate.remoteBaseline.revision)) {
|
|
4225
4318
|
return {changed: false, stale: true};
|
|
@@ -4303,10 +4396,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4303
4396
|
throw toRecordTimeLabelCloudFailureError(source);
|
|
4304
4397
|
}
|
|
4305
4398
|
const remote = rtlEnvelopePayload(response);
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4399
|
+
if (remote?.localOnly === true) {
|
|
4400
|
+
const localState = remote.state ?? remote.data;
|
|
4401
|
+
if (!rtlDurableIsObject(localState)) {
|
|
4402
|
+
throw toRecordTimeLabelCloudFailureError({
|
|
4403
|
+
code: 'recordtimelabel_invalid_local_only_bootstrap',
|
|
4404
|
+
reason: 'recordtimelabel_invalid_local_only_bootstrap',
|
|
4405
|
+
message: 'recordtimelabel_invalid_local_only_bootstrap',
|
|
4406
|
+
retryable: false
|
|
4407
|
+
});
|
|
4408
|
+
}
|
|
4409
|
+
return {
|
|
4410
|
+
localOnly: true,
|
|
4411
|
+
localState: normalizeRecordTimeLabelDomainState(localState),
|
|
4412
|
+
revision: Number.isSafeInteger(Number(remote.revision)) && Number(remote.revision) >= 0
|
|
4413
|
+
? Number(remote.revision)
|
|
4414
|
+
: 0,
|
|
4415
|
+
changeCursor: remote.changeCursor ?? null
|
|
4416
|
+
};
|
|
4417
|
+
}
|
|
4418
|
+
if (!rtlIsAdmissibleRemoteBaseline(remote)) {
|
|
4310
4419
|
throw toRecordTimeLabelCloudFailureError({
|
|
4311
4420
|
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
4312
4421
|
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
@@ -4314,7 +4423,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4314
4423
|
retryable: false
|
|
4315
4424
|
});
|
|
4316
4425
|
}
|
|
4317
|
-
return {
|
|
4426
|
+
return {
|
|
4427
|
+
state: remote.state ?? remote.data,
|
|
4428
|
+
revision: remote.revision,
|
|
4429
|
+
changeCursor: remote.changeCursor ?? null
|
|
4430
|
+
};
|
|
4318
4431
|
};
|
|
4319
4432
|
|
|
4320
4433
|
// A transient failure belongs to the same logical page walk. Keep its
|
|
@@ -4471,7 +4584,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4471
4584
|
now
|
|
4472
4585
|
}
|
|
4473
4586
|
);
|
|
4474
|
-
const currentUid = captured?.
|
|
4587
|
+
const currentUid = captured?.localOnly
|
|
4588
|
+
? (captured?.retainedOwnerUid || captured?.ownerUid || null)
|
|
4589
|
+
: captured?.uid;
|
|
4475
4590
|
const currentEpoch = captured?.workspaceEpoch ?? loadedWorkspace.workspaceEpoch;
|
|
4476
4591
|
const anonymousMigration = Boolean(
|
|
4477
4592
|
currentUid &&
|
|
@@ -4480,7 +4595,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4480
4595
|
!loadedWorkspace.syncMeta?.anonymousMigrationAt
|
|
4481
4596
|
);
|
|
4482
4597
|
if (anonymousMigration) {
|
|
4483
|
-
const anonymousState = loadedWorkspace.
|
|
4598
|
+
const anonymousState = loadedWorkspace.localOnly
|
|
4599
|
+
? loadedWorkspace.localState
|
|
4600
|
+
: loadedWorkspace.remoteBaseline.state;
|
|
4484
4601
|
const migrationTime = Math.max(1, rtlToFiniteNumber(anonymousState.lastModified, now()));
|
|
4485
4602
|
const migratedOperations = [];
|
|
4486
4603
|
const seenIds = new Set();
|
|
@@ -4582,9 +4699,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4582
4699
|
loadedWorkspace.pendingOperations.length ||
|
|
4583
4700
|
Object.prototype.hasOwnProperty.call(loadedWorkspace.syncMeta || {}, 'legacyExpandedGroups');
|
|
4584
4701
|
loadedWorkspace.pendingOperations = migratedView.pendingOperations;
|
|
4585
|
-
loadedWorkspace.
|
|
4586
|
-
loadedWorkspace.
|
|
4587
|
-
|
|
4702
|
+
if (loadedWorkspace.localOnly) {
|
|
4703
|
+
loadedWorkspace.localState = normalizeRecordTimeLabelDomainState(loadedWorkspace.localState);
|
|
4704
|
+
} else {
|
|
4705
|
+
loadedWorkspace.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
4706
|
+
loadedWorkspace.remoteBaseline.state
|
|
4707
|
+
);
|
|
4708
|
+
}
|
|
4588
4709
|
if (hadLegacyView) {
|
|
4589
4710
|
const syncMeta = {...loadedWorkspace.syncMeta};
|
|
4590
4711
|
delete syncMeta.legacyExpandedGroups;
|
|
@@ -5019,11 +5140,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5019
5140
|
failure
|
|
5020
5141
|
};
|
|
5021
5142
|
}
|
|
5143
|
+
const remoteState = remote?.state ?? remote?.data;
|
|
5144
|
+
if (remoteState !== undefined && !rtlIsAdmissibleRemoteBaseline(remote)) {
|
|
5145
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5146
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5147
|
+
code: 'invalid_remote_baseline',
|
|
5148
|
+
reason: 'invalid_remote_baseline',
|
|
5149
|
+
message: 'invalid_remote_baseline'
|
|
5150
|
+
});
|
|
5151
|
+
return {
|
|
5152
|
+
success: false,
|
|
5153
|
+
protocolError: true,
|
|
5154
|
+
reason: 'invalid_remote_baseline',
|
|
5155
|
+
failure,
|
|
5156
|
+
error: toRecordTimeLabelCloudFailureError(failure)
|
|
5157
|
+
};
|
|
5158
|
+
}
|
|
5022
5159
|
if (remoteRevision <= candidate.remoteBaseline.revision) {
|
|
5023
5160
|
return {success: true, ignored: true};
|
|
5024
5161
|
}
|
|
5025
5162
|
let baselineValue = remoteValue;
|
|
5026
|
-
const remoteState = remote?.state ?? remote?.data;
|
|
5027
5163
|
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
5028
5164
|
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
5029
5165
|
if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
|
|
@@ -5046,6 +5182,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5046
5182
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
5047
5183
|
}
|
|
5048
5184
|
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
5185
|
+
if (applied.invalid) {
|
|
5186
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5187
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5188
|
+
code: 'invalid_remote_baseline',
|
|
5189
|
+
reason: 'invalid_remote_baseline',
|
|
5190
|
+
message: 'invalid_remote_baseline'
|
|
5191
|
+
});
|
|
5192
|
+
return {success: false, protocolError: true, reason: failure.code, failure,
|
|
5193
|
+
error: toRecordTimeLabelCloudFailureError(failure)};
|
|
5194
|
+
}
|
|
5049
5195
|
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
5050
5196
|
const reconciledOperations = appendVodSiblingReconciliation(candidate, captured);
|
|
5051
5197
|
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
@@ -5136,21 +5282,36 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5136
5282
|
};
|
|
5137
5283
|
|
|
5138
5284
|
const resetForSessionIdentity = (captured) => {
|
|
5139
|
-
|
|
5285
|
+
const retainedOwner = rtlNormalizeUid(captured?.retainedOwnerUid || captured?.ownerUid);
|
|
5286
|
+
const effectiveUid = captured?.localOnly && retainedOwner ? retainedOwner : captured?.uid;
|
|
5140
5287
|
const sameOwner = Boolean(
|
|
5141
5288
|
rtlNormalizeUid(workspace.ownerUid) &&
|
|
5142
|
-
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(
|
|
5289
|
+
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(effectiveUid)
|
|
5143
5290
|
);
|
|
5291
|
+
const incomingEpoch = Number(captured?.workspaceEpoch);
|
|
5292
|
+
const currentEpoch = Number(workspace.workspaceEpoch);
|
|
5293
|
+
// A callback from an older session generation is stale. Discard it
|
|
5294
|
+
// before touching the active subscription, workspace, or persistence.
|
|
5295
|
+
if (sameOwner && Number.isFinite(incomingEpoch) && Number.isFinite(currentEpoch) && incomingEpoch < currentEpoch) {
|
|
5296
|
+
return {stale: true, reason: 'stale_session'};
|
|
5297
|
+
}
|
|
5298
|
+
stopCloudSubscription();
|
|
5299
|
+
if (captured?.localOnly && sameOwner) {
|
|
5300
|
+
initialized = true;
|
|
5301
|
+
hydrationRequired = false;
|
|
5302
|
+
return {stale: false};
|
|
5303
|
+
}
|
|
5144
5304
|
const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
|
|
5145
5305
|
workspace = sameOwner && epochIncreased
|
|
5146
5306
|
? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
|
|
5147
5307
|
: rtlEmptyDurableWorkspace({
|
|
5148
|
-
ownerUid:
|
|
5308
|
+
ownerUid: effectiveUid,
|
|
5149
5309
|
workspaceEpoch: captured?.workspaceEpoch
|
|
5150
5310
|
});
|
|
5151
5311
|
initialized = false;
|
|
5152
5312
|
hydrationRequired = true;
|
|
5153
5313
|
notify({type: 'session_changed'});
|
|
5314
|
+
return {stale: false};
|
|
5154
5315
|
};
|
|
5155
5316
|
|
|
5156
5317
|
const startCloudSubscription = (captured, candidate) => {
|
|
@@ -5161,7 +5322,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5161
5322
|
readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
|
|
5162
5323
|
return;
|
|
5163
5324
|
}
|
|
5164
|
-
if (!captured?.uid) {
|
|
5325
|
+
if (!captured?.uid || captured?.localOnly) {
|
|
5165
5326
|
readyState.resolve({skipped: true, reason: 'anonymous'});
|
|
5166
5327
|
return;
|
|
5167
5328
|
}
|
|
@@ -5251,6 +5412,21 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5251
5412
|
latestRootObservationRevision ?? 0,
|
|
5252
5413
|
observedRevision
|
|
5253
5414
|
);
|
|
5415
|
+
// A successful root callback has already processed (or proven) this
|
|
5416
|
+
// revision. Prefer that proof over an adapter-ready receipt that may
|
|
5417
|
+
// have resolved earlier with an older revision.
|
|
5418
|
+
const currentRevision = Number(adapterCatchUpProof?.revision);
|
|
5419
|
+
const currentCaughtUp = Number(adapterCatchUpProof?.caughtUpToRevision);
|
|
5420
|
+
adapterCatchUpProof = {
|
|
5421
|
+
...(adapterCatchUpProof || {}),
|
|
5422
|
+
success: true,
|
|
5423
|
+
revision: Number.isSafeInteger(currentRevision)
|
|
5424
|
+
? Math.max(currentRevision, observedRevision)
|
|
5425
|
+
: observedRevision,
|
|
5426
|
+
caughtUpToRevision: Number.isSafeInteger(currentCaughtUp)
|
|
5427
|
+
? Math.max(currentCaughtUp, observedRevision)
|
|
5428
|
+
: observedRevision
|
|
5429
|
+
};
|
|
5254
5430
|
}
|
|
5255
5431
|
if (!adapterReadyExpected) {
|
|
5256
5432
|
adapterCatchUpProof = {
|
|
@@ -5322,9 +5498,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5322
5498
|
}
|
|
5323
5499
|
));
|
|
5324
5500
|
} else {
|
|
5325
|
-
|
|
5501
|
+
const nextProof = result && typeof result === 'object'
|
|
5326
5502
|
? {...result, success: true}
|
|
5327
5503
|
: {success: true};
|
|
5504
|
+
const currentRevision = Number(adapterCatchUpProof?.revision);
|
|
5505
|
+
const currentCaughtUp = Number(adapterCatchUpProof?.caughtUpToRevision);
|
|
5506
|
+
adapterCatchUpProof = {
|
|
5507
|
+
...nextProof,
|
|
5508
|
+
...(Number.isSafeInteger(currentRevision) && Number.isSafeInteger(Number(nextProof.revision))
|
|
5509
|
+
? {revision: Math.max(currentRevision, Number(nextProof.revision))}
|
|
5510
|
+
: {}),
|
|
5511
|
+
...(Number.isSafeInteger(currentCaughtUp) && Number.isSafeInteger(Number(nextProof.caughtUpToRevision))
|
|
5512
|
+
? {caughtUpToRevision: Math.max(currentCaughtUp, Number(nextProof.caughtUpToRevision))}
|
|
5513
|
+
: {})
|
|
5514
|
+
};
|
|
5328
5515
|
settleReadyIfProven();
|
|
5329
5516
|
}
|
|
5330
5517
|
}).catch((error) => {
|
|
@@ -5390,7 +5577,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5390
5577
|
rethrowClassifiedCloudFailure(captured, error);
|
|
5391
5578
|
}
|
|
5392
5579
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5393
|
-
const applied =
|
|
5580
|
+
const applied = bootstrap?.localOnly === true
|
|
5581
|
+
? (() => {
|
|
5582
|
+
candidate.localOnly = true;
|
|
5583
|
+
candidate.localState = normalizeRecordTimeLabelDomainState(bootstrap.localState);
|
|
5584
|
+
candidate.remoteBaseline = null;
|
|
5585
|
+
return {changed: true, stale: false};
|
|
5586
|
+
})()
|
|
5587
|
+
: applyRemoteBaseline(candidate, bootstrap);
|
|
5394
5588
|
if (applied.stale) {
|
|
5395
5589
|
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5396
5590
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
@@ -5439,13 +5633,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5439
5633
|
enqueue(async () => {
|
|
5440
5634
|
const current = capture();
|
|
5441
5635
|
clearAuthTransitionLatchIfSessionChanged(current);
|
|
5442
|
-
const
|
|
5636
|
+
const effectiveUid = current.localOnly
|
|
5637
|
+
? (current.retainedOwnerUid || current.ownerUid || current.uid)
|
|
5638
|
+
: current.uid;
|
|
5639
|
+
const sameIdentity = effectiveUid === workspace.ownerUid &&
|
|
5443
5640
|
Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
|
|
5444
5641
|
if (sameIdentity) {
|
|
5445
5642
|
startCloudSubscription(current, workspace);
|
|
5446
5643
|
return {success: true, tokenRefreshed: true};
|
|
5447
5644
|
}
|
|
5448
|
-
resetForSessionIdentity(current);
|
|
5645
|
+
const transition = resetForSessionIdentity(current);
|
|
5646
|
+
if (transition?.stale) return transition;
|
|
5449
5647
|
return {success: true, sessionChanged: true};
|
|
5450
5648
|
}).catch((error) => {
|
|
5451
5649
|
logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
|
|
@@ -5457,14 +5655,23 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5457
5655
|
};
|
|
5458
5656
|
|
|
5459
5657
|
const dispatchInternal = async (operations) => {
|
|
5460
|
-
if (!initialized || hydrationRequired) {
|
|
5461
|
-
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5462
|
-
}
|
|
5463
5658
|
const captured = capture();
|
|
5464
5659
|
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
5660
|
+
// Keep the operation owner live across logout/relogin. A retained,
|
|
5661
|
+
// identity-matching workspace may accept local durable writes while
|
|
5662
|
+
// hydration is pending; cloud sync remains blocked by syncInternal until
|
|
5663
|
+
// the fresh baseline is committed.
|
|
5664
|
+
const retainedWorkspaceAdmission = Boolean(
|
|
5665
|
+
hydrationRequired && workspaceMatchesSession(captured) &&
|
|
5666
|
+
(captured.localOnly === true || captured.allowHydrationDispatch === true)
|
|
5667
|
+
);
|
|
5668
|
+
if ((!initialized || hydrationRequired) && !retainedWorkspaceAdmission) {
|
|
5669
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5670
|
+
}
|
|
5465
5671
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5466
5672
|
if (!workspaceMatchesSession(captured)) {
|
|
5467
|
-
resetForSessionIdentity(captured);
|
|
5673
|
+
const transition = resetForSessionIdentity(captured);
|
|
5674
|
+
if (transition?.stale) return transition;
|
|
5468
5675
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5469
5676
|
}
|
|
5470
5677
|
const input = Array.isArray(operations)
|
|
@@ -5472,7 +5679,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5472
5679
|
: (operations && typeof operations === 'object' ? [operations] : []);
|
|
5473
5680
|
if (input.length === 0) return getSnapshot();
|
|
5474
5681
|
const candidate = clone(workspace);
|
|
5475
|
-
if (captured?.uid !== undefined && captured?.uid !== null) {
|
|
5682
|
+
if (!captured?.localOnly && captured?.uid !== undefined && captured?.uid !== null) {
|
|
5476
5683
|
candidate.ownerUid = captured.uid;
|
|
5477
5684
|
}
|
|
5478
5685
|
if (captured?.hasEpoch) candidate.workspaceEpoch = captured.workspaceEpoch;
|
|
@@ -5480,7 +5687,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5480
5687
|
client,
|
|
5481
5688
|
clientId: typeof client === 'string' ? client : client?.id,
|
|
5482
5689
|
now,
|
|
5483
|
-
ownerUid: captured?.uid ?? candidate.ownerUid,
|
|
5690
|
+
ownerUid: captured?.localOnly ? candidate.ownerUid : (captured?.uid ?? candidate.ownerUid),
|
|
5484
5691
|
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
|
|
5485
5692
|
}));
|
|
5486
5693
|
const syncBatchId = rtlStableSyncBatchId(normalized);
|
|
@@ -5609,6 +5816,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5609
5816
|
identityRejectedCount
|
|
5610
5817
|
};
|
|
5611
5818
|
}
|
|
5819
|
+
if (captured?.localOnly) {
|
|
5820
|
+
return {
|
|
5821
|
+
success: true,
|
|
5822
|
+
skipped: 'local_only',
|
|
5823
|
+
localOnly: true,
|
|
5824
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5825
|
+
rejectedCount: identityRejectedCount,
|
|
5826
|
+
identityRejectedCount
|
|
5827
|
+
};
|
|
5828
|
+
}
|
|
5612
5829
|
if (!captured?.uid) {
|
|
5613
5830
|
return {
|
|
5614
5831
|
success: true,
|
|
@@ -5711,6 +5928,28 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5711
5928
|
}
|
|
5712
5929
|
const responseBaseline = rtlEnvelopePayload(response);
|
|
5713
5930
|
const responseRevision = responseRevisionInfo.revision;
|
|
5931
|
+
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
5932
|
+
if (responseState !== undefined &&
|
|
5933
|
+
!rtlIsAdmissibleRemoteBaseline({
|
|
5934
|
+
...responseBaseline,
|
|
5935
|
+
revision: responseRevision
|
|
5936
|
+
})) {
|
|
5937
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5938
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5939
|
+
code: 'invalid_authoritative_baseline',
|
|
5940
|
+
reason: 'invalid_authoritative_baseline',
|
|
5941
|
+
message: 'invalid_authoritative_baseline'
|
|
5942
|
+
});
|
|
5943
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
5944
|
+
return persistTerminalFailureBlock({
|
|
5945
|
+
failure,
|
|
5946
|
+
error,
|
|
5947
|
+
captured,
|
|
5948
|
+
timestamp,
|
|
5949
|
+
identityRejectedCount,
|
|
5950
|
+
extra: {protocolError: true}
|
|
5951
|
+
});
|
|
5952
|
+
}
|
|
5714
5953
|
const parsed = normalizeOperationResults(response, wireOperations);
|
|
5715
5954
|
if (parsed.error) {
|
|
5716
5955
|
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
@@ -5794,7 +6033,6 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5794
6033
|
});
|
|
5795
6034
|
}
|
|
5796
6035
|
}
|
|
5797
|
-
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
5798
6036
|
const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
|
|
5799
6037
|
Number.isSafeInteger(responseRevision)
|
|
5800
6038
|
? {...responseBaseline, revision: responseRevision}
|
|
@@ -6076,7 +6314,8 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
6076
6314
|
return getSnapshot();
|
|
6077
6315
|
}
|
|
6078
6316
|
if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
|
|
6079
|
-
resetForSessionIdentity(captured);
|
|
6317
|
+
const transition = resetForSessionIdentity(captured);
|
|
6318
|
+
if (transition?.stale) return transition;
|
|
6080
6319
|
}
|
|
6081
6320
|
return initialize();
|
|
6082
6321
|
});
|
|
@@ -6743,6 +6982,13 @@ const rtlApplyOperationToPartialDocuments = ({
|
|
|
6743
6982
|
};
|
|
6744
6983
|
};
|
|
6745
6984
|
|
|
6985
|
+
const rtlHasStaleLifecyclePayload = (payload, ...currentEntities) => {
|
|
6986
|
+
if (!Object.prototype.hasOwnProperty.call(payload || {}, 'lifecycleGeneration')) return false;
|
|
6987
|
+
const incoming = Number(payload.lifecycleGeneration);
|
|
6988
|
+
if (!Number.isSafeInteger(incoming) || incoming < 0) return true;
|
|
6989
|
+
return incoming < rtlMaxLifecycleGeneration(...currentEntities);
|
|
6990
|
+
};
|
|
6991
|
+
|
|
6746
6992
|
const rtlCanRestoreLocalFolder = (root, folder) => {
|
|
6747
6993
|
if (!folder?.id) return false;
|
|
6748
6994
|
const tombstones = root?.deletedFolderTombstones;
|
|
@@ -6773,6 +7019,13 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowC
|
|
|
6773
7019
|
};
|
|
6774
7020
|
|
|
6775
7021
|
const rtlSetRoot = (target, source) => {
|
|
7022
|
+
// Lifecycle tombstones from the v2 subcollection are merged into the
|
|
7023
|
+
// reducer state for conflict checks, but must never be projected back into
|
|
7024
|
+
// the bounded root document during an ordinary partial rewrite. Preserve
|
|
7025
|
+
// the root-owned (legacy) maps from the current candidate; restore paths
|
|
7026
|
+
// explicitly remove their own legacy entry below when authoritative.
|
|
7027
|
+
const legacyRecordTombstones = clone(target?.deletedRecordTombstones || {});
|
|
7028
|
+
const legacyFolderTombstones = clone(target?.deletedFolderTombstones || {});
|
|
6776
7029
|
Object.keys(target).forEach((key) => delete target[key]);
|
|
6777
7030
|
const next = clone(source || {});
|
|
6778
7031
|
// `Object.assign` invokes the legacy `__proto__` setter on a normal target.
|
|
@@ -6787,6 +7040,8 @@ const rtlSetRoot = (target, source) => {
|
|
|
6787
7040
|
writable: true
|
|
6788
7041
|
});
|
|
6789
7042
|
});
|
|
7043
|
+
target.deletedRecordTombstones = legacyRecordTombstones;
|
|
7044
|
+
target.deletedFolderTombstones = legacyFolderTombstones;
|
|
6790
7045
|
};
|
|
6791
7046
|
|
|
6792
7047
|
/**
|
|
@@ -6910,6 +7165,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6910
7165
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
6911
7166
|
break;
|
|
6912
7167
|
}
|
|
7168
|
+
if (rtlHasStaleLifecyclePayload(payload.patch || payload.record,
|
|
7169
|
+
existing, rtlGetLifecycleTombstone(nextDocuments, 'record', recordId))) {
|
|
7170
|
+
reason = 'lifecycle_conflict';
|
|
7171
|
+
break;
|
|
7172
|
+
}
|
|
6913
7173
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
6914
7174
|
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6915
7175
|
? applied.documents.records[recordId]
|
|
@@ -6928,6 +7188,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6928
7188
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
6929
7189
|
break;
|
|
6930
7190
|
}
|
|
7191
|
+
if (rtlHasStaleLifecyclePayload(payload.record,
|
|
7192
|
+
existing, rtlGetLifecycleTombstone(nextDocuments, 'record', recordId))) {
|
|
7193
|
+
reason = 'lifecycle_conflict';
|
|
7194
|
+
break;
|
|
7195
|
+
}
|
|
6931
7196
|
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6932
7197
|
if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
|
|
6933
7198
|
reason = 'missing_target_folder_id';
|
|
@@ -7005,6 +7270,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
7005
7270
|
? nextDocuments.trash[operationTrashEntryId]
|
|
7006
7271
|
: null;
|
|
7007
7272
|
const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
|
|
7273
|
+
if (rtlHasStaleLifecyclePayload(payload.record, existingRecord, existingTombstone)) {
|
|
7274
|
+
reason = 'lifecycle_conflict';
|
|
7275
|
+
break;
|
|
7276
|
+
}
|
|
7008
7277
|
if (!existingRecord) {
|
|
7009
7278
|
const hasTrash = Boolean(existingTrashEntry);
|
|
7010
7279
|
const hasTombstone = Boolean(existingTombstone);
|
|
@@ -7121,6 +7390,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
7121
7390
|
: null);
|
|
7122
7391
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
7123
7392
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
7393
|
+
// A restore is authoritative for this entity: remove only its
|
|
7394
|
+
// legacy root tombstone. The lifecycle subcollection deletion is
|
|
7395
|
+
// recorded separately below and unrelated legacy entries remain.
|
|
7396
|
+
if (nextDocuments.root.deletedRecordTombstones) {
|
|
7397
|
+
delete nextDocuments.root.deletedRecordTombstones[restoredRecordId];
|
|
7398
|
+
}
|
|
7124
7399
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
7125
7400
|
delete nextDocuments.trash[trashEntryId];
|
|
7126
7401
|
const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
|
|
@@ -7159,6 +7434,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
7159
7434
|
reason = 'folder_not_found';
|
|
7160
7435
|
break;
|
|
7161
7436
|
}
|
|
7437
|
+
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE &&
|
|
7438
|
+
rtlHasStaleLifecyclePayload(payload.patch || payload.folder,
|
|
7439
|
+
nextDocuments.folders[folderId], rtlGetLifecycleTombstone(nextDocuments, 'folder', folderId))) {
|
|
7440
|
+
reason = 'lifecycle_conflict';
|
|
7441
|
+
break;
|
|
7442
|
+
}
|
|
7162
7443
|
const previousOrder = rtlOwn(nextDocuments.folders, folderId)
|
|
7163
7444
|
? nextDocuments.folders[folderId]?.recordOrder || []
|
|
7164
7445
|
: [];
|