@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 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.9`; 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.9"
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.10",
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
@@ -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.10';
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
- Number(recordSnapshot.lifecycleGeneration || 0)) {
1151
+ rtlMaxLifecycleGeneration(recordSnapshot, existingEntry?.record)) {
1135
1152
  return normalized;
1136
1153
  }
1137
1154
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
1138
- const previousGeneration = Math.max(
1139
- Number(recordSnapshot?.lifecycleGeneration || 0),
1140
- Number(nextState.deletedRecordTombstones[recordId]?.lifecycleGeneration || 0)
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, updatedAt: patch.updatedAt || operationTime }
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
- Number(folderSnapshot.lifecycleGeneration || 0)) {
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 = Math.max(
1288
- Number(folderSnapshot?.lifecycleGeneration || 0),
1289
- Number(nextState.deletedFolderTombstones[folderId]?.lifecycleGeneration || 0)
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
- 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) => ({
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 baseline = legacy
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
- remoteBaseline: baseline,
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(workspace?.remoteBaseline?.state || {})
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 uid = rtlNormalizeUid(current && typeof current === 'object'
3899
- ? (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)
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(workspace?.remoteBaseline?.state || {});
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
- 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 &&
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
- captured.uid,
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
- const revision = Number(remote?.revision);
4305
- const state = remote?.state ?? remote?.data;
4306
- if (!remote || !rtlDurableIsObject(state) ||
4307
- !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)) {
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 {state, revision, changeCursor: remote.changeCursor ?? null};
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?.uid;
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.remoteBaseline.state;
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.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
4584
- loadedWorkspace.remoteBaseline.state
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
- stopCloudSubscription();
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(captured?.uid)
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: captured?.uid,
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 = 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);
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 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 &&
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,