@recordtimelabel/core 0.6.11 → 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 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.11`; verify that its registry tarball and lockfile integrity are available before updating consumers:
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.11"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
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) => String(value || '').trim();
12
- const toIdList = (value) => (Array.isArray(value) ? value : []).map(toId).filter(Boolean);
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
- const id = toId(value);
16
- if (!id) return '';
27
+ if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) return '';
28
+ const id = value;
17
29
  try {
18
- return decodeURIComponent(id);
30
+ const decoded = decodeURIComponent(id);
31
+ return normalizeRecordTimeLabelImmutableId(decoded) || '';
19
32
  } catch {
20
- return id;
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([toId(value), logicalId, encodeURIComponent(logicalId)].filter(Boolean))];
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 changedRecords = toIdList(change.changedRecordIds);
192
- const deletedRecords = toIdList(change.deletedRecordIds);
193
- const changedFolders = toIdList(change.changedFolderIds);
194
- const deletedFolders = toIdList(change.deletedFolderIds);
195
- const changedTrash = toIdList(change.changedTrashEntryIds);
196
- const deletedTrash = toIdList(change.deletedTrashEntryIds);
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 lifecycleDeletes = toIdList(change.deletedLifecycleTombstoneIds);
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.11';
150
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.12';
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
- Number(recordSnapshot.lifecycleGeneration || 0)) {
1151
+ rtlMaxLifecycleGeneration(recordSnapshot, existingEntry?.record)) {
1137
1152
  return normalized;
1138
1153
  }
1139
1154
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
1140
- const previousGeneration = Math.max(
1141
- Number(recordSnapshot?.lifecycleGeneration || 0),
1142
- Number(nextState.deletedRecordTombstones[recordId]?.lifecycleGeneration || 0)
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, updatedAt: patch.updatedAt || operationTime }
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
- Number(folderSnapshot.lifecycleGeneration || 0)) {
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 = Math.max(
1290
- Number(folderSnapshot?.lifecycleGeneration || 0),
1291
- Number(nextState.deletedFolderTombstones[folderId]?.lifecycleGeneration || 0)
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
- nextState.records[folderId] = toArray(trashEntry.payload?.records).map((record) => ({
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 baseline = legacy
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
- remoteBaseline: baseline,
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(workspace?.remoteBaseline?.state || {})
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 uid = rtlNormalizeUid(current && typeof current === 'object'
3901
- ? (current.uid ?? current.ownerUid)
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(workspace?.remoteBaseline?.state || {});
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
- return rtlNormalizeUid(workspace.ownerUid) === captured.uid &&
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
- captured.uid,
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
- const revision = Number(remote?.revision);
4307
- const state = remote?.state ?? remote?.data;
4308
- if (!remote || !rtlDurableIsObject(state) ||
4309
- !Number.isSafeInteger(revision) || revision < 0) {
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 {state, revision, changeCursor: remote.changeCursor ?? null};
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?.uid;
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.remoteBaseline.state;
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.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
4586
- loadedWorkspace.remoteBaseline.state
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
- stopCloudSubscription();
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(captured?.uid)
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: captured?.uid,
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
  }
@@ -5390,7 +5551,14 @@ export const createRecordTimeLabelSyncEngine = ({
5390
5551
  rethrowClassifiedCloudFailure(captured, error);
5391
5552
  }
5392
5553
  if (!(await isCurrent(captured))) return getSnapshot();
5393
- const applied = applyRemoteBaseline(candidate, bootstrap);
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);
5394
5562
  if (applied.stale) {
5395
5563
  candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5396
5564
  if (!(await persist(candidate, captured))) return getSnapshot();
@@ -5439,13 +5607,17 @@ export const createRecordTimeLabelSyncEngine = ({
5439
5607
  enqueue(async () => {
5440
5608
  const current = capture();
5441
5609
  clearAuthTransitionLatchIfSessionChanged(current);
5442
- const sameIdentity = current.uid === workspace.ownerUid &&
5610
+ const effectiveUid = current.localOnly
5611
+ ? (current.retainedOwnerUid || current.ownerUid || current.uid)
5612
+ : current.uid;
5613
+ const sameIdentity = effectiveUid === workspace.ownerUid &&
5443
5614
  Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
5444
5615
  if (sameIdentity) {
5445
5616
  startCloudSubscription(current, workspace);
5446
5617
  return {success: true, tokenRefreshed: true};
5447
5618
  }
5448
- resetForSessionIdentity(current);
5619
+ const transition = resetForSessionIdentity(current);
5620
+ if (transition?.stale) return transition;
5449
5621
  return {success: true, sessionChanged: true};
5450
5622
  }).catch((error) => {
5451
5623
  logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
@@ -5457,14 +5629,23 @@ export const createRecordTimeLabelSyncEngine = ({
5457
5629
  };
5458
5630
 
5459
5631
  const dispatchInternal = async (operations) => {
5460
- if (!initialized || hydrationRequired) {
5461
- return {success: false, code: 'recordtimelabel_hydration_required'};
5462
- }
5463
5632
  const captured = capture();
5464
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
+ }
5465
5645
  if (!(await isCurrent(captured))) return getSnapshot();
5466
5646
  if (!workspaceMatchesSession(captured)) {
5467
- resetForSessionIdentity(captured);
5647
+ const transition = resetForSessionIdentity(captured);
5648
+ if (transition?.stale) return transition;
5468
5649
  return {success: false, code: 'recordtimelabel_hydration_required'};
5469
5650
  }
5470
5651
  const input = Array.isArray(operations)
@@ -5472,7 +5653,7 @@ export const createRecordTimeLabelSyncEngine = ({
5472
5653
  : (operations && typeof operations === 'object' ? [operations] : []);
5473
5654
  if (input.length === 0) return getSnapshot();
5474
5655
  const candidate = clone(workspace);
5475
- if (captured?.uid !== undefined && captured?.uid !== null) {
5656
+ if (!captured?.localOnly && captured?.uid !== undefined && captured?.uid !== null) {
5476
5657
  candidate.ownerUid = captured.uid;
5477
5658
  }
5478
5659
  if (captured?.hasEpoch) candidate.workspaceEpoch = captured.workspaceEpoch;
@@ -5480,7 +5661,7 @@ export const createRecordTimeLabelSyncEngine = ({
5480
5661
  client,
5481
5662
  clientId: typeof client === 'string' ? client : client?.id,
5482
5663
  now,
5483
- ownerUid: captured?.uid ?? candidate.ownerUid,
5664
+ ownerUid: captured?.localOnly ? candidate.ownerUid : (captured?.uid ?? candidate.ownerUid),
5484
5665
  workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
5485
5666
  }));
5486
5667
  const syncBatchId = rtlStableSyncBatchId(normalized);
@@ -5609,6 +5790,16 @@ export const createRecordTimeLabelSyncEngine = ({
5609
5790
  identityRejectedCount
5610
5791
  };
5611
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
+ }
5612
5803
  if (!captured?.uid) {
5613
5804
  return {
5614
5805
  success: true,
@@ -5711,6 +5902,28 @@ export const createRecordTimeLabelSyncEngine = ({
5711
5902
  }
5712
5903
  const responseBaseline = rtlEnvelopePayload(response);
5713
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
+ }
5714
5927
  const parsed = normalizeOperationResults(response, wireOperations);
5715
5928
  if (parsed.error) {
5716
5929
  const failure = normalizeRecordTimeLabelCloudFailure({
@@ -5794,7 +6007,6 @@ export const createRecordTimeLabelSyncEngine = ({
5794
6007
  });
5795
6008
  }
5796
6009
  }
5797
- const responseState = responseBaseline?.state ?? responseBaseline?.data;
5798
6010
  const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
5799
6011
  Number.isSafeInteger(responseRevision)
5800
6012
  ? {...responseBaseline, revision: responseRevision}
@@ -6076,7 +6288,8 @@ export const createRecordTimeLabelSyncEngine = ({
6076
6288
  return getSnapshot();
6077
6289
  }
6078
6290
  if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
6079
- resetForSessionIdentity(captured);
6291
+ const transition = resetForSessionIdentity(captured);
6292
+ if (transition?.stale) return transition;
6080
6293
  }
6081
6294
  return initialize();
6082
6295
  });
@@ -6743,6 +6956,13 @@ const rtlApplyOperationToPartialDocuments = ({
6743
6956
  };
6744
6957
  };
6745
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
+
6746
6966
  const rtlCanRestoreLocalFolder = (root, folder) => {
6747
6967
  if (!folder?.id) return false;
6748
6968
  const tombstones = root?.deletedFolderTombstones;
@@ -6773,6 +6993,13 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowC
6773
6993
  };
6774
6994
 
6775
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 || {});
6776
7003
  Object.keys(target).forEach((key) => delete target[key]);
6777
7004
  const next = clone(source || {});
6778
7005
  // `Object.assign` invokes the legacy `__proto__` setter on a normal target.
@@ -6787,6 +7014,8 @@ const rtlSetRoot = (target, source) => {
6787
7014
  writable: true
6788
7015
  });
6789
7016
  });
7017
+ target.deletedRecordTombstones = legacyRecordTombstones;
7018
+ target.deletedFolderTombstones = legacyFolderTombstones;
6790
7019
  };
6791
7020
 
6792
7021
  /**
@@ -6910,6 +7139,11 @@ export const planFirestoreV2OperationChanges = ({
6910
7139
  reason = recordId ? 'record_not_found' : 'missing_record_id';
6911
7140
  break;
6912
7141
  }
7142
+ if (rtlHasStaleLifecyclePayload(payload.patch || payload.record,
7143
+ existing, rtlGetLifecycleTombstone(nextDocuments, 'record', recordId))) {
7144
+ reason = 'lifecycle_conflict';
7145
+ break;
7146
+ }
6913
7147
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
6914
7148
  const recordDocument = rtlOwn(applied.documents.records, recordId)
6915
7149
  ? applied.documents.records[recordId]
@@ -6928,6 +7162,11 @@ export const planFirestoreV2OperationChanges = ({
6928
7162
  reason = recordId ? 'record_not_found' : 'missing_record_id';
6929
7163
  break;
6930
7164
  }
7165
+ if (rtlHasStaleLifecyclePayload(payload.record,
7166
+ existing, rtlGetLifecycleTombstone(nextDocuments, 'record', recordId))) {
7167
+ reason = 'lifecycle_conflict';
7168
+ break;
7169
+ }
6931
7170
  const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6932
7171
  if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
6933
7172
  reason = 'missing_target_folder_id';
@@ -7005,6 +7244,10 @@ export const planFirestoreV2OperationChanges = ({
7005
7244
  ? nextDocuments.trash[operationTrashEntryId]
7006
7245
  : null;
7007
7246
  const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
7247
+ if (rtlHasStaleLifecyclePayload(payload.record, existingRecord, existingTombstone)) {
7248
+ reason = 'lifecycle_conflict';
7249
+ break;
7250
+ }
7008
7251
  if (!existingRecord) {
7009
7252
  const hasTrash = Boolean(existingTrashEntry);
7010
7253
  const hasTombstone = Boolean(existingTombstone);
@@ -7121,6 +7364,12 @@ export const planFirestoreV2OperationChanges = ({
7121
7364
  : null);
7122
7365
  if (!targetFolder) { reason = 'folder_not_found'; break; }
7123
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
+ }
7124
7373
  nextDocuments.records[restoredRecordId] = restoredRecord;
7125
7374
  delete nextDocuments.trash[trashEntryId];
7126
7375
  const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
@@ -7159,6 +7408,12 @@ export const planFirestoreV2OperationChanges = ({
7159
7408
  reason = 'folder_not_found';
7160
7409
  break;
7161
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
+ }
7162
7417
  const previousOrder = rtlOwn(nextDocuments.folders, folderId)
7163
7418
  ? nextDocuments.folders[folderId]?.recordOrder || []
7164
7419
  : [];