@recordtimelabel/core 0.6.10 → 0.6.12
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 +304 -47
- package/src/protocol.js +8 -0
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.12`; 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.12"
|
|
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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
8
8
|
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
9
9
|
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
10
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
|
|
10
11
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
11
12
|
RECORD_TIMELABEL_CLOUD_RECOVERY_ACTIONS,
|
|
12
13
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
@@ -33,6 +34,7 @@ export {
|
|
|
33
34
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
34
35
|
RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
|
|
35
36
|
RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
|
|
37
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
|
|
36
38
|
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
37
39
|
RECORD_TIMELABEL_CLOUD_RECOVERY_ACTIONS,
|
|
38
40
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
@@ -145,7 +147,7 @@ export {
|
|
|
145
147
|
selectBestMatchingTwitchVod
|
|
146
148
|
};
|
|
147
149
|
|
|
148
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
150
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.12';
|
|
149
151
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
150
152
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
151
153
|
'fifo-retry-fence',
|
|
@@ -602,6 +604,13 @@ export const applyExplicitRecordUpdate = (record = {}, patch = {}, operationTime
|
|
|
602
604
|
return next;
|
|
603
605
|
};
|
|
604
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
|
+
|
|
605
614
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
606
615
|
const source = records && typeof records === 'object' ? records : {};
|
|
607
616
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
@@ -1019,6 +1028,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1019
1028
|
id: recordId,
|
|
1020
1029
|
updatedAt: record.updatedAt || operationTime
|
|
1021
1030
|
};
|
|
1031
|
+
cleanedRecord.lifecycleGeneration = rtlMaxLifecycleGeneration(
|
|
1032
|
+
record, nextState.deletedRecordTombstones[recordId]
|
|
1033
|
+
);
|
|
1022
1034
|
delete cleanedRecord.folderId;
|
|
1023
1035
|
|
|
1024
1036
|
nextState.records = removeRecordById(nextState.records, recordId);
|
|
@@ -1034,12 +1046,15 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1034
1046
|
if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
|
|
1035
1047
|
const entry = findRecordEntry(nextState.records, recordId);
|
|
1036
1048
|
if (!entry) return normalized;
|
|
1037
|
-
const patch = payload.patch || payload.record || {};
|
|
1049
|
+
const patch = {...(payload.patch || payload.record || {})};
|
|
1050
|
+
delete patch.lifecycleGeneration;
|
|
1038
1051
|
if (Object.keys(patch).some((key) => RTL_RESERVED_DOCUMENT_KEYS.has(key))) {
|
|
1039
1052
|
return normalized;
|
|
1040
1053
|
}
|
|
1041
1054
|
const updatedRecord = applyExplicitRecordUpdate(entry.record, patch, operationTime);
|
|
1042
1055
|
updatedRecord.id = recordId;
|
|
1056
|
+
updatedRecord.lifecycleGeneration = rtlMaxLifecycleGeneration(entry.record,
|
|
1057
|
+
nextState.deletedRecordTombstones[recordId]);
|
|
1043
1058
|
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
1044
1059
|
break;
|
|
1045
1060
|
}
|
|
@@ -1058,6 +1073,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1058
1073
|
id: recordId,
|
|
1059
1074
|
updatedAt: payload.updatedAt || operationTime
|
|
1060
1075
|
};
|
|
1076
|
+
movedRecord.lifecycleGeneration = rtlMaxLifecycleGeneration(entry.record,
|
|
1077
|
+
nextState.deletedRecordTombstones[recordId]);
|
|
1061
1078
|
nextState.records = removeRecordById(nextState.records, recordId);
|
|
1062
1079
|
nextState.records[targetFolderId] = [
|
|
1063
1080
|
...toArray(nextState.records[targetFolderId]),
|
|
@@ -1131,13 +1148,13 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1131
1148
|
return normalized;
|
|
1132
1149
|
}
|
|
1133
1150
|
if (recordSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1134
|
-
|
|
1151
|
+
rtlMaxLifecycleGeneration(recordSnapshot, existingEntry?.record)) {
|
|
1135
1152
|
return normalized;
|
|
1136
1153
|
}
|
|
1137
1154
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1138
|
-
const previousGeneration =
|
|
1139
|
-
|
|
1140
|
-
|
|
1155
|
+
const previousGeneration = rtlMaxLifecycleGeneration(
|
|
1156
|
+
existingEntry?.record, recordSnapshot,
|
|
1157
|
+
nextState.deletedRecordTombstones[recordId]
|
|
1141
1158
|
);
|
|
1142
1159
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1143
1160
|
if (recordSnapshot) {
|
|
@@ -1254,10 +1271,14 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1254
1271
|
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
1255
1272
|
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
1256
1273
|
if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
|
|
1257
|
-
const patch = payload.patch || payload.folder || {};
|
|
1274
|
+
const patch = {...(payload.patch || payload.folder || {})};
|
|
1275
|
+
delete patch.lifecycleGeneration;
|
|
1258
1276
|
nextState.folders = nextState.folders.map((folder) => (
|
|
1259
1277
|
folder.id === folderId
|
|
1260
|
-
? { ...folder, ...patch, id: folderId,
|
|
1278
|
+
? { ...folder, ...patch, id: folderId,
|
|
1279
|
+
lifecycleGeneration: rtlMaxLifecycleGeneration(folder,
|
|
1280
|
+
nextState.deletedFolderTombstones[folderId]),
|
|
1281
|
+
updatedAt: patch.updatedAt || operationTime }
|
|
1261
1282
|
: folder
|
|
1262
1283
|
));
|
|
1263
1284
|
break;
|
|
@@ -1271,6 +1292,14 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1271
1292
|
? {...payload.folder, id: folderId}
|
|
1272
1293
|
: nextState.folders.find((folder) => folder.id === folderId);
|
|
1273
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
|
+
});
|
|
1274
1303
|
const existingTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
|
|
1275
1304
|
? nextState.deletedFolderTombstones[folderId]
|
|
1276
1305
|
: null;
|
|
@@ -1280,13 +1309,14 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1280
1309
|
return normalized;
|
|
1281
1310
|
}
|
|
1282
1311
|
if (folderSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
|
|
1283
|
-
|
|
1312
|
+
rtlMaxLifecycleGeneration(folderSnapshot,
|
|
1313
|
+
nextState.folders.find((folder) => folder.id === folderId))) {
|
|
1284
1314
|
return normalized;
|
|
1285
1315
|
}
|
|
1286
1316
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
1287
|
-
const previousGeneration =
|
|
1288
|
-
|
|
1289
|
-
|
|
1317
|
+
const previousGeneration = rtlMaxLifecycleGeneration(
|
|
1318
|
+
nextState.folders.find((folder) => folder.id === folderId), folderSnapshot,
|
|
1319
|
+
nextState.deletedFolderTombstones[folderId]
|
|
1290
1320
|
);
|
|
1291
1321
|
const lifecycleGeneration = previousGeneration + 1;
|
|
1292
1322
|
if (folderSnapshot) {
|
|
@@ -1370,7 +1400,16 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1370
1400
|
lifecycleGeneration,
|
|
1371
1401
|
updatedAt: operationTime
|
|
1372
1402
|
});
|
|
1373
|
-
|
|
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) => ({
|
|
1374
1413
|
...record,
|
|
1375
1414
|
lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1,
|
|
1376
1415
|
updatedAt: operationTime
|
|
@@ -1923,6 +1962,12 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
|
1923
1962
|
const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
|
|
1924
1963
|
delete cleanRecord.pendingSync;
|
|
1925
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;
|
|
1926
1971
|
return {
|
|
1927
1972
|
...cleanRecord,
|
|
1928
1973
|
updatedAt: cleanRecord.updatedAt || cleanRecord.createdAt || now,
|
|
@@ -3475,6 +3520,9 @@ const rtlSanitizeDurableWorkspace = (workspace) => {
|
|
|
3475
3520
|
state: clone(workspace.remoteBaseline.state)
|
|
3476
3521
|
};
|
|
3477
3522
|
}
|
|
3523
|
+
if (rtlDurableIsObject(workspace.localState)) {
|
|
3524
|
+
sanitized.localState = clone(workspace.localState);
|
|
3525
|
+
}
|
|
3478
3526
|
if (Array.isArray(workspace.pendingOperations)) {
|
|
3479
3527
|
sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
|
|
3480
3528
|
}
|
|
@@ -3559,6 +3607,22 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
|
3559
3607
|
};
|
|
3560
3608
|
};
|
|
3561
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
|
+
|
|
3562
3626
|
const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) => ({
|
|
3563
3627
|
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
3564
3628
|
ownerUid: rtlNormalizeUid(ownerUid),
|
|
@@ -3646,7 +3710,8 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3646
3710
|
source.workspaceEpoch ?? options.workspaceEpoch,
|
|
3647
3711
|
options.workspaceEpoch
|
|
3648
3712
|
);
|
|
3649
|
-
const
|
|
3713
|
+
const localOnly = source.localOnly === true;
|
|
3714
|
+
const baseline = localOnly ? null : legacy
|
|
3650
3715
|
? rtlNormalizeRemoteBaseline({
|
|
3651
3716
|
state: source.state || {},
|
|
3652
3717
|
revision: source.revision ?? source.syncMeta?.revision,
|
|
@@ -3690,7 +3755,11 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
3690
3755
|
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
3691
3756
|
ownerUid,
|
|
3692
3757
|
workspaceEpoch,
|
|
3693
|
-
|
|
3758
|
+
...(localOnly
|
|
3759
|
+
? {localOnly: true, localState: normalizeRecordTimeLabelDomainState(
|
|
3760
|
+
source.localState ?? source.state ?? {}
|
|
3761
|
+
)}
|
|
3762
|
+
: {remoteBaseline: baseline}),
|
|
3694
3763
|
pendingOperations,
|
|
3695
3764
|
rejectedOperations,
|
|
3696
3765
|
syncMeta: {
|
|
@@ -3819,7 +3888,9 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
3819
3888
|
}
|
|
3820
3889
|
});
|
|
3821
3890
|
let lifecycleState = operations === workspace?.pendingOperations
|
|
3822
|
-
? normalizeRecordTimeLabelDomainState(
|
|
3891
|
+
? normalizeRecordTimeLabelDomainState(
|
|
3892
|
+
workspace?.localOnly ? workspace?.localState || {} : workspace?.remoteBaseline?.state || {}
|
|
3893
|
+
)
|
|
3823
3894
|
: rtlDeriveDurableVisibleState(workspace);
|
|
3824
3895
|
if (operations !== workspace?.pendingOperations) {
|
|
3825
3896
|
toArray(workspace?.pendingOperations).forEach((operation) => {
|
|
@@ -3895,13 +3966,22 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3895
3966
|
const authSessionBinding = current && typeof current === 'object'
|
|
3896
3967
|
? (current.authSessionBinding ?? current.authSessionId ?? current.authSessionKey ?? null)
|
|
3897
3968
|
: null;
|
|
3898
|
-
const
|
|
3899
|
-
|
|
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)
|
|
3900
3977
|
: null);
|
|
3901
3978
|
const rawEpoch = current && typeof current === 'object'
|
|
3902
3979
|
? (current.workspaceEpoch ?? current.epoch)
|
|
3903
3980
|
: undefined;
|
|
3904
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);
|
|
3905
3985
|
return {
|
|
3906
3986
|
current,
|
|
3907
3987
|
hasSession: typeof session?.current === 'function',
|
|
@@ -3910,6 +3990,9 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3910
3990
|
? authSessionBinding
|
|
3911
3991
|
: null,
|
|
3912
3992
|
uid,
|
|
3993
|
+
retainedOwnerUid,
|
|
3994
|
+
localOnly,
|
|
3995
|
+
allowHydrationDispatch,
|
|
3913
3996
|
workspaceEpoch: hasEpoch
|
|
3914
3997
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
3915
3998
|
: rtlNormalizeWorkspaceEpoch(fallbackEpoch, 0),
|
|
@@ -4057,7 +4140,9 @@ const rtlIsStaleRevision = (revision, baselineRevision) => (
|
|
|
4057
4140
|
);
|
|
4058
4141
|
|
|
4059
4142
|
const rtlDeriveDurableVisibleState = (workspace) => {
|
|
4060
|
-
let state = normalizeRecordTimeLabelDomainState(
|
|
4143
|
+
let state = normalizeRecordTimeLabelDomainState(
|
|
4144
|
+
workspace?.localOnly ? workspace?.localState || {} : workspace?.remoteBaseline?.state || {}
|
|
4145
|
+
);
|
|
4061
4146
|
toArray(workspace?.pendingOperations).forEach((operation) => {
|
|
4062
4147
|
state = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
4063
4148
|
? state
|
|
@@ -4156,7 +4241,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4156
4241
|
|
|
4157
4242
|
const workspaceMatchesSession = (captured) => {
|
|
4158
4243
|
if (!captured?.hasSession) return true;
|
|
4159
|
-
|
|
4244
|
+
const effectiveUid = captured.localOnly
|
|
4245
|
+
? (captured.retainedOwnerUid || captured.uid)
|
|
4246
|
+
: captured.uid;
|
|
4247
|
+
return rtlNormalizeUid(workspace.ownerUid) === effectiveUid &&
|
|
4160
4248
|
Number(workspace.workspaceEpoch) === Number(captured.workspaceEpoch);
|
|
4161
4249
|
};
|
|
4162
4250
|
|
|
@@ -4164,9 +4252,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4164
4252
|
if (destroyed || !captured) return false;
|
|
4165
4253
|
if (typeof session?.isCurrent === 'function') {
|
|
4166
4254
|
try {
|
|
4255
|
+
const effectiveUid = captured.localOnly
|
|
4256
|
+
? (captured.retainedOwnerUid || captured.uid)
|
|
4257
|
+
: captured.uid;
|
|
4167
4258
|
return Boolean(await session.isCurrent(
|
|
4168
4259
|
captured.sessionToken,
|
|
4169
|
-
|
|
4260
|
+
effectiveUid,
|
|
4170
4261
|
captured.workspaceEpoch
|
|
4171
4262
|
));
|
|
4172
4263
|
} catch {
|
|
@@ -4218,6 +4309,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4218
4309
|
const applyRemoteBaseline = (candidate, remoteValue) => {
|
|
4219
4310
|
const remote = rtlEnvelopePayload(remoteValue);
|
|
4220
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
|
+
}
|
|
4221
4316
|
const revision = Number(remote.revision);
|
|
4222
4317
|
if (rtlIsStaleRevision(revision, candidate.remoteBaseline.revision)) {
|
|
4223
4318
|
return {changed: false, stale: true};
|
|
@@ -4301,10 +4396,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4301
4396
|
throw toRecordTimeLabelCloudFailureError(source);
|
|
4302
4397
|
}
|
|
4303
4398
|
const remote = rtlEnvelopePayload(response);
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
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)) {
|
|
4308
4419
|
throw toRecordTimeLabelCloudFailureError({
|
|
4309
4420
|
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
4310
4421
|
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
@@ -4312,7 +4423,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4312
4423
|
retryable: false
|
|
4313
4424
|
});
|
|
4314
4425
|
}
|
|
4315
|
-
return {
|
|
4426
|
+
return {
|
|
4427
|
+
state: remote.state ?? remote.data,
|
|
4428
|
+
revision: remote.revision,
|
|
4429
|
+
changeCursor: remote.changeCursor ?? null
|
|
4430
|
+
};
|
|
4316
4431
|
};
|
|
4317
4432
|
|
|
4318
4433
|
// A transient failure belongs to the same logical page walk. Keep its
|
|
@@ -4469,7 +4584,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4469
4584
|
now
|
|
4470
4585
|
}
|
|
4471
4586
|
);
|
|
4472
|
-
const currentUid = captured?.
|
|
4587
|
+
const currentUid = captured?.localOnly
|
|
4588
|
+
? (captured?.retainedOwnerUid || captured?.ownerUid || null)
|
|
4589
|
+
: captured?.uid;
|
|
4473
4590
|
const currentEpoch = captured?.workspaceEpoch ?? loadedWorkspace.workspaceEpoch;
|
|
4474
4591
|
const anonymousMigration = Boolean(
|
|
4475
4592
|
currentUid &&
|
|
@@ -4478,7 +4595,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4478
4595
|
!loadedWorkspace.syncMeta?.anonymousMigrationAt
|
|
4479
4596
|
);
|
|
4480
4597
|
if (anonymousMigration) {
|
|
4481
|
-
const anonymousState = loadedWorkspace.
|
|
4598
|
+
const anonymousState = loadedWorkspace.localOnly
|
|
4599
|
+
? loadedWorkspace.localState
|
|
4600
|
+
: loadedWorkspace.remoteBaseline.state;
|
|
4482
4601
|
const migrationTime = Math.max(1, rtlToFiniteNumber(anonymousState.lastModified, now()));
|
|
4483
4602
|
const migratedOperations = [];
|
|
4484
4603
|
const seenIds = new Set();
|
|
@@ -4580,9 +4699,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4580
4699
|
loadedWorkspace.pendingOperations.length ||
|
|
4581
4700
|
Object.prototype.hasOwnProperty.call(loadedWorkspace.syncMeta || {}, 'legacyExpandedGroups');
|
|
4582
4701
|
loadedWorkspace.pendingOperations = migratedView.pendingOperations;
|
|
4583
|
-
loadedWorkspace.
|
|
4584
|
-
loadedWorkspace.
|
|
4585
|
-
|
|
4702
|
+
if (loadedWorkspace.localOnly) {
|
|
4703
|
+
loadedWorkspace.localState = normalizeRecordTimeLabelDomainState(loadedWorkspace.localState);
|
|
4704
|
+
} else {
|
|
4705
|
+
loadedWorkspace.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
4706
|
+
loadedWorkspace.remoteBaseline.state
|
|
4707
|
+
);
|
|
4708
|
+
}
|
|
4586
4709
|
if (hadLegacyView) {
|
|
4587
4710
|
const syncMeta = {...loadedWorkspace.syncMeta};
|
|
4588
4711
|
delete syncMeta.legacyExpandedGroups;
|
|
@@ -5017,11 +5140,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5017
5140
|
failure
|
|
5018
5141
|
};
|
|
5019
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
|
+
}
|
|
5020
5159
|
if (remoteRevision <= candidate.remoteBaseline.revision) {
|
|
5021
5160
|
return {success: true, ignored: true};
|
|
5022
5161
|
}
|
|
5023
5162
|
let baselineValue = remoteValue;
|
|
5024
|
-
const remoteState = remote?.state ?? remote?.data;
|
|
5025
5163
|
const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
|
|
5026
5164
|
if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
|
|
5027
5165
|
if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
|
|
@@ -5044,6 +5182,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5044
5182
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
5045
5183
|
}
|
|
5046
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
|
+
}
|
|
5047
5195
|
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
5048
5196
|
const reconciledOperations = appendVodSiblingReconciliation(candidate, captured);
|
|
5049
5197
|
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
@@ -5134,21 +5282,36 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5134
5282
|
};
|
|
5135
5283
|
|
|
5136
5284
|
const resetForSessionIdentity = (captured) => {
|
|
5137
|
-
|
|
5285
|
+
const retainedOwner = rtlNormalizeUid(captured?.retainedOwnerUid || captured?.ownerUid);
|
|
5286
|
+
const effectiveUid = captured?.localOnly && retainedOwner ? retainedOwner : captured?.uid;
|
|
5138
5287
|
const sameOwner = Boolean(
|
|
5139
5288
|
rtlNormalizeUid(workspace.ownerUid) &&
|
|
5140
|
-
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(
|
|
5289
|
+
rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(effectiveUid)
|
|
5141
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
|
+
}
|
|
5142
5304
|
const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
|
|
5143
5305
|
workspace = sameOwner && epochIncreased
|
|
5144
5306
|
? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
|
|
5145
5307
|
: rtlEmptyDurableWorkspace({
|
|
5146
|
-
ownerUid:
|
|
5308
|
+
ownerUid: effectiveUid,
|
|
5147
5309
|
workspaceEpoch: captured?.workspaceEpoch
|
|
5148
5310
|
});
|
|
5149
5311
|
initialized = false;
|
|
5150
5312
|
hydrationRequired = true;
|
|
5151
5313
|
notify({type: 'session_changed'});
|
|
5314
|
+
return {stale: false};
|
|
5152
5315
|
};
|
|
5153
5316
|
|
|
5154
5317
|
const startCloudSubscription = (captured, candidate) => {
|
|
@@ -5159,7 +5322,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5159
5322
|
readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
|
|
5160
5323
|
return;
|
|
5161
5324
|
}
|
|
5162
|
-
if (!captured?.uid) {
|
|
5325
|
+
if (!captured?.uid || captured?.localOnly) {
|
|
5163
5326
|
readyState.resolve({skipped: true, reason: 'anonymous'});
|
|
5164
5327
|
return;
|
|
5165
5328
|
}
|
|
@@ -5388,7 +5551,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5388
5551
|
rethrowClassifiedCloudFailure(captured, error);
|
|
5389
5552
|
}
|
|
5390
5553
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5391
|
-
const applied =
|
|
5554
|
+
const applied = bootstrap?.localOnly === true
|
|
5555
|
+
? (() => {
|
|
5556
|
+
candidate.localOnly = true;
|
|
5557
|
+
candidate.localState = normalizeRecordTimeLabelDomainState(bootstrap.localState);
|
|
5558
|
+
candidate.remoteBaseline = null;
|
|
5559
|
+
return {changed: true, stale: false};
|
|
5560
|
+
})()
|
|
5561
|
+
: applyRemoteBaseline(candidate, bootstrap);
|
|
5392
5562
|
if (applied.stale) {
|
|
5393
5563
|
candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
|
|
5394
5564
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
@@ -5437,13 +5607,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5437
5607
|
enqueue(async () => {
|
|
5438
5608
|
const current = capture();
|
|
5439
5609
|
clearAuthTransitionLatchIfSessionChanged(current);
|
|
5440
|
-
const
|
|
5610
|
+
const effectiveUid = current.localOnly
|
|
5611
|
+
? (current.retainedOwnerUid || current.ownerUid || current.uid)
|
|
5612
|
+
: current.uid;
|
|
5613
|
+
const sameIdentity = effectiveUid === workspace.ownerUid &&
|
|
5441
5614
|
Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
|
|
5442
5615
|
if (sameIdentity) {
|
|
5443
5616
|
startCloudSubscription(current, workspace);
|
|
5444
5617
|
return {success: true, tokenRefreshed: true};
|
|
5445
5618
|
}
|
|
5446
|
-
resetForSessionIdentity(current);
|
|
5619
|
+
const transition = resetForSessionIdentity(current);
|
|
5620
|
+
if (transition?.stale) return transition;
|
|
5447
5621
|
return {success: true, sessionChanged: true};
|
|
5448
5622
|
}).catch((error) => {
|
|
5449
5623
|
logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
|
|
@@ -5455,14 +5629,23 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5455
5629
|
};
|
|
5456
5630
|
|
|
5457
5631
|
const dispatchInternal = async (operations) => {
|
|
5458
|
-
if (!initialized || hydrationRequired) {
|
|
5459
|
-
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5460
|
-
}
|
|
5461
5632
|
const captured = capture();
|
|
5462
5633
|
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
5634
|
+
// Keep the operation owner live across logout/relogin. A retained,
|
|
5635
|
+
// identity-matching workspace may accept local durable writes while
|
|
5636
|
+
// hydration is pending; cloud sync remains blocked by syncInternal until
|
|
5637
|
+
// the fresh baseline is committed.
|
|
5638
|
+
const retainedWorkspaceAdmission = Boolean(
|
|
5639
|
+
hydrationRequired && workspaceMatchesSession(captured) &&
|
|
5640
|
+
(captured.localOnly === true || captured.allowHydrationDispatch === true)
|
|
5641
|
+
);
|
|
5642
|
+
if ((!initialized || hydrationRequired) && !retainedWorkspaceAdmission) {
|
|
5643
|
+
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5644
|
+
}
|
|
5463
5645
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
5464
5646
|
if (!workspaceMatchesSession(captured)) {
|
|
5465
|
-
resetForSessionIdentity(captured);
|
|
5647
|
+
const transition = resetForSessionIdentity(captured);
|
|
5648
|
+
if (transition?.stale) return transition;
|
|
5466
5649
|
return {success: false, code: 'recordtimelabel_hydration_required'};
|
|
5467
5650
|
}
|
|
5468
5651
|
const input = Array.isArray(operations)
|
|
@@ -5470,7 +5653,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5470
5653
|
: (operations && typeof operations === 'object' ? [operations] : []);
|
|
5471
5654
|
if (input.length === 0) return getSnapshot();
|
|
5472
5655
|
const candidate = clone(workspace);
|
|
5473
|
-
if (captured?.uid !== undefined && captured?.uid !== null) {
|
|
5656
|
+
if (!captured?.localOnly && captured?.uid !== undefined && captured?.uid !== null) {
|
|
5474
5657
|
candidate.ownerUid = captured.uid;
|
|
5475
5658
|
}
|
|
5476
5659
|
if (captured?.hasEpoch) candidate.workspaceEpoch = captured.workspaceEpoch;
|
|
@@ -5478,7 +5661,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5478
5661
|
client,
|
|
5479
5662
|
clientId: typeof client === 'string' ? client : client?.id,
|
|
5480
5663
|
now,
|
|
5481
|
-
ownerUid: captured?.uid ?? candidate.ownerUid,
|
|
5664
|
+
ownerUid: captured?.localOnly ? candidate.ownerUid : (captured?.uid ?? candidate.ownerUid),
|
|
5482
5665
|
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
|
|
5483
5666
|
}));
|
|
5484
5667
|
const syncBatchId = rtlStableSyncBatchId(normalized);
|
|
@@ -5607,6 +5790,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5607
5790
|
identityRejectedCount
|
|
5608
5791
|
};
|
|
5609
5792
|
}
|
|
5793
|
+
if (captured?.localOnly) {
|
|
5794
|
+
return {
|
|
5795
|
+
success: true,
|
|
5796
|
+
skipped: 'local_only',
|
|
5797
|
+
localOnly: true,
|
|
5798
|
+
pendingCount: workspace.pendingOperations.length,
|
|
5799
|
+
rejectedCount: identityRejectedCount,
|
|
5800
|
+
identityRejectedCount
|
|
5801
|
+
};
|
|
5802
|
+
}
|
|
5610
5803
|
if (!captured?.uid) {
|
|
5611
5804
|
return {
|
|
5612
5805
|
success: true,
|
|
@@ -5709,6 +5902,28 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5709
5902
|
}
|
|
5710
5903
|
const responseBaseline = rtlEnvelopePayload(response);
|
|
5711
5904
|
const responseRevision = responseRevisionInfo.revision;
|
|
5905
|
+
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
5906
|
+
if (responseState !== undefined &&
|
|
5907
|
+
!rtlIsAdmissibleRemoteBaseline({
|
|
5908
|
+
...responseBaseline,
|
|
5909
|
+
revision: responseRevision
|
|
5910
|
+
})) {
|
|
5911
|
+
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
5912
|
+
class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
|
|
5913
|
+
code: 'invalid_authoritative_baseline',
|
|
5914
|
+
reason: 'invalid_authoritative_baseline',
|
|
5915
|
+
message: 'invalid_authoritative_baseline'
|
|
5916
|
+
});
|
|
5917
|
+
const error = toRecordTimeLabelCloudFailureError(failure);
|
|
5918
|
+
return persistTerminalFailureBlock({
|
|
5919
|
+
failure,
|
|
5920
|
+
error,
|
|
5921
|
+
captured,
|
|
5922
|
+
timestamp,
|
|
5923
|
+
identityRejectedCount,
|
|
5924
|
+
extra: {protocolError: true}
|
|
5925
|
+
});
|
|
5926
|
+
}
|
|
5712
5927
|
const parsed = normalizeOperationResults(response, wireOperations);
|
|
5713
5928
|
if (parsed.error) {
|
|
5714
5929
|
const failure = normalizeRecordTimeLabelCloudFailure({
|
|
@@ -5792,7 +6007,6 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5792
6007
|
});
|
|
5793
6008
|
}
|
|
5794
6009
|
}
|
|
5795
|
-
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
5796
6010
|
const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
|
|
5797
6011
|
Number.isSafeInteger(responseRevision)
|
|
5798
6012
|
? {...responseBaseline, revision: responseRevision}
|
|
@@ -6074,7 +6288,8 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
6074
6288
|
return getSnapshot();
|
|
6075
6289
|
}
|
|
6076
6290
|
if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
|
|
6077
|
-
resetForSessionIdentity(captured);
|
|
6291
|
+
const transition = resetForSessionIdentity(captured);
|
|
6292
|
+
if (transition?.stale) return transition;
|
|
6078
6293
|
}
|
|
6079
6294
|
return initialize();
|
|
6080
6295
|
});
|
|
@@ -6741,6 +6956,13 @@ const rtlApplyOperationToPartialDocuments = ({
|
|
|
6741
6956
|
};
|
|
6742
6957
|
};
|
|
6743
6958
|
|
|
6959
|
+
const rtlHasStaleLifecyclePayload = (payload, ...currentEntities) => {
|
|
6960
|
+
if (!Object.prototype.hasOwnProperty.call(payload || {}, 'lifecycleGeneration')) return false;
|
|
6961
|
+
const incoming = Number(payload.lifecycleGeneration);
|
|
6962
|
+
if (!Number.isSafeInteger(incoming) || incoming < 0) return true;
|
|
6963
|
+
return incoming < rtlMaxLifecycleGeneration(...currentEntities);
|
|
6964
|
+
};
|
|
6965
|
+
|
|
6744
6966
|
const rtlCanRestoreLocalFolder = (root, folder) => {
|
|
6745
6967
|
if (!folder?.id) return false;
|
|
6746
6968
|
const tombstones = root?.deletedFolderTombstones;
|
|
@@ -6771,6 +6993,13 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowC
|
|
|
6771
6993
|
};
|
|
6772
6994
|
|
|
6773
6995
|
const rtlSetRoot = (target, source) => {
|
|
6996
|
+
// Lifecycle tombstones from the v2 subcollection are merged into the
|
|
6997
|
+
// reducer state for conflict checks, but must never be projected back into
|
|
6998
|
+
// the bounded root document during an ordinary partial rewrite. Preserve
|
|
6999
|
+
// the root-owned (legacy) maps from the current candidate; restore paths
|
|
7000
|
+
// explicitly remove their own legacy entry below when authoritative.
|
|
7001
|
+
const legacyRecordTombstones = clone(target?.deletedRecordTombstones || {});
|
|
7002
|
+
const legacyFolderTombstones = clone(target?.deletedFolderTombstones || {});
|
|
6774
7003
|
Object.keys(target).forEach((key) => delete target[key]);
|
|
6775
7004
|
const next = clone(source || {});
|
|
6776
7005
|
// `Object.assign` invokes the legacy `__proto__` setter on a normal target.
|
|
@@ -6785,6 +7014,8 @@ const rtlSetRoot = (target, source) => {
|
|
|
6785
7014
|
writable: true
|
|
6786
7015
|
});
|
|
6787
7016
|
});
|
|
7017
|
+
target.deletedRecordTombstones = legacyRecordTombstones;
|
|
7018
|
+
target.deletedFolderTombstones = legacyFolderTombstones;
|
|
6788
7019
|
};
|
|
6789
7020
|
|
|
6790
7021
|
/**
|
|
@@ -6908,6 +7139,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6908
7139
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
6909
7140
|
break;
|
|
6910
7141
|
}
|
|
7142
|
+
if (rtlHasStaleLifecyclePayload(payload.patch || payload.record,
|
|
7143
|
+
existing, rtlGetLifecycleTombstone(nextDocuments, 'record', recordId))) {
|
|
7144
|
+
reason = 'lifecycle_conflict';
|
|
7145
|
+
break;
|
|
7146
|
+
}
|
|
6911
7147
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
6912
7148
|
const recordDocument = rtlOwn(applied.documents.records, recordId)
|
|
6913
7149
|
? applied.documents.records[recordId]
|
|
@@ -6926,6 +7162,11 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
6926
7162
|
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
6927
7163
|
break;
|
|
6928
7164
|
}
|
|
7165
|
+
if (rtlHasStaleLifecyclePayload(payload.record,
|
|
7166
|
+
existing, rtlGetLifecycleTombstone(nextDocuments, 'record', recordId))) {
|
|
7167
|
+
reason = 'lifecycle_conflict';
|
|
7168
|
+
break;
|
|
7169
|
+
}
|
|
6929
7170
|
const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
|
|
6930
7171
|
if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
|
|
6931
7172
|
reason = 'missing_target_folder_id';
|
|
@@ -7003,6 +7244,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
7003
7244
|
? nextDocuments.trash[operationTrashEntryId]
|
|
7004
7245
|
: null;
|
|
7005
7246
|
const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
|
|
7247
|
+
if (rtlHasStaleLifecyclePayload(payload.record, existingRecord, existingTombstone)) {
|
|
7248
|
+
reason = 'lifecycle_conflict';
|
|
7249
|
+
break;
|
|
7250
|
+
}
|
|
7006
7251
|
if (!existingRecord) {
|
|
7007
7252
|
const hasTrash = Boolean(existingTrashEntry);
|
|
7008
7253
|
const hasTombstone = Boolean(existingTombstone);
|
|
@@ -7119,6 +7364,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
7119
7364
|
: null);
|
|
7120
7365
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
7121
7366
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
7367
|
+
// A restore is authoritative for this entity: remove only its
|
|
7368
|
+
// legacy root tombstone. The lifecycle subcollection deletion is
|
|
7369
|
+
// recorded separately below and unrelated legacy entries remain.
|
|
7370
|
+
if (nextDocuments.root.deletedRecordTombstones) {
|
|
7371
|
+
delete nextDocuments.root.deletedRecordTombstones[restoredRecordId];
|
|
7372
|
+
}
|
|
7122
7373
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
7123
7374
|
delete nextDocuments.trash[trashEntryId];
|
|
7124
7375
|
const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
|
|
@@ -7157,6 +7408,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
7157
7408
|
reason = 'folder_not_found';
|
|
7158
7409
|
break;
|
|
7159
7410
|
}
|
|
7411
|
+
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE &&
|
|
7412
|
+
rtlHasStaleLifecyclePayload(payload.patch || payload.folder,
|
|
7413
|
+
nextDocuments.folders[folderId], rtlGetLifecycleTombstone(nextDocuments, 'folder', folderId))) {
|
|
7414
|
+
reason = 'lifecycle_conflict';
|
|
7415
|
+
break;
|
|
7416
|
+
}
|
|
7160
7417
|
const previousOrder = rtlOwn(nextDocuments.folders, folderId)
|
|
7161
7418
|
? nextDocuments.folders[folderId]?.recordOrder || []
|
|
7162
7419
|
: [];
|
package/src/protocol.js
CHANGED
|
@@ -20,6 +20,11 @@ export const RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE =
|
|
|
20
20
|
export const RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE = 'cloud-failure-state-v1';
|
|
21
21
|
export const RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER = 'deterministic-planner-v1';
|
|
22
22
|
export const RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS = 'strict-readiness-v1';
|
|
23
|
+
// Platform adapters use this exact code when their local durable store cannot
|
|
24
|
+
// accept another snapshot. Keep provider-specific matching in the adapter;
|
|
25
|
+
// Core owns only the canonical wire code and recovery semantics.
|
|
26
|
+
export const RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED =
|
|
27
|
+
'recordtimelabel_local_storage_quota_exceeded';
|
|
23
28
|
// Keep the original capability spelling for rolling compatibility. New
|
|
24
29
|
// clients may advertise the versioned alias while old clients continue to
|
|
25
30
|
// advertise `lifecycle-generation-fence`.
|
|
@@ -133,6 +138,8 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
|
|
|
133
138
|
['too_many_requests', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
134
139
|
['network_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
135
140
|
['network_request_failed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
141
|
+
[RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
|
|
142
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
|
|
136
143
|
['bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
137
144
|
['bootstraprequired', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
138
145
|
['recordtimelabel_bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
|
|
@@ -856,6 +863,7 @@ export const createRecordTimeLabelTransportFailureResults = (
|
|
|
856
863
|
|
|
857
864
|
export default {
|
|
858
865
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
866
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
|
|
859
867
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
860
868
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
861
869
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|