@recordtimelabel/core 0.3.2 → 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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +218 -40
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.3.2",
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.2';
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
  });
@@ -843,7 +847,8 @@ export const applyOperation = (state = {}, operation = {}) => {
843
847
  const recordSnapshot = payload.record && typeof payload.record === 'object'
844
848
  ? {...payload.record, id: recordId}
845
849
  : existingEntry?.record;
846
- if (!recordSnapshot && nextState.trashEntries[`record:${recordId}`]) return normalized;
850
+ const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
851
+ if (!recordSnapshot && nextState.trashEntries[trashEntryId]) return normalized;
847
852
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
848
853
  const previousGeneration = Math.max(
849
854
  Number(recordSnapshot?.lifecycleGeneration || 0),
@@ -851,7 +856,6 @@ export const applyOperation = (state = {}, operation = {}) => {
851
856
  );
852
857
  const lifecycleGeneration = previousGeneration + 1;
853
858
  if (recordSnapshot) {
854
- const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
855
859
  nextState.trashEntries[trashEntryId] = {
856
860
  id: trashEntryId,
857
861
  kind: 'record',
@@ -967,6 +971,8 @@ export const applyOperation = (state = {}, operation = {}) => {
967
971
  entityId: folderId,
968
972
  batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
969
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),
970
976
  lifecycleGeneration,
971
977
  deletedAt,
972
978
  purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
@@ -1036,6 +1042,15 @@ export const applyOperation = (state = {}, operation = {}) => {
1036
1042
  );
1037
1043
  folderOrder.splice(restoreIndex, 0, folderId);
1038
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
+ }
1039
1054
  delete nextState.deletedFolderTombstones[folderId];
1040
1055
  nextState.records[folderId].forEach((record) => {
1041
1056
  delete nextState.deletedRecordTombstones[record.id];
@@ -1585,6 +1600,29 @@ const cleanV2TrashDocument = (entry = {}, now) => {
1585
1600
  };
1586
1601
  };
1587
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
+
1588
1626
  export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
1589
1627
  const normalized = normalizeState(state || {});
1590
1628
  const now = resolveNow(options.now);
@@ -1620,6 +1658,11 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1620
1658
  if (trashDocument) trash[trashDocument.id] = trashDocument;
1621
1659
  });
1622
1660
 
1661
+ const lifecycleTombstones = buildV2LifecycleTombstoneDocuments(
1662
+ normalized.deletedRecordTombstones,
1663
+ normalized.deletedFolderTombstones
1664
+ );
1665
+
1623
1666
  const root = {
1624
1667
  id: FIRESTORE_V2_SETTINGS_DOC_ID,
1625
1668
  schemaVersion: 2,
@@ -1643,6 +1686,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1643
1686
  records,
1644
1687
  folders,
1645
1688
  trash,
1689
+ lifecycleTombstones,
1646
1690
  ops
1647
1691
  };
1648
1692
  };
@@ -1739,7 +1783,12 @@ export const buildFirestoreV2DocumentChangeSet = (
1739
1783
  nextDocuments?.trash,
1740
1784
  allowDeletes
1741
1785
  );
1742
- 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) => (
1743
1792
  Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
1744
1793
  ));
1745
1794
 
@@ -1749,6 +1798,7 @@ export const buildFirestoreV2DocumentChangeSet = (
1749
1798
  records,
1750
1799
  folders,
1751
1800
  trash,
1801
+ lifecycleTombstones,
1752
1802
  ops
1753
1803
  };
1754
1804
  };
@@ -1758,11 +1808,54 @@ const removeV2Metadata = (data = {}) => {
1758
1808
  return rest;
1759
1809
  };
1760
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
+
1761
1851
  export const buildStateFromFirestoreV2Documents = (documents = {}, options = {}) => {
1762
1852
  const rootDoc = documents?.root ||
1763
1853
  documents?.settings?.[FIRESTORE_V2_SETTINGS_DOC_ID] ||
1764
1854
  documents?.settings ||
1765
1855
  {};
1856
+ const lifecycleTombstones = splitLifecycleTombstoneDocuments(
1857
+ documents?.lifecycleTombstones
1858
+ );
1766
1859
  const records = {};
1767
1860
  mapValuesArray(documents?.records).forEach((recordDoc) => {
1768
1861
  const recordId = normalizeId(recordDoc?.id);
@@ -1820,8 +1913,14 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1820
1913
  folderOrder: rootDoc.folderOrder || [],
1821
1914
  expandedGroups: rootDoc.expandedGroups || [],
1822
1915
  rtlSyncMeta: rootDoc.rtlSyncMeta || {},
1823
- deletedRecordTombstones: rootDoc.deletedRecordTombstones || {},
1824
- deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
1916
+ deletedRecordTombstones: mergeLifecycleTombstoneSources(
1917
+ rootDoc.deletedRecordTombstones,
1918
+ lifecycleTombstones.records
1919
+ ),
1920
+ deletedFolderTombstones: mergeLifecycleTombstoneSources(
1921
+ rootDoc.deletedFolderTombstones,
1922
+ lifecycleTombstones.folders
1923
+ ),
1825
1924
  trashEntries,
1826
1925
  lastModified: rootDoc.lastModified || 0
1827
1926
  });
@@ -2144,7 +2243,7 @@ export const flushPendingOperations = async ({
2144
2243
  }
2145
2244
 
2146
2245
  const syncTime = resolveNow(now);
2147
- const storageData = await storageAdapter.load(storageKeys);
2246
+ const storageData = (await storageAdapter.load(storageKeys)) || {};
2148
2247
  const latestPendingOps = toArray(storageData.pendingOps);
2149
2248
  const requestedIds = new Set(requestedOperations.map((operation) => operation?.id).filter(Boolean));
2150
2249
  const pendingOpsForPayload = latestPendingOps.filter((operation) => requestedIds.has(operation?.id));
@@ -2622,16 +2721,23 @@ const rtlOperationFolderId = (operation = {}) => normalizeId(
2622
2721
  const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
2623
2722
  operation?.payload?.trashEntryId || operation?.payload?.id
2624
2723
  );
2724
+ const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
2725
+ encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
2726
+ );
2625
2727
  const rtlCloneDocuments = (documents = {}) => ({
2626
2728
  root: clone(documents.root || null),
2627
2729
  records: clone(documents.records || {}),
2628
2730
  folders: clone(documents.folders || {}),
2629
2731
  trash: clone(documents.trash || {}),
2732
+ lifecycleTombstones: clone(documents.lifecycleTombstones || {}),
2630
2733
  ops: {}
2631
2734
  });
2632
2735
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
2633
- const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order)
2634
- .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);
2635
2741
  const rtlPayloadOrder = (operation, keys) => {
2636
2742
  for (const key of keys) {
2637
2743
  if (Array.isArray(operation?.payload?.[key])) return operation.payload[key];
@@ -2811,13 +2917,17 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2811
2917
  const folderIds = new Set();
2812
2918
  const folderDeleteIds = new Set();
2813
2919
  const trashEntryIds = new Set();
2920
+ const lifecycleTombstoneIds = new Set();
2814
2921
  toArray(operations).filter(Boolean).forEach((operation) => {
2815
2922
  const payload = operation.payload || {};
2816
2923
  switch (operation.type) {
2817
2924
  case OPERATION_TYPES.RECORD_CREATE:
2818
2925
  case OPERATION_TYPES.RECORD_MOVE: {
2819
2926
  const recordId = rtlOperationRecordId(operation);
2820
- if (recordId) recordIds.add(recordId);
2927
+ if (recordId) {
2928
+ recordIds.add(recordId);
2929
+ lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
2930
+ }
2821
2931
  folderIds.add(safeFolderId(
2822
2932
  payload.targetFolderId || payload.folderId || payload.record?.folderId
2823
2933
  ));
@@ -2826,7 +2936,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2826
2936
  case OPERATION_TYPES.RECORD_UPDATE:
2827
2937
  case OPERATION_TYPES.RECORD_DELETE: {
2828
2938
  const recordId = rtlOperationRecordId(operation);
2829
- if (recordId) recordIds.add(recordId);
2939
+ if (recordId) {
2940
+ recordIds.add(recordId);
2941
+ lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
2942
+ }
2830
2943
  break;
2831
2944
  }
2832
2945
  case OPERATION_TYPES.RECORD_RESTORE:
@@ -2842,7 +2955,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2842
2955
  case OPERATION_TYPES.FOLDER_UPDATE:
2843
2956
  case OPERATION_TYPES.FOLDER_DELETE: {
2844
2957
  const folderId = rtlOperationFolderId(operation);
2845
- if (folderId) folderIds.add(folderId);
2958
+ if (folderId) {
2959
+ folderIds.add(folderId);
2960
+ lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', folderId));
2961
+ }
2846
2962
  if (folderId && operation.type === OPERATION_TYPES.FOLDER_DELETE) {
2847
2963
  folderDeleteIds.add(folderId);
2848
2964
  }
@@ -2852,7 +2968,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2852
2968
  break;
2853
2969
  }
2854
2970
  });
2855
- return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
2971
+ return {recordIds, folderIds, folderDeleteIds, trashEntryIds, lifecycleTombstoneIds};
2856
2972
  };
2857
2973
 
2858
2974
  export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
@@ -2860,7 +2976,8 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
2860
2976
  recordIds: new Set(readPlan?.recordIds || []),
2861
2977
  folderIds: new Set(readPlan?.folderIds || []),
2862
2978
  folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2863
- trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2979
+ trashEntryIds: new Set(readPlan?.trashEntryIds || []),
2980
+ lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
2864
2981
  };
2865
2982
  Object.values(records || {}).forEach((record) => {
2866
2983
  next.folderIds.add(safeFolderId(record?.folderId));
@@ -2873,18 +2990,40 @@ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntri
2873
2990
  recordIds: new Set(readPlan?.recordIds || []),
2874
2991
  folderIds: new Set(readPlan?.folderIds || []),
2875
2992
  folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2876
- trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2993
+ trashEntryIds: new Set(readPlan?.trashEntryIds || []),
2994
+ lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
2877
2995
  };
2878
2996
  Object.values(trashEntries || {}).forEach((entry) => {
2879
- 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
+ }
2880
3006
  });
2881
3007
  next.folderIds.add(DEFAULT_FOLDER_ID);
2882
3008
  return next;
2883
3009
  };
2884
3010
 
2885
- 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
+ }) => {
2886
3020
  const {state} = buildStateFromFirestoreV2Documents({
2887
- 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: {}
2888
3027
  });
2889
3028
  const nextState = applyRecordTimeLabelOperation(state, operation);
2890
3029
  return {
@@ -2950,6 +3089,7 @@ export const planFirestoreV2OperationChanges = ({
2950
3089
  const folderId = rtlOperationFolderId(operation);
2951
3090
  let applied = null;
2952
3091
  let reason = null;
3092
+ let lifecycleTombstoneId = null;
2953
3093
 
2954
3094
  switch (operation.type) {
2955
3095
  case OPERATION_TYPES.RECORD_CREATE: {
@@ -3012,25 +3152,28 @@ export const planFirestoreV2OperationChanges = ({
3012
3152
  });
3013
3153
  const recordDocument = applied.documents.records[recordId];
3014
3154
  if (!recordDocument) { reason = 'record_not_found'; break; }
3155
+ const candidateRoot = clone(applied.documents.root);
3156
+ const candidateFolders = clone(nextDocuments.folders);
3015
3157
  const targetFolder = rtlEnsureTargetFolder({
3016
- root: nextDocuments.root,
3017
- folders: nextDocuments.folders,
3158
+ root: candidateRoot,
3159
+ folders: candidateFolders,
3018
3160
  localState: normalizedLocalState,
3019
3161
  folderId: targetFolderId,
3020
3162
  now: operationNow
3021
3163
  });
3022
3164
  if (!targetFolder) { reason = 'folder_not_found'; break; }
3023
- rtlSetRoot(nextDocuments.root, applied.documents.root);
3024
- nextDocuments.records[recordId] = recordDocument;
3025
- if (nextDocuments.folders[sourceFolderId]) {
3026
- nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
3027
- nextDocuments.folders[sourceFolderId].recordOrder
3165
+ if (candidateFolders[sourceFolderId]) {
3166
+ candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
3167
+ candidateFolders[sourceFolderId].recordOrder
3028
3168
  ).filter((id) => id !== recordId);
3029
3169
  }
3030
3170
  targetFolder.recordOrder = [
3031
3171
  ...rtlMergeOrder(targetFolder.recordOrder).filter((id) => id !== recordId),
3032
3172
  recordId
3033
3173
  ];
3174
+ rtlSetRoot(nextDocuments.root, candidateRoot);
3175
+ nextDocuments.folders = candidateFolders;
3176
+ nextDocuments.records[recordId] = recordDocument;
3034
3177
  changed = true;
3035
3178
  break;
3036
3179
  }
@@ -3050,6 +3193,7 @@ export const planFirestoreV2OperationChanges = ({
3050
3193
  }
3051
3194
  case OPERATION_TYPES.RECORD_DELETE: {
3052
3195
  if (!recordId) { reason = 'missing_record_id'; break; }
3196
+ lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
3053
3197
  const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
3054
3198
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3055
3199
  rtlSetRoot(nextDocuments.root, applied.documents.root);
@@ -3078,6 +3222,7 @@ export const planFirestoreV2OperationChanges = ({
3078
3222
  reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
3079
3223
  break;
3080
3224
  }
3225
+ lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
3081
3226
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3082
3227
  const restoredRecord = applied.documents.records[restoredRecordId];
3083
3228
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
@@ -3088,10 +3233,12 @@ export const planFirestoreV2OperationChanges = ({
3088
3233
  rtlSetRoot(nextDocuments.root, applied.documents.root);
3089
3234
  nextDocuments.records[restoredRecordId] = restoredRecord;
3090
3235
  delete nextDocuments.trash[trashEntryId];
3236
+ const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
3237
+ const restoreIndex = Number(trashEntry.originalRecordIndex || 0);
3091
3238
  targetFolder.recordOrder = rtlMergeOrder(
3092
- targetFolder.recordOrder.slice(0, Number(trashEntry.originalRecordIndex || 0)),
3239
+ previousRecordOrder.slice(0, restoreIndex),
3093
3240
  [restoredRecordId],
3094
- targetFolder.recordOrder.slice(Number(trashEntry.originalRecordIndex || 0))
3241
+ previousRecordOrder.slice(restoreIndex)
3095
3242
  );
3096
3243
  changed = true;
3097
3244
  break;
@@ -3155,9 +3302,7 @@ export const planFirestoreV2OperationChanges = ({
3155
3302
  ));
3156
3303
  } else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
3157
3304
  appliedRoot.expandedGroups = rtlMergeOrder(
3158
- rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids']),
3159
- nextDocuments.root.expandedGroups,
3160
- normalizedLocalState.expandedGroups
3305
+ rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
3161
3306
  );
3162
3307
  }
3163
3308
  rtlSetRoot(nextDocuments.root, appliedRoot);
@@ -3167,6 +3312,14 @@ export const planFirestoreV2OperationChanges = ({
3167
3312
  default:
3168
3313
  reason = 'unsupported_operation';
3169
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
+ }
3170
3323
  operationResults.push({
3171
3324
  id: operation.id || null,
3172
3325
  type: operation.type || null,
@@ -3200,7 +3353,7 @@ export const planFirestoreV2OperationChanges = ({
3200
3353
  * Counts all billable writes before a transaction reserves quota.
3201
3354
  */
3202
3355
  export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
3203
- const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
3356
+ const collectionWrites = ['records', 'folders', 'trash', 'lifecycleTombstones', 'ops'].reduce((count, key) => (
3204
3357
  count + Object.keys(changes?.[key]?.upserts || {}).length +
3205
3358
  toArray(changes?.[key]?.deleteIds).length
3206
3359
  ), 0);
@@ -3222,6 +3375,15 @@ const rtlStateRecordMap = (state) => {
3222
3375
  return records;
3223
3376
  };
3224
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
+
3225
3387
  const rtlComparableJson = (value) => JSON.stringify(value || {});
3226
3388
 
3227
3389
  /**
@@ -3240,6 +3402,11 @@ export const buildOperationsFromSnapshotDiff = ({
3240
3402
  const nextRecords = rtlStateRecordMap(next);
3241
3403
  const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
3242
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
+ );
3243
3410
  const drafts = [];
3244
3411
  const bulkDrafts = [];
3245
3412
 
@@ -3271,18 +3438,29 @@ export const buildOperationsFromSnapshotDiff = ({
3271
3438
  });
3272
3439
 
3273
3440
  previousRecords.forEach((entry, recordId) => {
3274
- if (!nextRecords.has(recordId)) {
3441
+ if (!nextRecords.has(recordId) && !deletedFolderIds.has(entry.folderId)) {
3275
3442
  drafts.push({type: OPERATION_TYPES.RECORD_DELETE, payload: {recordId, deletedAt: now}});
3276
3443
  }
3277
3444
  });
3278
3445
 
3279
- previousFolders.forEach((folder, folderId) => {
3280
- if (!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)) {
3281
- bulkDrafts.push({
3282
- type: OPERATION_TYPES.FOLDER_DELETE,
3283
- payload: {folderId, deletedAt: now}
3284
- });
3285
- }
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
+ });
3286
3464
  });
3287
3465
 
3288
3466
  if (rtlComparableJson(previous.folderOrder) !== rtlComparableJson(next.folderOrder)) {