@recordtimelabel/core 0.2.1 → 0.3.1

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 +13 -1
  2. package/package.json +1 -1
  3. package/src/index.js +508 -44
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.2.1"
23
+ "@recordtimelabel/core": "0.3.1"
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.
@@ -48,6 +48,7 @@ If this checkout's `package.json` is ahead of the published version, publish the
48
48
  - `validateRecordTimeLabelOperationBatch(body)`
49
49
  - `buildFirestoreV2OperationReadPlan(operations)`
50
50
  - `extendFirestoreV2OperationReadPlanWithRecords(readPlan, records)`
51
+ - `extendFirestoreV2OperationReadPlanWithTrash(readPlan, trashEntries)`
51
52
  - `planFirestoreV2OperationChanges({ documents, operations, localState, now })`
52
53
  - `estimateFirestoreV2WriteUnits(changes, overhead)`
53
54
  - `buildOperationsFromSnapshotDiff({ previousState, nextState, now, operationIdPrefix, batchSize })`(一般操作在 `batches`,`folder.delete` 在 `bulkOperations`)
@@ -59,6 +60,8 @@ If this checkout's `package.json` is ahead of the published version, publish the
59
60
  - `RECORD_TIMELABEL_SYNC_MODES`
60
61
  - `createOperation(type, payload, options)`
61
62
  - `OPERATION_TYPES`
63
+ - `getActiveTrashEntries(entries, now)`
64
+ - `RTL_TRASH_RETENTION_MS`
62
65
 
63
66
  ## Firestore v1 Compatibility
64
67
 
@@ -76,9 +79,16 @@ The core also normalizes and preserves these compatibility metadata fields:
76
79
  - `rtlSyncMeta`
77
80
  - `deletedRecordTombstones`
78
81
  - `deletedFolderTombstones`
82
+ - `trashEntries`
79
83
 
80
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.
81
85
 
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.
91
+
82
92
  ## Firestore v2 Gateway Contract
83
93
 
84
94
  The package exposes platform-neutral v2 document helpers so app adapters can share the same record/folder/settings/ops shape before wiring Firebase SDK or REST calls:
@@ -86,6 +96,8 @@ The package exposes platform-neutral v2 document helpers so app adapters can sha
86
96
  - root document `users/{uid}/recordTimeLabel/main`: settings, order arrays, tombstones, and sync metadata
87
97
  - `users/{uid}/recordTimeLabel/main/records/{recordId}`: flattened per-record documents with `folderId`
88
98
  - `users/{uid}/recordTimeLabel/main/folders/{folderId}`: per-folder documents
99
+ - `users/{uid}/recordTimeLabel/main/trash/{trashEntryId}`: 30-day record/folder snapshots
100
+ - `users/{uid}/recordTimeLabel/main/lifecycleTombstones/{id}`: content-free anti-revival markers
89
101
  - `users/{uid}/recordTimeLabel/main/ops/{opId}`: pending operation documents
90
102
 
91
103
  The v2 validator, bounded read planner, pure mutation planner, cost estimator, and snapshot-diff batch builder are also platform-neutral. They do not import Firebase and do not perform network writes. Functions and clients must use this package instead of maintaining app-specific planner copies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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,25 +5,32 @@ const REQUIRED_FOLDERS = [
5
5
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
6
6
  ];
7
7
 
8
- export const RECORD_TIMELABEL_CORE_VERSION = '0.2.1';
9
- export const RTL_SYNC_PROTOCOL_VERSION = 1;
8
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.3.1';
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;
12
12
  export const RTL_MAX_TARGET_WRITES = 100;
13
+ export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
13
14
 
14
15
  export const OPERATION_TYPES = Object.freeze({
15
16
  RECORD_CREATE: 'record.create',
16
17
  RECORD_UPDATE: 'record.update',
17
18
  RECORD_MOVE: 'record.move',
18
19
  RECORD_DELETE: 'record.delete',
20
+ RECORD_RESTORE: 'record.restore',
19
21
  RECORD_REORDER: 'record.reorder',
20
22
  FOLDER_CREATE: 'folder.create',
21
23
  FOLDER_UPDATE: 'folder.update',
22
24
  FOLDER_DELETE: 'folder.delete',
25
+ FOLDER_RESTORE: 'folder.restore',
23
26
  FOLDER_REORDER: 'folder.reorder',
24
27
  GROUP_REORDER: 'group.reorder',
25
28
  EXPANDED_GROUPS_UPDATE: 'expandedGroups.update',
26
- SETTINGS_UPDATE: 'settings.update'
29
+ SETTINGS_UPDATE: 'settings.update',
30
+ TRASH_PURGE: 'trash.purge',
31
+ TRASH_PURGE_BATCH: 'trash.purgeBatch',
32
+ TRASH_RESTORE_BATCH: 'trash.restoreBatch',
33
+ TRASH_EMPTY: 'trash.empty'
27
34
  });
28
35
 
29
36
  const ORDER_OPERATION_TYPES = new Set([
@@ -63,6 +70,14 @@ const clone = (value, seen = new WeakMap()) => {
63
70
  };
64
71
 
65
72
  const toFiniteTimestamp = (value) => {
73
+ if (value && typeof value.toMillis === 'function') {
74
+ const millis = Number(value.toMillis());
75
+ return Number.isFinite(millis) ? millis : 0;
76
+ }
77
+ if (value && Number.isFinite(Number(value.seconds))) {
78
+ return (Number(value.seconds) * 1000) +
79
+ Math.floor(Number(value.nanoseconds || 0) / 1e6);
80
+ }
66
81
  if (typeof value === 'number') {
67
82
  return Number.isFinite(value) ? value : 0;
68
83
  }
@@ -168,6 +183,39 @@ const normalizeTombstones = (value) => {
168
183
  }, {});
169
184
  };
170
185
 
186
+ const normalizeTrashEntries = (value) => {
187
+ if (!value) return {};
188
+ const entries = Array.isArray(value)
189
+ ? value.map((entry) => [entry?.id, entry])
190
+ : Object.entries(value);
191
+
192
+ return entries.reduce((result, [rawId, rawEntry]) => {
193
+ if (!rawEntry || typeof rawEntry !== 'object') return result;
194
+ const id = normalizeId(rawId || rawEntry.id);
195
+ const kind = normalizeId(rawEntry.kind);
196
+ const entityId = normalizeId(rawEntry.entityId);
197
+ if (!id || !entityId || !new Set(['record', 'folder']).has(kind)) return result;
198
+ const deletedAt = toFiniteTimestamp(rawEntry.deletedAt);
199
+ const purgeAt = toFiniteTimestamp(rawEntry.purgeAt) ||
200
+ ((deletedAt || Date.now()) + RTL_TRASH_RETENTION_MS);
201
+ result[id] = {
202
+ ...clone(rawEntry),
203
+ id,
204
+ kind,
205
+ entityId,
206
+ batchId: normalizeId(rawEntry.batchId) || id,
207
+ lifecycleGeneration: Math.max(1, Number(rawEntry.lifecycleGeneration || 1)),
208
+ deletedAt: deletedAt || Date.now(),
209
+ purgeAt
210
+ };
211
+ return result;
212
+ }, {});
213
+ };
214
+
215
+ export const getActiveTrashEntries = (value, now = Date.now()) => Object.values(
216
+ normalizeTrashEntries(value)
217
+ ).filter((entry) => toFiniteTimestamp(entry.purgeAt) > toFiniteTimestamp(now));
218
+
171
219
  const mergeTombstones = (left = {}, right = {}) => {
172
220
  const merged = { ...left };
173
221
  Object.entries(right).forEach(([id, tombstone]) => {
@@ -179,6 +227,80 @@ const mergeTombstones = (left = {}, right = {}) => {
179
227
  return merged;
180
228
  };
181
229
 
230
+ const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
231
+ const merged = normalizeTrashEntries(remoteEntries);
232
+ Object.entries(normalizeTrashEntries(localEntries)).forEach(([id, entry]) => {
233
+ const current = merged[id];
234
+ const currentVersion = Number(current?.lifecycleGeneration || 0);
235
+ const nextVersion = Number(entry.lifecycleGeneration || 0);
236
+ if (!current || nextVersion > currentVersion ||
237
+ (nextVersion === currentVersion && toFiniteTimestamp(entry.deletedAt) > toFiniteTimestamp(current.deletedAt))) {
238
+ merged[id] = entry;
239
+ }
240
+ });
241
+ return merged;
242
+ };
243
+
244
+ const collectActiveLifecycleGenerations = (...states) => {
245
+ const records = new Map();
246
+ const folders = new Map();
247
+ const track = (target, id, entity) => {
248
+ if (!id) return;
249
+ const generation = Number(entity?.lifecycleGeneration || 0);
250
+ target.set(id, Math.max(generation, target.get(id) || 0));
251
+ };
252
+ states.forEach((state) => {
253
+ Object.entries(state?.records || {}).forEach(([folderId, folderRecords]) => {
254
+ if (VIRTUAL_FOLDER_IDS.has(normalizeId(folderId))) return;
255
+ toArray(folderRecords).forEach((record) => track(records, getRecordId(record), record));
256
+ });
257
+ toArray(state?.folders).forEach((folder) => track(folders, normalizeId(folder?.id), folder));
258
+ });
259
+ return { records, folders };
260
+ };
261
+
262
+ /**
263
+ * 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.
268
+ */
269
+ const dropSupersededLifecycleDeletions = ({
270
+ deletedRecordTombstones = {},
271
+ deletedFolderTombstones = {},
272
+ trashEntries = {},
273
+ activeGenerations
274
+ }) => {
275
+ const isSuperseded = (target, id, deletion) => (
276
+ (target.get(normalizeId(id)) || 0) > Number(deletion?.lifecycleGeneration || 0)
277
+ );
278
+ const nextRecordTombstones = { ...deletedRecordTombstones };
279
+ Object.entries(nextRecordTombstones).forEach(([recordId, tombstone]) => {
280
+ if (isSuperseded(activeGenerations.records, recordId, tombstone)) {
281
+ delete nextRecordTombstones[recordId];
282
+ }
283
+ });
284
+ const nextFolderTombstones = { ...deletedFolderTombstones };
285
+ Object.entries(nextFolderTombstones).forEach(([folderId, tombstone]) => {
286
+ if (isSuperseded(activeGenerations.folders, folderId, tombstone)) {
287
+ delete nextFolderTombstones[folderId];
288
+ }
289
+ });
290
+ const nextTrashEntries = { ...trashEntries };
291
+ Object.values(nextTrashEntries).forEach((entry) => {
292
+ const target = entry?.kind === 'folder' ? activeGenerations.folders : activeGenerations.records;
293
+ if (isSuperseded(target, entry?.entityId, entry)) {
294
+ delete nextTrashEntries[entry.id];
295
+ }
296
+ });
297
+ return {
298
+ deletedRecordTombstones: nextRecordTombstones,
299
+ deletedFolderTombstones: nextFolderTombstones,
300
+ trashEntries: nextTrashEntries
301
+ };
302
+ };
303
+
182
304
  const resolveRecordFolderId = (folderId, record = {}) => {
183
305
  const candidates = [
184
306
  record.folderId,
@@ -256,13 +378,9 @@ export const normalizeRecords = (records = {}, options = {}) => {
256
378
  delete normalizedRecord.sortIndex;
257
379
 
258
380
  const tombstone = tombstones[id];
259
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
260
- return;
261
- }
381
+ if (tombstone) return;
262
382
  const folderTombstone = folderTombstones[targetFolderId];
263
- if (folderTombstone && toFiniteTimestamp(folderTombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
264
- return;
265
- }
383
+ if (folderTombstone) return;
266
384
 
267
385
  if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
268
386
 
@@ -363,9 +481,7 @@ const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombst
363
481
  if (!id) return;
364
482
 
365
483
  const tombstone = deletedFolderTombstones[id];
366
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
367
- return;
368
- }
484
+ if (tombstone) return;
369
485
 
370
486
  const current = byId.get(id);
371
487
  if (!current) {
@@ -401,9 +517,7 @@ export const normalizeState = (input = {}) => {
401
517
  const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
402
518
  const folders = ensureFolders(input.folders).filter((folder) => {
403
519
  const tombstone = deletedFolderTombstones[folder.id];
404
- if (!tombstone) return true;
405
- const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
406
- return folderTime > toFiniteTimestamp(tombstone.deletedAt);
520
+ return !tombstone;
407
521
  });
408
522
  const records = ensureRecordFolders(
409
523
  normalizeRecords(input.records, { deletedRecordTombstones, deletedFolderTombstones }),
@@ -423,6 +537,7 @@ export const normalizeState = (input = {}) => {
423
537
  },
424
538
  deletedRecordTombstones,
425
539
  deletedFolderTombstones,
540
+ trashEntries: normalizeTrashEntries(input.trashEntries),
426
541
  lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
427
542
  };
428
543
  };
@@ -445,6 +560,7 @@ export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {})
445
560
  Object.keys(state.settings || {}).length > 0 ||
446
561
  Object.keys(state.deletedRecordTombstones || {}).length > 0 ||
447
562
  Object.keys(state.deletedFolderTombstones || {}).length > 0 ||
563
+ Object.keys(state.trashEntries || {}).length > 0 ||
448
564
  hasNonDefaultGroupOrder ||
449
565
  state.folderOrder.length > 0 ||
450
566
  state.expandedGroups.length > 0;
@@ -460,7 +576,8 @@ export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
460
576
  folderOrder: state.folderOrder,
461
577
  expandedGroups: state.expandedGroups,
462
578
  deletedRecordTombstones: state.deletedRecordTombstones,
463
- deletedFolderTombstones: state.deletedFolderTombstones
579
+ deletedFolderTombstones: state.deletedFolderTombstones,
580
+ trashEntries: state.trashEntries
464
581
  });
465
582
  };
466
583
 
@@ -601,10 +718,7 @@ export const applyOperation = (state = {}, operation = {}) => {
601
718
  const recordId = normalizeId(record.id || payload.recordId);
602
719
  if (!recordId) return normalized;
603
720
  const tombstone = nextState.deletedRecordTombstones[recordId];
604
- const recordTime = getRecordTime(record) || operationTime;
605
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
606
- return normalized;
607
- }
721
+ if (tombstone) return normalized;
608
722
 
609
723
  const folderId = safeFolderId(payload.folderId || record.folderId);
610
724
  const cleanedRecord = {
@@ -709,20 +823,85 @@ export const applyOperation = (state = {}, operation = {}) => {
709
823
  case OPERATION_TYPES.RECORD_DELETE: {
710
824
  const recordId = normalizeId(payload.recordId || payload.id);
711
825
  if (!recordId) return normalized;
826
+ const existingEntry = findRecordEntry(nextState.records, recordId);
827
+ const recordSnapshot = payload.record && typeof payload.record === 'object'
828
+ ? {...payload.record, id: recordId}
829
+ : existingEntry?.record;
830
+ if (!recordSnapshot && nextState.trashEntries[`record:${recordId}`]) return normalized;
831
+ const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
832
+ const previousGeneration = Math.max(
833
+ Number(recordSnapshot?.lifecycleGeneration || 0),
834
+ Number(nextState.deletedRecordTombstones[recordId]?.lifecycleGeneration || 0)
835
+ );
836
+ const lifecycleGeneration = previousGeneration + 1;
837
+ if (recordSnapshot) {
838
+ const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
839
+ nextState.trashEntries[trashEntryId] = {
840
+ id: trashEntryId,
841
+ kind: 'record',
842
+ entityId: recordId,
843
+ batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
844
+ originalFolderId: safeFolderId(payload.folderId || existingEntry?.folderId),
845
+ originalRecordIndex: Number.isInteger(payload.originalRecordIndex)
846
+ ? payload.originalRecordIndex
847
+ : Math.max(0, Number(existingEntry?.index || 0)),
848
+ lifecycleGeneration,
849
+ deletedAt,
850
+ purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
851
+ payload: {record: {...recordSnapshot, lifecycleGeneration}}
852
+ };
853
+ }
712
854
  nextState.records = removeRecordById(nextState.records, recordId);
713
855
  nextState.deletedRecordTombstones[recordId] = {
714
856
  id: recordId,
715
- deletedAt: payload.deletedAt || operationTime,
857
+ deletedAt,
858
+ lifecycleGeneration,
716
859
  clientId: operation.clientId || payload.clientId || null,
717
860
  operationId: operation.id || null
718
861
  };
719
862
  break;
720
863
  }
721
864
 
865
+ case OPERATION_TYPES.RECORD_RESTORE: {
866
+ const trashEntryId = normalizeId(payload.trashEntryId) ||
867
+ `record:${normalizeId(payload.recordId || payload.id)}`;
868
+ const trashEntry = nextState.trashEntries[trashEntryId];
869
+ if (!trashEntry || trashEntry.kind !== 'record') return normalized;
870
+ if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
871
+ if (payload.expectedGeneration &&
872
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
873
+ const recordId = normalizeId(trashEntry.entityId);
874
+ if (!recordId || findRecordEntry(nextState.records, recordId)) return normalized;
875
+ const recordSnapshot = trashEntry.payload?.record;
876
+ if (!recordSnapshot) return normalized;
877
+ const originalFolderId = safeFolderId(trashEntry.originalFolderId);
878
+ const targetFolderId = nextState.folders.some((folder) => folder.id === originalFolderId)
879
+ ? originalFolderId
880
+ : DEFAULT_FOLDER_ID;
881
+ const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
882
+ const restoredRecord = {
883
+ ...recordSnapshot,
884
+ id: recordId,
885
+ lifecycleGeneration,
886
+ updatedAt: operationTime
887
+ };
888
+ const targetRecords = [...toArray(nextState.records[targetFolderId])];
889
+ const restoreIndex = Math.min(
890
+ Math.max(0, Number(trashEntry.originalRecordIndex || 0)),
891
+ targetRecords.length
892
+ );
893
+ targetRecords.splice(restoreIndex, 0, restoredRecord);
894
+ nextState.records[targetFolderId] = targetRecords;
895
+ delete nextState.trashEntries[trashEntryId];
896
+ delete nextState.deletedRecordTombstones[recordId];
897
+ break;
898
+ }
899
+
722
900
  case OPERATION_TYPES.FOLDER_CREATE: {
723
901
  const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
724
902
  const folderId = normalizeId(folder?.id || payload.folderId);
725
903
  if (!folderId) return normalized;
904
+ if (nextState.deletedFolderTombstones[folderId]) return normalized;
726
905
  const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
727
906
  const nextFolder = {
728
907
  ...(folder || {}),
@@ -753,16 +932,146 @@ export const applyOperation = (state = {}, operation = {}) => {
753
932
  case OPERATION_TYPES.FOLDER_DELETE: {
754
933
  const folderId = normalizeId(payload.folderId || payload.id);
755
934
  if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
935
+ const folderSnapshot = payload.folder && typeof payload.folder === 'object'
936
+ ? {...payload.folder, id: folderId}
937
+ : nextState.folders.find((folder) => folder.id === folderId);
938
+ const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
939
+ if (!folderSnapshot && nextState.trashEntries[`folder:${folderId}`]) return normalized;
940
+ const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
941
+ const previousGeneration = Math.max(
942
+ Number(folderSnapshot?.lifecycleGeneration || 0),
943
+ Number(nextState.deletedFolderTombstones[folderId]?.lifecycleGeneration || 0)
944
+ );
945
+ const lifecycleGeneration = previousGeneration + 1;
946
+ if (folderSnapshot) {
947
+ const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
948
+ nextState.trashEntries[trashEntryId] = {
949
+ id: trashEntryId,
950
+ kind: 'folder',
951
+ entityId: folderId,
952
+ batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
953
+ originalFolderOrderIndex: Math.max(0, nextState.folderOrder.indexOf(folderId)),
954
+ lifecycleGeneration,
955
+ deletedAt,
956
+ purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
957
+ payload: {
958
+ folder: {...folderSnapshot, lifecycleGeneration},
959
+ records: folderRecords.map((record) => ({
960
+ ...record,
961
+ lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1
962
+ }))
963
+ }
964
+ };
965
+ }
756
966
  nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
757
967
  delete nextState.records[folderId];
758
968
  nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
759
969
  nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
760
970
  nextState.deletedFolderTombstones[folderId] = {
761
971
  id: folderId,
762
- deletedAt: payload.deletedAt || operationTime,
972
+ deletedAt,
973
+ lifecycleGeneration,
763
974
  clientId: operation.clientId || payload.clientId || null,
764
975
  operationId: operation.id || null
765
976
  };
977
+ folderRecords.forEach((record) => {
978
+ const recordId = getRecordId(record);
979
+ if (!recordId) return;
980
+ nextState.deletedRecordTombstones[recordId] = {
981
+ id: recordId,
982
+ folderId,
983
+ deletedAt,
984
+ lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1,
985
+ clientId: operation.clientId || payload.clientId || null,
986
+ operationId: operation.id || null
987
+ };
988
+ });
989
+ break;
990
+ }
991
+
992
+ case OPERATION_TYPES.FOLDER_RESTORE: {
993
+ const trashEntryId = normalizeId(payload.trashEntryId) ||
994
+ `folder:${normalizeId(payload.folderId || payload.id)}`;
995
+ const trashEntry = nextState.trashEntries[trashEntryId];
996
+ if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
997
+ if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
998
+ if (payload.expectedGeneration &&
999
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
1000
+ const folderId = normalizeId(trashEntry.entityId);
1001
+ if (!folderId || nextState.folders.some((folder) => folder.id === folderId)) return normalized;
1002
+ const folderSnapshot = trashEntry.payload?.folder;
1003
+ if (!folderSnapshot) return normalized;
1004
+ const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
1005
+ nextState.folders.push({
1006
+ ...folderSnapshot,
1007
+ id: folderId,
1008
+ lifecycleGeneration,
1009
+ updatedAt: operationTime
1010
+ });
1011
+ nextState.records[folderId] = toArray(trashEntry.payload?.records).map((record) => ({
1012
+ ...record,
1013
+ lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1,
1014
+ updatedAt: operationTime
1015
+ }));
1016
+ const folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
1017
+ const restoreIndex = Math.min(
1018
+ Math.max(0, Number(trashEntry.originalFolderOrderIndex || 0)),
1019
+ folderOrder.length
1020
+ );
1021
+ folderOrder.splice(restoreIndex, 0, folderId);
1022
+ nextState.folderOrder = folderOrder;
1023
+ delete nextState.deletedFolderTombstones[folderId];
1024
+ nextState.records[folderId].forEach((record) => {
1025
+ delete nextState.deletedRecordTombstones[record.id];
1026
+ });
1027
+ delete nextState.trashEntries[trashEntryId];
1028
+ break;
1029
+ }
1030
+
1031
+ case OPERATION_TYPES.TRASH_PURGE: {
1032
+ const trashEntryId = normalizeId(payload.trashEntryId || payload.id);
1033
+ const trashEntry = nextState.trashEntries[trashEntryId];
1034
+ if (!trashEntry) return normalized;
1035
+ if (payload.expectedGeneration &&
1036
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
1037
+ delete nextState.trashEntries[trashEntryId];
1038
+ break;
1039
+ }
1040
+
1041
+ case OPERATION_TYPES.TRASH_RESTORE_BATCH: {
1042
+ const batchId = normalizeId(payload.batchId);
1043
+ if (!batchId) return normalized;
1044
+ let restoredState = nextState;
1045
+ Object.values(nextState.trashEntries)
1046
+ .filter((entry) => entry.batchId === batchId)
1047
+ .sort((left, right) => left.kind === 'folder' ? -1 : right.kind === 'folder' ? 1 : 0)
1048
+ .forEach((entry, index) => {
1049
+ restoredState = applyOperation(restoredState, {
1050
+ ...operation,
1051
+ id: `${operation.id || 'trash.restoreBatch'}:${index}`,
1052
+ type: entry.kind === 'folder'
1053
+ ? OPERATION_TYPES.FOLDER_RESTORE
1054
+ : OPERATION_TYPES.RECORD_RESTORE,
1055
+ payload: {trashEntryId: entry.id, expectedGeneration: entry.lifecycleGeneration}
1056
+ });
1057
+ });
1058
+ return restoredState;
1059
+ }
1060
+
1061
+ case OPERATION_TYPES.TRASH_PURGE_BATCH: {
1062
+ const batchId = normalizeId(payload.batchId);
1063
+ if (!batchId) return normalized;
1064
+ Object.values(nextState.trashEntries).forEach((entry) => {
1065
+ if (entry.batchId === batchId) delete nextState.trashEntries[entry.id];
1066
+ });
1067
+ break;
1068
+ }
1069
+
1070
+ case OPERATION_TYPES.TRASH_EMPTY: {
1071
+ const cutoffAt = toFiniteTimestamp(payload.cutoffAt) || operationTime;
1072
+ Object.values(nextState.trashEntries).forEach((entry) => {
1073
+ if (toFiniteTimestamp(entry.deletedAt) <= cutoffAt) delete nextState.trashEntries[entry.id];
1074
+ });
766
1075
  break;
767
1076
  }
768
1077
 
@@ -811,14 +1120,22 @@ export const mergeLocalRemote = ({
811
1120
  } = {}) => {
812
1121
  const local = normalizeState(localState);
813
1122
  const remote = normalizeState(remoteState);
814
- const deletedRecordTombstones = mergeTombstones(
815
- remote.deletedRecordTombstones,
816
- local.deletedRecordTombstones
817
- );
818
- const deletedFolderTombstones = mergeTombstones(
819
- remote.deletedFolderTombstones,
820
- local.deletedFolderTombstones
821
- );
1123
+ const {
1124
+ deletedRecordTombstones,
1125
+ deletedFolderTombstones,
1126
+ trashEntries
1127
+ } = dropSupersededLifecycleDeletions({
1128
+ deletedRecordTombstones: mergeTombstones(
1129
+ remote.deletedRecordTombstones,
1130
+ local.deletedRecordTombstones
1131
+ ),
1132
+ deletedFolderTombstones: mergeTombstones(
1133
+ remote.deletedFolderTombstones,
1134
+ local.deletedFolderTombstones
1135
+ ),
1136
+ trashEntries: mergeTrashEntries(remote.trashEntries, local.trashEntries),
1137
+ activeGenerations: collectActiveLifecycleGenerations(remote, local)
1138
+ });
822
1139
  const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
823
1140
  const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
824
1141
 
@@ -832,6 +1149,7 @@ export const mergeLocalRemote = ({
832
1149
  settings: { ...remote.settings, ...local.settings },
833
1150
  deletedRecordTombstones,
834
1151
  deletedFolderTombstones,
1152
+ trashEntries,
835
1153
  rtlSyncMeta: {
836
1154
  ...remote.rtlSyncMeta,
837
1155
  ...local.rtlSyncMeta,
@@ -984,6 +1302,7 @@ export const buildLocalStateFromStorage = (data = {}, options = {}) => normalize
984
1302
  rtlSyncMeta: data.rtlSyncMeta || {},
985
1303
  deletedRecordTombstones: data.deletedRecordTombstones || {},
986
1304
  deletedFolderTombstones: data.deletedFolderTombstones || {},
1305
+ trashEntries: data.trashEntries || {},
987
1306
  lastModified: data.lastModified || data.updatedAt || 0
988
1307
  });
989
1308
 
@@ -1002,6 +1321,7 @@ export const buildStoragePatchFromState = (state = {}, options = {}) => {
1002
1321
  rtlSyncMeta: normalized.rtlSyncMeta,
1003
1322
  deletedRecordTombstones: normalized.deletedRecordTombstones,
1004
1323
  deletedFolderTombstones: normalized.deletedFolderTombstones,
1324
+ trashEntries: normalized.trashEntries,
1005
1325
  lastModified: normalized.lastModified || resolveNow(options.now)
1006
1326
  };
1007
1327
 
@@ -1030,6 +1350,7 @@ export const buildFirestoreV1UserPatch = (state = {}, options = {}) => {
1030
1350
  },
1031
1351
  deletedRecordTombstones: normalized.deletedRecordTombstones || {},
1032
1352
  deletedFolderTombstones: normalized.deletedFolderTombstones || {},
1353
+ trashEntries: normalized.trashEntries || {},
1033
1354
  lastModified: normalized.lastModified || resolveNow(options.now)
1034
1355
  };
1035
1356
  };
@@ -1041,6 +1362,8 @@ export const buildFirestoreV2LogicalPaths = (userId = '{uid}') => {
1041
1362
  root,
1042
1363
  records: `${root}/records`,
1043
1364
  folders: `${root}/folders`,
1365
+ trash: `${root}/trash`,
1366
+ lifecycleTombstones: `${root}/lifecycleTombstones`,
1044
1367
  settings: root,
1045
1368
  ops: `${root}/ops`
1046
1369
  };
@@ -1234,6 +1557,18 @@ const cleanV2OperationDocument = (operation = {}, now) => {
1234
1557
  };
1235
1558
  };
1236
1559
 
1560
+ const cleanV2TrashDocument = (entry = {}, now) => {
1561
+ const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
1562
+ const trashEntry = Object.values(normalized)[0];
1563
+ if (!trashEntry) return null;
1564
+ return {
1565
+ ...trashEntry,
1566
+ deletedAt: trashEntry.deletedAt || now,
1567
+ purgeAt: trashEntry.purgeAt || now + RTL_TRASH_RETENTION_MS,
1568
+ schemaVersion: 2
1569
+ };
1570
+ };
1571
+
1237
1572
  export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
1238
1573
  const normalized = normalizeState(state || {});
1239
1574
  const now = resolveNow(options.now);
@@ -1263,6 +1598,12 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1263
1598
  if (operationDoc) ops[operationDoc.id] = operationDoc;
1264
1599
  });
1265
1600
 
1601
+ const trash = {};
1602
+ Object.values(normalized.trashEntries || {}).forEach((entry) => {
1603
+ const trashDocument = cleanV2TrashDocument(entry, now);
1604
+ if (trashDocument) trash[trashDocument.id] = trashDocument;
1605
+ });
1606
+
1266
1607
  const root = {
1267
1608
  id: FIRESTORE_V2_SETTINGS_DOC_ID,
1268
1609
  schemaVersion: 2,
@@ -1285,6 +1626,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1285
1626
  root,
1286
1627
  records,
1287
1628
  folders,
1629
+ trash,
1288
1630
  ops
1289
1631
  };
1290
1632
  };
@@ -1376,7 +1718,12 @@ export const buildFirestoreV2DocumentChangeSet = (
1376
1718
  nextDocuments?.ops,
1377
1719
  allowDeletes
1378
1720
  );
1379
- const hasCollectionChanges = [records, folders, ops].some((changeSet) => (
1721
+ const trash = buildFirestoreV2CollectionChangeSet(
1722
+ previousDocuments?.trash,
1723
+ nextDocuments?.trash,
1724
+ allowDeletes
1725
+ );
1726
+ const hasCollectionChanges = [records, folders, trash, ops].some((changeSet) => (
1380
1727
  Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
1381
1728
  ));
1382
1729
 
@@ -1385,6 +1732,7 @@ export const buildFirestoreV2DocumentChangeSet = (
1385
1732
  root: { upsert: rootUpsert },
1386
1733
  records,
1387
1734
  folders,
1735
+ trash,
1388
1736
  ops
1389
1737
  };
1390
1738
  };
@@ -1429,6 +1777,25 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1429
1777
  })
1430
1778
  .filter(Boolean);
1431
1779
 
1780
+ const trashEntries = normalizeTrashEntries(Object.fromEntries(
1781
+ mapValuesArray(documents?.trash)
1782
+ .map((entry) => [entry?.id, removeV2Metadata(entry)])
1783
+ .filter(([id]) => normalizeId(id))
1784
+ ));
1785
+ Object.values(trashEntries).forEach((entry) => {
1786
+ const parentEntryId = normalizeId(entry.parentEntryId);
1787
+ const parent = parentEntryId ? trashEntries[parentEntryId] : null;
1788
+ const record = entry.hidden === true ? entry.payload?.record : null;
1789
+ if (!parent || parent.kind !== 'folder' || !record) return;
1790
+ const existingRecords = toArray(parent.payload?.records);
1791
+ if (existingRecords.some((candidate) => getRecordId(candidate) === getRecordId(record))) return;
1792
+ parent.payload = {
1793
+ ...(parent.payload || {}),
1794
+ records: [...existingRecords, record]
1795
+ };
1796
+ parent.recordCount = Math.max(Number(parent.recordCount || 0), parent.payload.records.length);
1797
+ });
1798
+
1432
1799
  const state = normalizeState({
1433
1800
  folders,
1434
1801
  records: reorderV2RecordsByFolderRecordOrder(records, recordOrderByFolder),
@@ -1439,6 +1806,7 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1439
1806
  rtlSyncMeta: rootDoc.rtlSyncMeta || {},
1440
1807
  deletedRecordTombstones: rootDoc.deletedRecordTombstones || {},
1441
1808
  deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
1809
+ trashEntries,
1442
1810
  lastModified: rootDoc.lastModified || 0
1443
1811
  });
1444
1812
 
@@ -1627,6 +1995,7 @@ const buildSyncStateInput = (data = {}, operationTime, options = {}) => ({
1627
1995
  rtlSyncMeta: data?.rtlSyncMeta || {},
1628
1996
  deletedRecordTombstones: data?.deletedRecordTombstones || {},
1629
1997
  deletedFolderTombstones: data?.deletedFolderTombstones || {},
1998
+ trashEntries: data?.trashEntries || {},
1630
1999
  lastModified: operationTime
1631
2000
  });
1632
2001
 
@@ -2218,7 +2587,13 @@ export const createSyncEngine = ({
2218
2587
 
2219
2588
  const RTL_SYNC_CLIENT_APPS = new Set(['extension', 'flowmoor']);
2220
2589
  const RTL_SUPPORTED_OPERATION_TYPES = new Set(Object.values(OPERATION_TYPES));
2221
- const RTL_BULK_OPERATION_TYPES = new Set([OPERATION_TYPES.FOLDER_DELETE]);
2590
+ const RTL_BULK_OPERATION_TYPES = new Set([
2591
+ OPERATION_TYPES.FOLDER_DELETE,
2592
+ OPERATION_TYPES.FOLDER_RESTORE,
2593
+ OPERATION_TYPES.TRASH_RESTORE_BATCH,
2594
+ OPERATION_TYPES.TRASH_PURGE_BATCH,
2595
+ OPERATION_TYPES.TRASH_EMPTY
2596
+ ]);
2222
2597
  const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
2223
2598
 
2224
2599
  const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
@@ -2228,10 +2603,14 @@ const rtlOperationRecordId = (operation = {}) => normalizeId(
2228
2603
  const rtlOperationFolderId = (operation = {}) => normalizeId(
2229
2604
  operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
2230
2605
  );
2606
+ const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
2607
+ operation?.payload?.trashEntryId || operation?.payload?.id
2608
+ );
2231
2609
  const rtlCloneDocuments = (documents = {}) => ({
2232
2610
  root: clone(documents.root || null),
2233
2611
  records: clone(documents.records || {}),
2234
2612
  folders: clone(documents.folders || {}),
2613
+ trash: clone(documents.trash || {}),
2235
2614
  ops: {}
2236
2615
  });
2237
2616
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
@@ -2287,6 +2666,9 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
2287
2666
  case OPERATION_TYPES.RECORD_DELETE:
2288
2667
  if (!recordId) errors.push(`operation_${index}_missing_record_id`);
2289
2668
  break;
2669
+ case OPERATION_TYPES.RECORD_RESTORE:
2670
+ if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
2671
+ break;
2290
2672
  case OPERATION_TYPES.RECORD_REORDER:
2291
2673
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
2292
2674
  rtlValidateIdList(payload.recordIds, index, 'record_ids', errors);
@@ -2303,6 +2685,17 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
2303
2685
  case OPERATION_TYPES.FOLDER_DELETE:
2304
2686
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
2305
2687
  break;
2688
+ case OPERATION_TYPES.FOLDER_RESTORE:
2689
+ case OPERATION_TYPES.TRASH_PURGE:
2690
+ if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
2691
+ break;
2692
+ case OPERATION_TYPES.TRASH_RESTORE_BATCH:
2693
+ case OPERATION_TYPES.TRASH_PURGE_BATCH:
2694
+ if (!normalizeId(payload.batchId)) errors.push(`operation_${index}_missing_batch_id`);
2695
+ break;
2696
+ case OPERATION_TYPES.TRASH_EMPTY:
2697
+ if (!toFiniteTimestamp(payload.cutoffAt)) errors.push(`operation_${index}_missing_cutoff_at`);
2698
+ break;
2306
2699
  case OPERATION_TYPES.FOLDER_REORDER:
2307
2700
  rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
2308
2701
  break;
@@ -2401,6 +2794,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2401
2794
  const recordIds = new Set();
2402
2795
  const folderIds = new Set();
2403
2796
  const folderDeleteIds = new Set();
2797
+ const trashEntryIds = new Set();
2404
2798
  toArray(operations).filter(Boolean).forEach((operation) => {
2405
2799
  const payload = operation.payload || {};
2406
2800
  switch (operation.type) {
@@ -2419,6 +2813,12 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2419
2813
  if (recordId) recordIds.add(recordId);
2420
2814
  break;
2421
2815
  }
2816
+ case OPERATION_TYPES.RECORD_RESTORE:
2817
+ case OPERATION_TYPES.TRASH_PURGE: {
2818
+ const trashEntryId = rtlOperationTrashEntryId(operation);
2819
+ if (trashEntryId) trashEntryIds.add(trashEntryId);
2820
+ break;
2821
+ }
2422
2822
  case OPERATION_TYPES.RECORD_REORDER:
2423
2823
  folderIds.add(safeFolderId(payload.folderId));
2424
2824
  break;
@@ -2436,14 +2836,15 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2436
2836
  break;
2437
2837
  }
2438
2838
  });
2439
- return {recordIds, folderIds, folderDeleteIds};
2839
+ return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
2440
2840
  };
2441
2841
 
2442
2842
  export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
2443
2843
  const next = {
2444
2844
  recordIds: new Set(readPlan?.recordIds || []),
2445
2845
  folderIds: new Set(readPlan?.folderIds || []),
2446
- folderDeleteIds: new Set(readPlan?.folderDeleteIds || [])
2846
+ folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2847
+ trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2447
2848
  };
2448
2849
  Object.values(records || {}).forEach((record) => {
2449
2850
  next.folderIds.add(safeFolderId(record?.folderId));
@@ -2451,9 +2852,23 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
2451
2852
  return next;
2452
2853
  };
2453
2854
 
2454
- const rtlApplyOperationToPartialDocuments = ({root, records, folders, operation, now}) => {
2855
+ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntries = {}) => {
2856
+ const next = {
2857
+ recordIds: new Set(readPlan?.recordIds || []),
2858
+ folderIds: new Set(readPlan?.folderIds || []),
2859
+ folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2860
+ trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2861
+ };
2862
+ Object.values(trashEntries || {}).forEach((entry) => {
2863
+ if (entry?.kind === 'record') next.folderIds.add(safeFolderId(entry.originalFolderId));
2864
+ });
2865
+ next.folderIds.add(DEFAULT_FOLDER_ID);
2866
+ return next;
2867
+ };
2868
+
2869
+ const rtlApplyOperationToPartialDocuments = ({root, records, folders, trash, operation, now}) => {
2455
2870
  const {state} = buildStateFromFirestoreV2Documents({
2456
- root: root || {}, records: records || {}, folders: folders || {}, ops: {}
2871
+ root: root || {}, records: records || {}, folders: folders || {}, trash: trash || {}, ops: {}
2457
2872
  });
2458
2873
  const nextState = applyRecordTimeLabelOperation(state, operation);
2459
2874
  return {
@@ -2465,12 +2880,7 @@ const rtlApplyOperationToPartialDocuments = ({root, records, folders, operation,
2465
2880
  const rtlCanRestoreLocalFolder = (root, folder) => {
2466
2881
  if (!folder?.id) return false;
2467
2882
  const tombstone = root?.deletedFolderTombstones?.[folder.id];
2468
- if (!tombstone) return true;
2469
- const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt || folder.lastModified);
2470
- const deletedAt = toFiniteTimestamp(
2471
- tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt
2472
- );
2473
- return Boolean(folderTime) && Boolean(deletedAt) && folderTime > deletedAt;
2883
+ return !tombstone;
2474
2884
  };
2475
2885
 
2476
2886
  const rtlBuildLocalFolderDocument = (folder, now) => {
@@ -2627,6 +3037,9 @@ export const planFirestoreV2OperationChanges = ({
2627
3037
  const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
2628
3038
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
2629
3039
  rtlSetRoot(nextDocuments.root, applied.documents.root);
3040
+ const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
3041
+ const trashDocument = applied.documents.trash?.[trashEntryId];
3042
+ if (trashDocument) nextDocuments.trash[trashEntryId] = trashDocument;
2630
3043
  delete nextDocuments.records[recordId];
2631
3044
  if (nextDocuments.folders[sourceFolderId]) {
2632
3045
  nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
@@ -2636,6 +3049,50 @@ export const planFirestoreV2OperationChanges = ({
2636
3049
  changed = true;
2637
3050
  break;
2638
3051
  }
3052
+ case OPERATION_TYPES.RECORD_RESTORE: {
3053
+ const trashEntryId = rtlOperationTrashEntryId(operation);
3054
+ const trashEntry = nextDocuments.trash[trashEntryId];
3055
+ if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
3056
+ if (toFiniteTimestamp(trashEntry.purgeAt) <= operationNow) {
3057
+ reason = 'trash_entry_expired';
3058
+ break;
3059
+ }
3060
+ const restoredRecordId = normalizeId(trashEntry.entityId);
3061
+ if (!restoredRecordId || nextDocuments.records[restoredRecordId]) {
3062
+ reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
3063
+ break;
3064
+ }
3065
+ applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3066
+ const restoredRecord = applied.documents.records[restoredRecordId];
3067
+ if (!restoredRecord) { reason = 'restore_conflict'; break; }
3068
+ const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
3069
+ const targetFolder = nextDocuments.folders[targetFolderId] ||
3070
+ nextDocuments.folders[DEFAULT_FOLDER_ID];
3071
+ if (!targetFolder) { reason = 'folder_not_found'; break; }
3072
+ rtlSetRoot(nextDocuments.root, applied.documents.root);
3073
+ nextDocuments.records[restoredRecordId] = restoredRecord;
3074
+ delete nextDocuments.trash[trashEntryId];
3075
+ targetFolder.recordOrder = rtlMergeOrder(
3076
+ targetFolder.recordOrder.slice(0, Number(trashEntry.originalRecordIndex || 0)),
3077
+ [restoredRecordId],
3078
+ targetFolder.recordOrder.slice(Number(trashEntry.originalRecordIndex || 0))
3079
+ );
3080
+ changed = true;
3081
+ break;
3082
+ }
3083
+ case OPERATION_TYPES.TRASH_PURGE: {
3084
+ const trashEntryId = rtlOperationTrashEntryId(operation);
3085
+ const trashEntry = nextDocuments.trash[trashEntryId];
3086
+ if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
3087
+ if (payload.expectedGeneration &&
3088
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) {
3089
+ reason = 'lifecycle_conflict';
3090
+ break;
3091
+ }
3092
+ delete nextDocuments.trash[trashEntryId];
3093
+ changed = true;
3094
+ break;
3095
+ }
2639
3096
  case OPERATION_TYPES.FOLDER_CREATE:
2640
3097
  case OPERATION_TYPES.FOLDER_UPDATE: {
2641
3098
  if (!folderId) { reason = 'missing_folder_id'; break; }
@@ -2656,6 +3113,10 @@ export const planFirestoreV2OperationChanges = ({
2656
3113
  break;
2657
3114
  }
2658
3115
  case OPERATION_TYPES.FOLDER_DELETE:
3116
+ case OPERATION_TYPES.FOLDER_RESTORE:
3117
+ case OPERATION_TYPES.TRASH_RESTORE_BATCH:
3118
+ case OPERATION_TYPES.TRASH_PURGE_BATCH:
3119
+ case OPERATION_TYPES.TRASH_EMPTY:
2659
3120
  reason = 'bulk_required';
2660
3121
  break;
2661
3122
  case OPERATION_TYPES.FOLDER_REORDER:
@@ -2700,7 +3161,7 @@ export const planFirestoreV2OperationChanges = ({
2700
3161
 
2701
3162
  if (changed) {
2702
3163
  const {state} = buildStateFromFirestoreV2Documents({
2703
- root: nextDocuments.root, records: {}, folders: {}, ops: {}
3164
+ root: nextDocuments.root, records: {}, folders: {}, trash: {}, ops: {}
2704
3165
  });
2705
3166
  nextDocuments.root = buildFirestoreV2DocumentsFromState(state, {
2706
3167
  now,
@@ -2723,7 +3184,7 @@ export const planFirestoreV2OperationChanges = ({
2723
3184
  * Counts all billable writes before a transaction reserves quota.
2724
3185
  */
2725
3186
  export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
2726
- const collectionWrites = ['records', 'folders', 'ops'].reduce((count, key) => (
3187
+ const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
2727
3188
  count + Object.keys(changes?.[key]?.upserts || {}).length +
2728
3189
  toArray(changes?.[key]?.deleteIds).length
2729
3190
  ), 0);
@@ -2864,11 +3325,13 @@ export default {
2864
3325
  RTL_MAX_OPERATIONS_PER_REQUEST,
2865
3326
  RTL_MAX_REQUEST_BYTES,
2866
3327
  RTL_MAX_TARGET_WRITES,
3328
+ RTL_TRASH_RETENTION_MS,
2867
3329
  OPERATION_TYPES,
2868
3330
  RECORD_TIMELABEL_SYNC_MODES,
2869
3331
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
2870
3332
  FIRESTORE_V2_SETTINGS_DOC_ID,
2871
3333
  normalizeState,
3334
+ getActiveTrashEntries,
2872
3335
  hasMeaningfulRecordTimeLabelCloudState,
2873
3336
  buildRecordTimeLabelContentFingerprint,
2874
3337
  buildMigratedRecordTimeLabelV2State,
@@ -2891,6 +3354,7 @@ export default {
2891
3354
  validateRecordTimeLabelOperationBatch,
2892
3355
  buildFirestoreV2OperationReadPlan,
2893
3356
  extendFirestoreV2OperationReadPlanWithRecords,
3357
+ extendFirestoreV2OperationReadPlanWithTrash,
2894
3358
  planFirestoreV2OperationChanges,
2895
3359
  estimateFirestoreV2WriteUnits,
2896
3360
  buildOperationsFromSnapshotDiff,