@recordtimelabel/core 0.3.1 → 0.3.3

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.
Files changed (3) hide show
  1. package/README.md +10 -5
  2. package/package.json +1 -1
  3. package/src/index.js +247 -53
package/README.md CHANGED
@@ -20,7 +20,7 @@ During local development an app can consume a sibling checkout with:
20
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 v2 gateway contract release is:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.3.1"
23
+ "@recordtimelabel/core": "0.3.2"
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.
@@ -84,10 +84,15 @@ The core also normalizes and preserves these compatibility metadata fields:
84
84
  Deletion must be represented by tombstones or operations. Do not reintroduce the old heuristic that treats "local exists but cloud missing for more than five minutes" as deletion.
85
85
 
86
86
  `mergeLocalRemote` resolves deletions by `lifecycleGeneration`, in both directions: a tombstone or
87
- trash entry is dropped when either side carries that entity as active with a higher generation,
88
- which is what restore operations bump. A stale snapshot on either side therefore cannot re-delete
89
- an entity that was already restored, and consumers must not reimplement this reconciliation in an
90
- app-level wrapper.
87
+ trash entry is dropped when either side carries that entity as active and newer, and restore
88
+ operations are what bump the generation. When generations tie an active copy written outside the
89
+ delete/restore path the active copy's own timestamp breaks the tie against `deletedAt`. A stale
90
+ snapshot on either side therefore cannot re-delete an entity that was already restored.
91
+
92
+ Consumers must not reimplement this reconciliation in an app-level wrapper. In particular, never
93
+ clear a local tombstone just because the remote snapshot still carries the entity as active: that
94
+ is presence-based inference, the same anti-pattern as the five-minute heuristic above, and it is not
95
+ order-independent, so two clients using different rules will not converge on shared cloud data.
91
96
 
92
97
  ## Firestore v2 Gateway Contract
93
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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/index.js CHANGED
@@ -5,7 +5,7 @@ const REQUIRED_FOLDERS = [
5
5
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
6
6
  ];
7
7
 
8
- export const RECORD_TIMELABEL_CORE_VERSION = '0.3.1';
8
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.3.3';
9
9
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
10
10
  export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
11
11
  export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
@@ -220,7 +220,11 @@ const mergeTombstones = (left = {}, right = {}) => {
220
220
  const merged = { ...left };
221
221
  Object.entries(right).forEach(([id, tombstone]) => {
222
222
  const current = merged[id];
223
- if (!current || toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt)) {
223
+ const currentGeneration = Number(current?.lifecycleGeneration || 0);
224
+ const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
225
+ if (!current || nextGeneration > currentGeneration ||
226
+ (nextGeneration === currentGeneration &&
227
+ toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt))) {
224
228
  merged[id] = { ...tombstone };
225
229
  }
226
230
  });
@@ -241,30 +245,42 @@ const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
241
245
  return merged;
242
246
  };
243
247
 
244
- const collectActiveLifecycleGenerations = (...states) => {
248
+ const collectActiveLifecycleStates = (...states) => {
245
249
  const records = new Map();
246
250
  const folders = new Map();
247
- const track = (target, id, entity) => {
251
+ const track = (target, id, entity, time) => {
248
252
  if (!id) return;
249
253
  const generation = Number(entity?.lifecycleGeneration || 0);
250
- target.set(id, Math.max(generation, target.get(id) || 0));
254
+ const current = target.get(id);
255
+ if (current && (current.generation > generation ||
256
+ (current.generation === generation && current.time >= time))) return;
257
+ target.set(id, { generation, time });
251
258
  };
252
259
  states.forEach((state) => {
253
260
  Object.entries(state?.records || {}).forEach(([folderId, folderRecords]) => {
254
261
  if (VIRTUAL_FOLDER_IDS.has(normalizeId(folderId))) return;
255
- toArray(folderRecords).forEach((record) => track(records, getRecordId(record), record));
262
+ toArray(folderRecords).forEach((record) => (
263
+ track(records, getRecordId(record), record, getRecordTime(record))
264
+ ));
256
265
  });
257
- toArray(state?.folders).forEach((folder) => track(folders, normalizeId(folder?.id), folder));
266
+ toArray(state?.folders).forEach((folder) => (
267
+ track(folders, normalizeId(folder?.id), folder, getFolderTime(folder))
268
+ ));
258
269
  });
259
270
  return { records, folders };
260
271
  };
261
272
 
262
273
  /**
263
274
  * A tombstone or trash entry only stays valid while it describes the newest lifecycle event for
264
- * its entity. Once either side carries that entity as active with a higher lifecycleGeneration
265
- * which restore operations bump the deletion is stale and must be dropped, otherwise
266
- * mergeTombstones' union lets a stale snapshot re-delete a record that was already restored.
267
- * Generations default to 0, so legacy data never outranks a real tombstone.
275
+ * its entity. Once either side carries that entity as active and newer, the deletion is stale and
276
+ * must be dropped, otherwise mergeTombstones' union lets a stale snapshot re-delete a record that
277
+ * was already restored.
278
+ *
279
+ * "Newer" is decided by lifecycleGeneration first — restore operations bump it, and generations
280
+ * default to 0 so legacy data never outranks a real tombstone. Generations only tie when an active
281
+ * copy was written outside the delete/restore path, and there the active copy's own timestamp
282
+ * breaks the tie. That tie-break also makes this consistent with mergeLocalRemote's local-record
283
+ * loop, which already intends a record newer than `deletedAt` to survive its tombstone.
268
284
  */
269
285
  const dropSupersededLifecycleDeletions = ({
270
286
  deletedRecordTombstones = {},
@@ -272,9 +288,13 @@ const dropSupersededLifecycleDeletions = ({
272
288
  trashEntries = {},
273
289
  activeGenerations
274
290
  }) => {
275
- const isSuperseded = (target, id, deletion) => (
276
- (target.get(normalizeId(id)) || 0) > Number(deletion?.lifecycleGeneration || 0)
277
- );
291
+ const isSuperseded = (target, id, deletion) => {
292
+ const active = target.get(normalizeId(id));
293
+ if (!active) return false;
294
+ const deletionGeneration = Number(deletion?.lifecycleGeneration || 0);
295
+ if (active.generation !== deletionGeneration) return active.generation > deletionGeneration;
296
+ return active.time > toFiniteTimestamp(deletion?.deletedAt);
297
+ };
278
298
  const nextRecordTombstones = { ...deletedRecordTombstones };
279
299
  Object.entries(nextRecordTombstones).forEach(([recordId, tombstone]) => {
280
300
  if (isSuperseded(activeGenerations.records, recordId, tombstone)) {
@@ -827,7 +847,8 @@ export const applyOperation = (state = {}, operation = {}) => {
827
847
  const recordSnapshot = payload.record && typeof payload.record === 'object'
828
848
  ? {...payload.record, id: recordId}
829
849
  : existingEntry?.record;
830
- if (!recordSnapshot && nextState.trashEntries[`record:${recordId}`]) return normalized;
850
+ const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
851
+ if (!recordSnapshot && nextState.trashEntries[trashEntryId]) return normalized;
831
852
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
832
853
  const previousGeneration = Math.max(
833
854
  Number(recordSnapshot?.lifecycleGeneration || 0),
@@ -835,7 +856,6 @@ export const applyOperation = (state = {}, operation = {}) => {
835
856
  );
836
857
  const lifecycleGeneration = previousGeneration + 1;
837
858
  if (recordSnapshot) {
838
- const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
839
859
  nextState.trashEntries[trashEntryId] = {
840
860
  id: trashEntryId,
841
861
  kind: 'record',
@@ -951,6 +971,8 @@ export const applyOperation = (state = {}, operation = {}) => {
951
971
  entityId: folderId,
952
972
  batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
953
973
  originalFolderOrderIndex: Math.max(0, nextState.folderOrder.indexOf(folderId)),
974
+ // -1 records "was not in groupOrder", so restore does not invent a placement.
975
+ originalGroupOrderIndex: nextState.groupOrder.indexOf(folderId),
954
976
  lifecycleGeneration,
955
977
  deletedAt,
956
978
  purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
@@ -1020,6 +1042,15 @@ export const applyOperation = (state = {}, operation = {}) => {
1020
1042
  );
1021
1043
  folderOrder.splice(restoreIndex, 0, folderId);
1022
1044
  nextState.folderOrder = folderOrder;
1045
+ // folder.delete strips the id from groupOrder too, so restore has to put it back.
1046
+ const originalGroupOrderIndex = Number.isInteger(trashEntry.originalGroupOrderIndex)
1047
+ ? trashEntry.originalGroupOrderIndex
1048
+ : -1;
1049
+ if (originalGroupOrderIndex >= 0) {
1050
+ const groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
1051
+ groupOrder.splice(Math.min(originalGroupOrderIndex, groupOrder.length), 0, folderId);
1052
+ nextState.groupOrder = groupOrder;
1053
+ }
1023
1054
  delete nextState.deletedFolderTombstones[folderId];
1024
1055
  nextState.records[folderId].forEach((record) => {
1025
1056
  delete nextState.deletedRecordTombstones[record.id];
@@ -1134,7 +1165,7 @@ export const mergeLocalRemote = ({
1134
1165
  local.deletedFolderTombstones
1135
1166
  ),
1136
1167
  trashEntries: mergeTrashEntries(remote.trashEntries, local.trashEntries),
1137
- activeGenerations: collectActiveLifecycleGenerations(remote, local)
1168
+ activeGenerations: collectActiveLifecycleStates(remote, local)
1138
1169
  });
1139
1170
  const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
1140
1171
  const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
@@ -1569,6 +1600,29 @@ const cleanV2TrashDocument = (entry = {}, now) => {
1569
1600
  };
1570
1601
  };
1571
1602
 
1603
+ const buildV2LifecycleTombstoneDocuments = (
1604
+ deletedRecordTombstones = {},
1605
+ deletedFolderTombstones = {}
1606
+ ) => {
1607
+ const documents = {};
1608
+ const append = (kind, tombstones) => {
1609
+ Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
1610
+ const id = `${kind}:${entityId}`;
1611
+ documents[encodeURIComponent(id)] = {
1612
+ id,
1613
+ kind,
1614
+ entityId,
1615
+ lifecycleGeneration: Math.max(1, Number(tombstone.lifecycleGeneration || 1)),
1616
+ deletedAt: toFiniteTimestamp(tombstone.deletedAt),
1617
+ schemaVersion: 2
1618
+ };
1619
+ });
1620
+ };
1621
+ append('record', deletedRecordTombstones);
1622
+ append('folder', deletedFolderTombstones);
1623
+ return documents;
1624
+ };
1625
+
1572
1626
  export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
1573
1627
  const normalized = normalizeState(state || {});
1574
1628
  const now = resolveNow(options.now);
@@ -1604,6 +1658,11 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1604
1658
  if (trashDocument) trash[trashDocument.id] = trashDocument;
1605
1659
  });
1606
1660
 
1661
+ const lifecycleTombstones = buildV2LifecycleTombstoneDocuments(
1662
+ normalized.deletedRecordTombstones,
1663
+ normalized.deletedFolderTombstones
1664
+ );
1665
+
1607
1666
  const root = {
1608
1667
  id: FIRESTORE_V2_SETTINGS_DOC_ID,
1609
1668
  schemaVersion: 2,
@@ -1627,6 +1686,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1627
1686
  records,
1628
1687
  folders,
1629
1688
  trash,
1689
+ lifecycleTombstones,
1630
1690
  ops
1631
1691
  };
1632
1692
  };
@@ -1723,7 +1783,12 @@ export const buildFirestoreV2DocumentChangeSet = (
1723
1783
  nextDocuments?.trash,
1724
1784
  allowDeletes
1725
1785
  );
1726
- const hasCollectionChanges = [records, folders, trash, ops].some((changeSet) => (
1786
+ const lifecycleTombstones = buildFirestoreV2CollectionChangeSet(
1787
+ previousDocuments?.lifecycleTombstones,
1788
+ nextDocuments?.lifecycleTombstones,
1789
+ allowDeletes
1790
+ );
1791
+ const hasCollectionChanges = [records, folders, trash, lifecycleTombstones, ops].some((changeSet) => (
1727
1792
  Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
1728
1793
  ));
1729
1794
 
@@ -1733,6 +1798,7 @@ export const buildFirestoreV2DocumentChangeSet = (
1733
1798
  records,
1734
1799
  folders,
1735
1800
  trash,
1801
+ lifecycleTombstones,
1736
1802
  ops
1737
1803
  };
1738
1804
  };
@@ -1742,11 +1808,54 @@ const removeV2Metadata = (data = {}) => {
1742
1808
  return rest;
1743
1809
  };
1744
1810
 
1811
+ /**
1812
+ * lifecycleTombstones 子集合的文件形狀是 { id: 'record:abc', kind, entityId, lifecycleGeneration,
1813
+ * deletedAt },拆成 record / folder 兩張以 entityId 為鍵的表,好跟 root 上的舊資料合併。
1814
+ */
1815
+ const splitLifecycleTombstoneDocuments = (value) => {
1816
+ const records = {};
1817
+ const folders = {};
1818
+ mapValuesArray(value).forEach((entry) => {
1819
+ const entityId = normalizeId(entry?.entityId);
1820
+ const kind = normalizeId(entry?.kind);
1821
+ const target = kind === 'folder' ? folders : kind === 'record' ? records : null;
1822
+ if (!entityId || !target) return;
1823
+ target[entityId] = {
1824
+ id: entityId,
1825
+ lifecycleGeneration: Math.max(1, Number(entry.lifecycleGeneration || 1)),
1826
+ deletedAt: toFiniteTimestamp(entry.deletedAt)
1827
+ };
1828
+ });
1829
+ return { records, folders };
1830
+ };
1831
+
1832
+ /**
1833
+ * root 的 tombstone 表已停止成長,但既有帳號上仍留著歷史資料,因此兩邊都要讀。同一個 id 同時出現時
1834
+ * 以世代優先、時間次之,跟 dropSupersededLifecycleDeletions 的判準一致。
1835
+ */
1836
+ const mergeLifecycleTombstoneSources = (legacy = {}, subcollection = {}) => {
1837
+ const merged = normalizeTombstones(legacy);
1838
+ Object.entries(normalizeTombstones(subcollection)).forEach(([id, tombstone]) => {
1839
+ const current = merged[id];
1840
+ const currentGeneration = Number(current?.lifecycleGeneration || 0);
1841
+ const nextGeneration = Number(tombstone.lifecycleGeneration || 0);
1842
+ if (!current || nextGeneration > currentGeneration ||
1843
+ (nextGeneration === currentGeneration &&
1844
+ toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt))) {
1845
+ merged[id] = tombstone;
1846
+ }
1847
+ });
1848
+ return merged;
1849
+ };
1850
+
1745
1851
  export const buildStateFromFirestoreV2Documents = (documents = {}, options = {}) => {
1746
1852
  const rootDoc = documents?.root ||
1747
1853
  documents?.settings?.[FIRESTORE_V2_SETTINGS_DOC_ID] ||
1748
1854
  documents?.settings ||
1749
1855
  {};
1856
+ const lifecycleTombstones = splitLifecycleTombstoneDocuments(
1857
+ documents?.lifecycleTombstones
1858
+ );
1750
1859
  const records = {};
1751
1860
  mapValuesArray(documents?.records).forEach((recordDoc) => {
1752
1861
  const recordId = normalizeId(recordDoc?.id);
@@ -1804,8 +1913,14 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1804
1913
  folderOrder: rootDoc.folderOrder || [],
1805
1914
  expandedGroups: rootDoc.expandedGroups || [],
1806
1915
  rtlSyncMeta: rootDoc.rtlSyncMeta || {},
1807
- deletedRecordTombstones: rootDoc.deletedRecordTombstones || {},
1808
- deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
1916
+ deletedRecordTombstones: mergeLifecycleTombstoneSources(
1917
+ rootDoc.deletedRecordTombstones,
1918
+ lifecycleTombstones.records
1919
+ ),
1920
+ deletedFolderTombstones: mergeLifecycleTombstoneSources(
1921
+ rootDoc.deletedFolderTombstones,
1922
+ lifecycleTombstones.folders
1923
+ ),
1809
1924
  trashEntries,
1810
1925
  lastModified: rootDoc.lastModified || 0
1811
1926
  });
@@ -2128,7 +2243,7 @@ export const flushPendingOperations = async ({
2128
2243
  }
2129
2244
 
2130
2245
  const syncTime = resolveNow(now);
2131
- const storageData = await storageAdapter.load(storageKeys);
2246
+ const storageData = (await storageAdapter.load(storageKeys)) || {};
2132
2247
  const latestPendingOps = toArray(storageData.pendingOps);
2133
2248
  const requestedIds = new Set(requestedOperations.map((operation) => operation?.id).filter(Boolean));
2134
2249
  const pendingOpsForPayload = latestPendingOps.filter((operation) => requestedIds.has(operation?.id));
@@ -2606,16 +2721,23 @@ const rtlOperationFolderId = (operation = {}) => normalizeId(
2606
2721
  const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
2607
2722
  operation?.payload?.trashEntryId || operation?.payload?.id
2608
2723
  );
2724
+ const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
2725
+ encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
2726
+ );
2609
2727
  const rtlCloneDocuments = (documents = {}) => ({
2610
2728
  root: clone(documents.root || null),
2611
2729
  records: clone(documents.records || {}),
2612
2730
  folders: clone(documents.folders || {}),
2613
2731
  trash: clone(documents.trash || {}),
2732
+ lifecycleTombstones: clone(documents.lifecycleTombstones || {}),
2614
2733
  ops: {}
2615
2734
  });
2616
2735
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
2617
- const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order)
2618
- .filter((id) => id.startsWith('group-'));
2736
+ /**
2737
+ * Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
2738
+ * so the planner must not drop ids on a prefix guess either.
2739
+ */
2740
+ const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order);
2619
2741
  const rtlPayloadOrder = (operation, keys) => {
2620
2742
  for (const key of keys) {
2621
2743
  if (Array.isArray(operation?.payload?.[key])) return operation.payload[key];
@@ -2795,13 +2917,17 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2795
2917
  const folderIds = new Set();
2796
2918
  const folderDeleteIds = new Set();
2797
2919
  const trashEntryIds = new Set();
2920
+ const lifecycleTombstoneIds = new Set();
2798
2921
  toArray(operations).filter(Boolean).forEach((operation) => {
2799
2922
  const payload = operation.payload || {};
2800
2923
  switch (operation.type) {
2801
2924
  case OPERATION_TYPES.RECORD_CREATE:
2802
2925
  case OPERATION_TYPES.RECORD_MOVE: {
2803
2926
  const recordId = rtlOperationRecordId(operation);
2804
- if (recordId) recordIds.add(recordId);
2927
+ if (recordId) {
2928
+ recordIds.add(recordId);
2929
+ lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
2930
+ }
2805
2931
  folderIds.add(safeFolderId(
2806
2932
  payload.targetFolderId || payload.folderId || payload.record?.folderId
2807
2933
  ));
@@ -2810,7 +2936,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2810
2936
  case OPERATION_TYPES.RECORD_UPDATE:
2811
2937
  case OPERATION_TYPES.RECORD_DELETE: {
2812
2938
  const recordId = rtlOperationRecordId(operation);
2813
- if (recordId) recordIds.add(recordId);
2939
+ if (recordId) {
2940
+ recordIds.add(recordId);
2941
+ lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
2942
+ }
2814
2943
  break;
2815
2944
  }
2816
2945
  case OPERATION_TYPES.RECORD_RESTORE:
@@ -2826,7 +2955,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2826
2955
  case OPERATION_TYPES.FOLDER_UPDATE:
2827
2956
  case OPERATION_TYPES.FOLDER_DELETE: {
2828
2957
  const folderId = rtlOperationFolderId(operation);
2829
- if (folderId) folderIds.add(folderId);
2958
+ if (folderId) {
2959
+ folderIds.add(folderId);
2960
+ lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', folderId));
2961
+ }
2830
2962
  if (folderId && operation.type === OPERATION_TYPES.FOLDER_DELETE) {
2831
2963
  folderDeleteIds.add(folderId);
2832
2964
  }
@@ -2836,7 +2968,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2836
2968
  break;
2837
2969
  }
2838
2970
  });
2839
- return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
2971
+ return {recordIds, folderIds, folderDeleteIds, trashEntryIds, lifecycleTombstoneIds};
2840
2972
  };
2841
2973
 
2842
2974
  export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
@@ -2844,7 +2976,8 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
2844
2976
  recordIds: new Set(readPlan?.recordIds || []),
2845
2977
  folderIds: new Set(readPlan?.folderIds || []),
2846
2978
  folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2847
- trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2979
+ trashEntryIds: new Set(readPlan?.trashEntryIds || []),
2980
+ lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
2848
2981
  };
2849
2982
  Object.values(records || {}).forEach((record) => {
2850
2983
  next.folderIds.add(safeFolderId(record?.folderId));
@@ -2857,18 +2990,40 @@ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntri
2857
2990
  recordIds: new Set(readPlan?.recordIds || []),
2858
2991
  folderIds: new Set(readPlan?.folderIds || []),
2859
2992
  folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2860
- trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2993
+ trashEntryIds: new Set(readPlan?.trashEntryIds || []),
2994
+ lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
2861
2995
  };
2862
2996
  Object.values(trashEntries || {}).forEach((entry) => {
2863
- if (entry?.kind === 'record') next.folderIds.add(safeFolderId(entry.originalFolderId));
2997
+ const entityId = normalizeId(entry?.entityId);
2998
+ if (entry?.kind === 'record') {
2999
+ next.folderIds.add(safeFolderId(entry.originalFolderId));
3000
+ if (entityId) {
3001
+ next.lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', entityId));
3002
+ }
3003
+ } else if (entry?.kind === 'folder' && entityId) {
3004
+ next.lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', entityId));
3005
+ }
2864
3006
  });
2865
3007
  next.folderIds.add(DEFAULT_FOLDER_ID);
2866
3008
  return next;
2867
3009
  };
2868
3010
 
2869
- const rtlApplyOperationToPartialDocuments = ({root, records, folders, trash, operation, now}) => {
3011
+ const rtlApplyOperationToPartialDocuments = ({
3012
+ root,
3013
+ records,
3014
+ folders,
3015
+ trash,
3016
+ lifecycleTombstones,
3017
+ operation,
3018
+ now
3019
+ }) => {
2870
3020
  const {state} = buildStateFromFirestoreV2Documents({
2871
- root: root || {}, records: records || {}, folders: folders || {}, trash: trash || {}, ops: {}
3021
+ root: root || {},
3022
+ records: records || {},
3023
+ folders: folders || {},
3024
+ trash: trash || {},
3025
+ lifecycleTombstones: lifecycleTombstones || {},
3026
+ ops: {}
2872
3027
  });
2873
3028
  const nextState = applyRecordTimeLabelOperation(state, operation);
2874
3029
  return {
@@ -2934,6 +3089,7 @@ export const planFirestoreV2OperationChanges = ({
2934
3089
  const folderId = rtlOperationFolderId(operation);
2935
3090
  let applied = null;
2936
3091
  let reason = null;
3092
+ let lifecycleTombstoneId = null;
2937
3093
 
2938
3094
  switch (operation.type) {
2939
3095
  case OPERATION_TYPES.RECORD_CREATE: {
@@ -2996,25 +3152,28 @@ export const planFirestoreV2OperationChanges = ({
2996
3152
  });
2997
3153
  const recordDocument = applied.documents.records[recordId];
2998
3154
  if (!recordDocument) { reason = 'record_not_found'; break; }
3155
+ const candidateRoot = clone(applied.documents.root);
3156
+ const candidateFolders = clone(nextDocuments.folders);
2999
3157
  const targetFolder = rtlEnsureTargetFolder({
3000
- root: nextDocuments.root,
3001
- folders: nextDocuments.folders,
3158
+ root: candidateRoot,
3159
+ folders: candidateFolders,
3002
3160
  localState: normalizedLocalState,
3003
3161
  folderId: targetFolderId,
3004
3162
  now: operationNow
3005
3163
  });
3006
3164
  if (!targetFolder) { reason = 'folder_not_found'; break; }
3007
- rtlSetRoot(nextDocuments.root, applied.documents.root);
3008
- nextDocuments.records[recordId] = recordDocument;
3009
- if (nextDocuments.folders[sourceFolderId]) {
3010
- nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
3011
- nextDocuments.folders[sourceFolderId].recordOrder
3165
+ if (candidateFolders[sourceFolderId]) {
3166
+ candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
3167
+ candidateFolders[sourceFolderId].recordOrder
3012
3168
  ).filter((id) => id !== recordId);
3013
3169
  }
3014
3170
  targetFolder.recordOrder = [
3015
3171
  ...rtlMergeOrder(targetFolder.recordOrder).filter((id) => id !== recordId),
3016
3172
  recordId
3017
3173
  ];
3174
+ rtlSetRoot(nextDocuments.root, candidateRoot);
3175
+ nextDocuments.folders = candidateFolders;
3176
+ nextDocuments.records[recordId] = recordDocument;
3018
3177
  changed = true;
3019
3178
  break;
3020
3179
  }
@@ -3034,6 +3193,7 @@ export const planFirestoreV2OperationChanges = ({
3034
3193
  }
3035
3194
  case OPERATION_TYPES.RECORD_DELETE: {
3036
3195
  if (!recordId) { reason = 'missing_record_id'; break; }
3196
+ lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
3037
3197
  const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
3038
3198
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3039
3199
  rtlSetRoot(nextDocuments.root, applied.documents.root);
@@ -3062,6 +3222,7 @@ export const planFirestoreV2OperationChanges = ({
3062
3222
  reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
3063
3223
  break;
3064
3224
  }
3225
+ lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
3065
3226
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3066
3227
  const restoredRecord = applied.documents.records[restoredRecordId];
3067
3228
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
@@ -3072,10 +3233,12 @@ export const planFirestoreV2OperationChanges = ({
3072
3233
  rtlSetRoot(nextDocuments.root, applied.documents.root);
3073
3234
  nextDocuments.records[restoredRecordId] = restoredRecord;
3074
3235
  delete nextDocuments.trash[trashEntryId];
3236
+ const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
3237
+ const restoreIndex = Number(trashEntry.originalRecordIndex || 0);
3075
3238
  targetFolder.recordOrder = rtlMergeOrder(
3076
- targetFolder.recordOrder.slice(0, Number(trashEntry.originalRecordIndex || 0)),
3239
+ previousRecordOrder.slice(0, restoreIndex),
3077
3240
  [restoredRecordId],
3078
- targetFolder.recordOrder.slice(Number(trashEntry.originalRecordIndex || 0))
3241
+ previousRecordOrder.slice(restoreIndex)
3079
3242
  );
3080
3243
  changed = true;
3081
3244
  break;
@@ -3139,9 +3302,7 @@ export const planFirestoreV2OperationChanges = ({
3139
3302
  ));
3140
3303
  } else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
3141
3304
  appliedRoot.expandedGroups = rtlMergeOrder(
3142
- rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids']),
3143
- nextDocuments.root.expandedGroups,
3144
- normalizedLocalState.expandedGroups
3305
+ rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
3145
3306
  );
3146
3307
  }
3147
3308
  rtlSetRoot(nextDocuments.root, appliedRoot);
@@ -3151,6 +3312,14 @@ export const planFirestoreV2OperationChanges = ({
3151
3312
  default:
3152
3313
  reason = 'unsupported_operation';
3153
3314
  }
3315
+ if (applied && !reason && lifecycleTombstoneId) {
3316
+ const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
3317
+ if (nextTombstone) {
3318
+ nextDocuments.lifecycleTombstones[lifecycleTombstoneId] = clone(nextTombstone);
3319
+ } else {
3320
+ delete nextDocuments.lifecycleTombstones[lifecycleTombstoneId];
3321
+ }
3322
+ }
3154
3323
  operationResults.push({
3155
3324
  id: operation.id || null,
3156
3325
  type: operation.type || null,
@@ -3184,7 +3353,7 @@ export const planFirestoreV2OperationChanges = ({
3184
3353
  * Counts all billable writes before a transaction reserves quota.
3185
3354
  */
3186
3355
  export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
3187
- const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
3356
+ const collectionWrites = ['records', 'folders', 'trash', 'lifecycleTombstones', 'ops'].reduce((count, key) => (
3188
3357
  count + Object.keys(changes?.[key]?.upserts || {}).length +
3189
3358
  toArray(changes?.[key]?.deleteIds).length
3190
3359
  ), 0);
@@ -3206,6 +3375,15 @@ const rtlStateRecordMap = (state) => {
3206
3375
  return records;
3207
3376
  };
3208
3377
 
3378
+ const rtlStateRecordOrders = (state) => {
3379
+ const orders = new Map();
3380
+ Object.entries(normalizeState(state || {}).records).forEach(([folderId, entries]) => {
3381
+ if (VIRTUAL_FOLDER_IDS.has(folderId)) return;
3382
+ orders.set(folderId, toArray(entries).map(getRecordId).filter(Boolean));
3383
+ });
3384
+ return orders;
3385
+ };
3386
+
3209
3387
  const rtlComparableJson = (value) => JSON.stringify(value || {});
3210
3388
 
3211
3389
  /**
@@ -3224,6 +3402,11 @@ export const buildOperationsFromSnapshotDiff = ({
3224
3402
  const nextRecords = rtlStateRecordMap(next);
3225
3403
  const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
3226
3404
  const nextFolders = new Map(next.folders.map((folder) => [folder.id, folder]));
3405
+ const deletedFolderIds = new Set(
3406
+ [...previousFolders.keys()].filter((folderId) => (
3407
+ !RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)
3408
+ ))
3409
+ );
3227
3410
  const drafts = [];
3228
3411
  const bulkDrafts = [];
3229
3412
 
@@ -3255,18 +3438,29 @@ export const buildOperationsFromSnapshotDiff = ({
3255
3438
  });
3256
3439
 
3257
3440
  previousRecords.forEach((entry, recordId) => {
3258
- if (!nextRecords.has(recordId)) {
3441
+ if (!nextRecords.has(recordId) && !deletedFolderIds.has(entry.folderId)) {
3259
3442
  drafts.push({type: OPERATION_TYPES.RECORD_DELETE, payload: {recordId, deletedAt: now}});
3260
3443
  }
3261
3444
  });
3262
3445
 
3263
- previousFolders.forEach((folder, folderId) => {
3264
- if (!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)) {
3265
- bulkDrafts.push({
3266
- type: OPERATION_TYPES.FOLDER_DELETE,
3267
- payload: {folderId, deletedAt: now}
3268
- });
3269
- }
3446
+ // Create prepends and move appends records, so neither operation can reproduce an arbitrary
3447
+ // snapshot position. Emit the complete target order whenever a folder's order differs; preceding
3448
+ // create/move/delete operations make the planner's fallback order contain only live target ids.
3449
+ const previousRecordOrders = rtlStateRecordOrders(previous);
3450
+ rtlStateRecordOrders(next).forEach((nextOrder, folderId) => {
3451
+ const previousOrder = toArray(previousRecordOrders.get(folderId));
3452
+ if (nextOrder.length === 0 || rtlComparableJson(previousOrder) === rtlComparableJson(nextOrder)) return;
3453
+ drafts.push({
3454
+ type: OPERATION_TYPES.RECORD_REORDER,
3455
+ payload: {folderId, recordIds: nextOrder}
3456
+ });
3457
+ });
3458
+
3459
+ deletedFolderIds.forEach((folderId) => {
3460
+ bulkDrafts.push({
3461
+ type: OPERATION_TYPES.FOLDER_DELETE,
3462
+ payload: {folderId, deletedAt: now}
3463
+ });
3270
3464
  });
3271
3465
 
3272
3466
  if (rtlComparableJson(previous.folderOrder) !== rtlComparableJson(next.folderOrder)) {