@recordtimelabel/core 0.2.0 → 0.3.0

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 +8 -2
  2. package/package.json +1 -1
  3. package/src/index.js +472 -46
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.0"
23
+ "@recordtimelabel/core": "0.3.0"
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,9 +48,10 @@ 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
- - `buildOperationsFromSnapshotDiff({ previousState, nextState, now, operationIdPrefix, batchSize })`
54
+ - `buildOperationsFromSnapshotDiff({ previousState, nextState, now, operationIdPrefix, batchSize })`(一般操作在 `batches`,`folder.delete` 在 `bulkOperations`)
54
55
  - `RTL_SYNC_PROTOCOL_VERSION`
55
56
  - `hasMeaningfulRecordTimeLabelCloudState(data, options)`
56
57
  - `buildRecordTimeLabelContentFingerprint(data)`
@@ -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,6 +79,7 @@ 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,6 +90,8 @@ The package exposes platform-neutral v2 document helpers so app adapters can sha
86
90
  - root document `users/{uid}/recordTimeLabel/main`: settings, order arrays, tombstones, and sync metadata
87
91
  - `users/{uid}/recordTimeLabel/main/records/{recordId}`: flattened per-record documents with `folderId`
88
92
  - `users/{uid}/recordTimeLabel/main/folders/{folderId}`: per-folder documents
93
+ - `users/{uid}/recordTimeLabel/main/trash/{trashEntryId}`: 30-day record/folder snapshots
94
+ - `users/{uid}/recordTimeLabel/main/lifecycleTombstones/{id}`: content-free anti-revival markers
89
95
  - `users/{uid}/recordTimeLabel/main/ops/{opId}`: pending operation documents
90
96
 
91
97
  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.0",
3
+ "version": "0.3.0",
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.0';
9
- export const RTL_SYNC_PROTOCOL_VERSION = 1;
8
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.3.0';
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([
@@ -46,12 +53,31 @@ export const RECORD_TIMELABEL_CLOUD_SCHEMAS = Object.freeze({
46
53
  export const FIRESTORE_V2_SETTINGS_DOC_ID = 'main';
47
54
 
48
55
  const toArray = (value) => (Array.isArray(value) ? value : []);
49
- const clone = (value) => {
50
- if (value === undefined || value === null) return value;
51
- return JSON.parse(JSON.stringify(value));
56
+ const clone = (value, seen = new WeakMap()) => {
57
+ if (value === undefined || value === null || typeof value !== 'object') return value;
58
+ if (value instanceof Date) return new Date(value.getTime());
59
+ const prototype = Object.getPrototypeOf(value);
60
+ if (prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) {
61
+ return value;
62
+ }
63
+ if (seen.has(value)) return seen.get(value);
64
+ const copy = Array.isArray(value) ? [] : {};
65
+ seen.set(value, copy);
66
+ Reflect.ownKeys(value).forEach((key) => {
67
+ copy[key] = clone(value[key], seen);
68
+ });
69
+ return copy;
52
70
  };
53
71
 
54
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
+ }
55
81
  if (typeof value === 'number') {
56
82
  return Number.isFinite(value) ? value : 0;
57
83
  }
@@ -157,6 +183,39 @@ const normalizeTombstones = (value) => {
157
183
  }, {});
158
184
  };
159
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
+
160
219
  const mergeTombstones = (left = {}, right = {}) => {
161
220
  const merged = { ...left };
162
221
  Object.entries(right).forEach(([id, tombstone]) => {
@@ -168,6 +227,20 @@ const mergeTombstones = (left = {}, right = {}) => {
168
227
  return merged;
169
228
  };
170
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
+
171
244
  const resolveRecordFolderId = (folderId, record = {}) => {
172
245
  const candidates = [
173
246
  record.folderId,
@@ -245,13 +318,9 @@ export const normalizeRecords = (records = {}, options = {}) => {
245
318
  delete normalizedRecord.sortIndex;
246
319
 
247
320
  const tombstone = tombstones[id];
248
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
249
- return;
250
- }
321
+ if (tombstone) return;
251
322
  const folderTombstone = folderTombstones[targetFolderId];
252
- if (folderTombstone && toFiniteTimestamp(folderTombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
253
- return;
254
- }
323
+ if (folderTombstone) return;
255
324
 
256
325
  if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
257
326
 
@@ -352,9 +421,7 @@ const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombst
352
421
  if (!id) return;
353
422
 
354
423
  const tombstone = deletedFolderTombstones[id];
355
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
356
- return;
357
- }
424
+ if (tombstone) return;
358
425
 
359
426
  const current = byId.get(id);
360
427
  if (!current) {
@@ -390,9 +457,7 @@ export const normalizeState = (input = {}) => {
390
457
  const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
391
458
  const folders = ensureFolders(input.folders).filter((folder) => {
392
459
  const tombstone = deletedFolderTombstones[folder.id];
393
- if (!tombstone) return true;
394
- const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
395
- return folderTime > toFiniteTimestamp(tombstone.deletedAt);
460
+ return !tombstone;
396
461
  });
397
462
  const records = ensureRecordFolders(
398
463
  normalizeRecords(input.records, { deletedRecordTombstones, deletedFolderTombstones }),
@@ -412,6 +477,7 @@ export const normalizeState = (input = {}) => {
412
477
  },
413
478
  deletedRecordTombstones,
414
479
  deletedFolderTombstones,
480
+ trashEntries: normalizeTrashEntries(input.trashEntries),
415
481
  lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
416
482
  };
417
483
  };
@@ -434,6 +500,7 @@ export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {})
434
500
  Object.keys(state.settings || {}).length > 0 ||
435
501
  Object.keys(state.deletedRecordTombstones || {}).length > 0 ||
436
502
  Object.keys(state.deletedFolderTombstones || {}).length > 0 ||
503
+ Object.keys(state.trashEntries || {}).length > 0 ||
437
504
  hasNonDefaultGroupOrder ||
438
505
  state.folderOrder.length > 0 ||
439
506
  state.expandedGroups.length > 0;
@@ -449,7 +516,8 @@ export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
449
516
  folderOrder: state.folderOrder,
450
517
  expandedGroups: state.expandedGroups,
451
518
  deletedRecordTombstones: state.deletedRecordTombstones,
452
- deletedFolderTombstones: state.deletedFolderTombstones
519
+ deletedFolderTombstones: state.deletedFolderTombstones,
520
+ trashEntries: state.trashEntries
453
521
  });
454
522
  };
455
523
 
@@ -590,10 +658,7 @@ export const applyOperation = (state = {}, operation = {}) => {
590
658
  const recordId = normalizeId(record.id || payload.recordId);
591
659
  if (!recordId) return normalized;
592
660
  const tombstone = nextState.deletedRecordTombstones[recordId];
593
- const recordTime = getRecordTime(record) || operationTime;
594
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
595
- return normalized;
596
- }
661
+ if (tombstone) return normalized;
597
662
 
598
663
  const folderId = safeFolderId(payload.folderId || record.folderId);
599
664
  const cleanedRecord = {
@@ -698,20 +763,85 @@ export const applyOperation = (state = {}, operation = {}) => {
698
763
  case OPERATION_TYPES.RECORD_DELETE: {
699
764
  const recordId = normalizeId(payload.recordId || payload.id);
700
765
  if (!recordId) return normalized;
766
+ const existingEntry = findRecordEntry(nextState.records, recordId);
767
+ const recordSnapshot = payload.record && typeof payload.record === 'object'
768
+ ? {...payload.record, id: recordId}
769
+ : existingEntry?.record;
770
+ if (!recordSnapshot && nextState.trashEntries[`record:${recordId}`]) return normalized;
771
+ const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
772
+ const previousGeneration = Math.max(
773
+ Number(recordSnapshot?.lifecycleGeneration || 0),
774
+ Number(nextState.deletedRecordTombstones[recordId]?.lifecycleGeneration || 0)
775
+ );
776
+ const lifecycleGeneration = previousGeneration + 1;
777
+ if (recordSnapshot) {
778
+ const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
779
+ nextState.trashEntries[trashEntryId] = {
780
+ id: trashEntryId,
781
+ kind: 'record',
782
+ entityId: recordId,
783
+ batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
784
+ originalFolderId: safeFolderId(payload.folderId || existingEntry?.folderId),
785
+ originalRecordIndex: Number.isInteger(payload.originalRecordIndex)
786
+ ? payload.originalRecordIndex
787
+ : Math.max(0, Number(existingEntry?.index || 0)),
788
+ lifecycleGeneration,
789
+ deletedAt,
790
+ purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
791
+ payload: {record: {...recordSnapshot, lifecycleGeneration}}
792
+ };
793
+ }
701
794
  nextState.records = removeRecordById(nextState.records, recordId);
702
795
  nextState.deletedRecordTombstones[recordId] = {
703
796
  id: recordId,
704
- deletedAt: payload.deletedAt || operationTime,
797
+ deletedAt,
798
+ lifecycleGeneration,
705
799
  clientId: operation.clientId || payload.clientId || null,
706
800
  operationId: operation.id || null
707
801
  };
708
802
  break;
709
803
  }
710
804
 
805
+ case OPERATION_TYPES.RECORD_RESTORE: {
806
+ const trashEntryId = normalizeId(payload.trashEntryId) ||
807
+ `record:${normalizeId(payload.recordId || payload.id)}`;
808
+ const trashEntry = nextState.trashEntries[trashEntryId];
809
+ if (!trashEntry || trashEntry.kind !== 'record') return normalized;
810
+ if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
811
+ if (payload.expectedGeneration &&
812
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
813
+ const recordId = normalizeId(trashEntry.entityId);
814
+ if (!recordId || findRecordEntry(nextState.records, recordId)) return normalized;
815
+ const recordSnapshot = trashEntry.payload?.record;
816
+ if (!recordSnapshot) return normalized;
817
+ const originalFolderId = safeFolderId(trashEntry.originalFolderId);
818
+ const targetFolderId = nextState.folders.some((folder) => folder.id === originalFolderId)
819
+ ? originalFolderId
820
+ : DEFAULT_FOLDER_ID;
821
+ const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
822
+ const restoredRecord = {
823
+ ...recordSnapshot,
824
+ id: recordId,
825
+ lifecycleGeneration,
826
+ updatedAt: operationTime
827
+ };
828
+ const targetRecords = [...toArray(nextState.records[targetFolderId])];
829
+ const restoreIndex = Math.min(
830
+ Math.max(0, Number(trashEntry.originalRecordIndex || 0)),
831
+ targetRecords.length
832
+ );
833
+ targetRecords.splice(restoreIndex, 0, restoredRecord);
834
+ nextState.records[targetFolderId] = targetRecords;
835
+ delete nextState.trashEntries[trashEntryId];
836
+ delete nextState.deletedRecordTombstones[recordId];
837
+ break;
838
+ }
839
+
711
840
  case OPERATION_TYPES.FOLDER_CREATE: {
712
841
  const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
713
842
  const folderId = normalizeId(folder?.id || payload.folderId);
714
843
  if (!folderId) return normalized;
844
+ if (nextState.deletedFolderTombstones[folderId]) return normalized;
715
845
  const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
716
846
  const nextFolder = {
717
847
  ...(folder || {}),
@@ -742,16 +872,146 @@ export const applyOperation = (state = {}, operation = {}) => {
742
872
  case OPERATION_TYPES.FOLDER_DELETE: {
743
873
  const folderId = normalizeId(payload.folderId || payload.id);
744
874
  if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
875
+ const folderSnapshot = payload.folder && typeof payload.folder === 'object'
876
+ ? {...payload.folder, id: folderId}
877
+ : nextState.folders.find((folder) => folder.id === folderId);
878
+ const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
879
+ if (!folderSnapshot && nextState.trashEntries[`folder:${folderId}`]) return normalized;
880
+ const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
881
+ const previousGeneration = Math.max(
882
+ Number(folderSnapshot?.lifecycleGeneration || 0),
883
+ Number(nextState.deletedFolderTombstones[folderId]?.lifecycleGeneration || 0)
884
+ );
885
+ const lifecycleGeneration = previousGeneration + 1;
886
+ if (folderSnapshot) {
887
+ const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
888
+ nextState.trashEntries[trashEntryId] = {
889
+ id: trashEntryId,
890
+ kind: 'folder',
891
+ entityId: folderId,
892
+ batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
893
+ originalFolderOrderIndex: Math.max(0, nextState.folderOrder.indexOf(folderId)),
894
+ lifecycleGeneration,
895
+ deletedAt,
896
+ purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
897
+ payload: {
898
+ folder: {...folderSnapshot, lifecycleGeneration},
899
+ records: folderRecords.map((record) => ({
900
+ ...record,
901
+ lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1
902
+ }))
903
+ }
904
+ };
905
+ }
745
906
  nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
746
907
  delete nextState.records[folderId];
747
908
  nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
748
909
  nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
749
910
  nextState.deletedFolderTombstones[folderId] = {
750
911
  id: folderId,
751
- deletedAt: payload.deletedAt || operationTime,
912
+ deletedAt,
913
+ lifecycleGeneration,
752
914
  clientId: operation.clientId || payload.clientId || null,
753
915
  operationId: operation.id || null
754
916
  };
917
+ folderRecords.forEach((record) => {
918
+ const recordId = getRecordId(record);
919
+ if (!recordId) return;
920
+ nextState.deletedRecordTombstones[recordId] = {
921
+ id: recordId,
922
+ folderId,
923
+ deletedAt,
924
+ lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1,
925
+ clientId: operation.clientId || payload.clientId || null,
926
+ operationId: operation.id || null
927
+ };
928
+ });
929
+ break;
930
+ }
931
+
932
+ case OPERATION_TYPES.FOLDER_RESTORE: {
933
+ const trashEntryId = normalizeId(payload.trashEntryId) ||
934
+ `folder:${normalizeId(payload.folderId || payload.id)}`;
935
+ const trashEntry = nextState.trashEntries[trashEntryId];
936
+ if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
937
+ if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
938
+ if (payload.expectedGeneration &&
939
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
940
+ const folderId = normalizeId(trashEntry.entityId);
941
+ if (!folderId || nextState.folders.some((folder) => folder.id === folderId)) return normalized;
942
+ const folderSnapshot = trashEntry.payload?.folder;
943
+ if (!folderSnapshot) return normalized;
944
+ const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
945
+ nextState.folders.push({
946
+ ...folderSnapshot,
947
+ id: folderId,
948
+ lifecycleGeneration,
949
+ updatedAt: operationTime
950
+ });
951
+ nextState.records[folderId] = toArray(trashEntry.payload?.records).map((record) => ({
952
+ ...record,
953
+ lifecycleGeneration: Number(record.lifecycleGeneration || 0) + 1,
954
+ updatedAt: operationTime
955
+ }));
956
+ const folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
957
+ const restoreIndex = Math.min(
958
+ Math.max(0, Number(trashEntry.originalFolderOrderIndex || 0)),
959
+ folderOrder.length
960
+ );
961
+ folderOrder.splice(restoreIndex, 0, folderId);
962
+ nextState.folderOrder = folderOrder;
963
+ delete nextState.deletedFolderTombstones[folderId];
964
+ nextState.records[folderId].forEach((record) => {
965
+ delete nextState.deletedRecordTombstones[record.id];
966
+ });
967
+ delete nextState.trashEntries[trashEntryId];
968
+ break;
969
+ }
970
+
971
+ case OPERATION_TYPES.TRASH_PURGE: {
972
+ const trashEntryId = normalizeId(payload.trashEntryId || payload.id);
973
+ const trashEntry = nextState.trashEntries[trashEntryId];
974
+ if (!trashEntry) return normalized;
975
+ if (payload.expectedGeneration &&
976
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) return normalized;
977
+ delete nextState.trashEntries[trashEntryId];
978
+ break;
979
+ }
980
+
981
+ case OPERATION_TYPES.TRASH_RESTORE_BATCH: {
982
+ const batchId = normalizeId(payload.batchId);
983
+ if (!batchId) return normalized;
984
+ let restoredState = nextState;
985
+ Object.values(nextState.trashEntries)
986
+ .filter((entry) => entry.batchId === batchId)
987
+ .sort((left, right) => left.kind === 'folder' ? -1 : right.kind === 'folder' ? 1 : 0)
988
+ .forEach((entry, index) => {
989
+ restoredState = applyOperation(restoredState, {
990
+ ...operation,
991
+ id: `${operation.id || 'trash.restoreBatch'}:${index}`,
992
+ type: entry.kind === 'folder'
993
+ ? OPERATION_TYPES.FOLDER_RESTORE
994
+ : OPERATION_TYPES.RECORD_RESTORE,
995
+ payload: {trashEntryId: entry.id, expectedGeneration: entry.lifecycleGeneration}
996
+ });
997
+ });
998
+ return restoredState;
999
+ }
1000
+
1001
+ case OPERATION_TYPES.TRASH_PURGE_BATCH: {
1002
+ const batchId = normalizeId(payload.batchId);
1003
+ if (!batchId) return normalized;
1004
+ Object.values(nextState.trashEntries).forEach((entry) => {
1005
+ if (entry.batchId === batchId) delete nextState.trashEntries[entry.id];
1006
+ });
1007
+ break;
1008
+ }
1009
+
1010
+ case OPERATION_TYPES.TRASH_EMPTY: {
1011
+ const cutoffAt = toFiniteTimestamp(payload.cutoffAt) || operationTime;
1012
+ Object.values(nextState.trashEntries).forEach((entry) => {
1013
+ if (toFiniteTimestamp(entry.deletedAt) <= cutoffAt) delete nextState.trashEntries[entry.id];
1014
+ });
755
1015
  break;
756
1016
  }
757
1017
 
@@ -808,6 +1068,7 @@ export const mergeLocalRemote = ({
808
1068
  remote.deletedFolderTombstones,
809
1069
  local.deletedFolderTombstones
810
1070
  );
1071
+ const trashEntries = mergeTrashEntries(remote.trashEntries, local.trashEntries);
811
1072
  const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
812
1073
  const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
813
1074
 
@@ -821,6 +1082,7 @@ export const mergeLocalRemote = ({
821
1082
  settings: { ...remote.settings, ...local.settings },
822
1083
  deletedRecordTombstones,
823
1084
  deletedFolderTombstones,
1085
+ trashEntries,
824
1086
  rtlSyncMeta: {
825
1087
  ...remote.rtlSyncMeta,
826
1088
  ...local.rtlSyncMeta,
@@ -973,6 +1235,7 @@ export const buildLocalStateFromStorage = (data = {}, options = {}) => normalize
973
1235
  rtlSyncMeta: data.rtlSyncMeta || {},
974
1236
  deletedRecordTombstones: data.deletedRecordTombstones || {},
975
1237
  deletedFolderTombstones: data.deletedFolderTombstones || {},
1238
+ trashEntries: data.trashEntries || {},
976
1239
  lastModified: data.lastModified || data.updatedAt || 0
977
1240
  });
978
1241
 
@@ -991,6 +1254,7 @@ export const buildStoragePatchFromState = (state = {}, options = {}) => {
991
1254
  rtlSyncMeta: normalized.rtlSyncMeta,
992
1255
  deletedRecordTombstones: normalized.deletedRecordTombstones,
993
1256
  deletedFolderTombstones: normalized.deletedFolderTombstones,
1257
+ trashEntries: normalized.trashEntries,
994
1258
  lastModified: normalized.lastModified || resolveNow(options.now)
995
1259
  };
996
1260
 
@@ -1019,6 +1283,7 @@ export const buildFirestoreV1UserPatch = (state = {}, options = {}) => {
1019
1283
  },
1020
1284
  deletedRecordTombstones: normalized.deletedRecordTombstones || {},
1021
1285
  deletedFolderTombstones: normalized.deletedFolderTombstones || {},
1286
+ trashEntries: normalized.trashEntries || {},
1022
1287
  lastModified: normalized.lastModified || resolveNow(options.now)
1023
1288
  };
1024
1289
  };
@@ -1030,6 +1295,8 @@ export const buildFirestoreV2LogicalPaths = (userId = '{uid}') => {
1030
1295
  root,
1031
1296
  records: `${root}/records`,
1032
1297
  folders: `${root}/folders`,
1298
+ trash: `${root}/trash`,
1299
+ lifecycleTombstones: `${root}/lifecycleTombstones`,
1033
1300
  settings: root,
1034
1301
  ops: `${root}/ops`
1035
1302
  };
@@ -1223,6 +1490,18 @@ const cleanV2OperationDocument = (operation = {}, now) => {
1223
1490
  };
1224
1491
  };
1225
1492
 
1493
+ const cleanV2TrashDocument = (entry = {}, now) => {
1494
+ const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
1495
+ const trashEntry = Object.values(normalized)[0];
1496
+ if (!trashEntry) return null;
1497
+ return {
1498
+ ...trashEntry,
1499
+ deletedAt: trashEntry.deletedAt || now,
1500
+ purgeAt: trashEntry.purgeAt || now + RTL_TRASH_RETENTION_MS,
1501
+ schemaVersion: 2
1502
+ };
1503
+ };
1504
+
1226
1505
  export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
1227
1506
  const normalized = normalizeState(state || {});
1228
1507
  const now = resolveNow(options.now);
@@ -1252,6 +1531,12 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1252
1531
  if (operationDoc) ops[operationDoc.id] = operationDoc;
1253
1532
  });
1254
1533
 
1534
+ const trash = {};
1535
+ Object.values(normalized.trashEntries || {}).forEach((entry) => {
1536
+ const trashDocument = cleanV2TrashDocument(entry, now);
1537
+ if (trashDocument) trash[trashDocument.id] = trashDocument;
1538
+ });
1539
+
1255
1540
  const root = {
1256
1541
  id: FIRESTORE_V2_SETTINGS_DOC_ID,
1257
1542
  schemaVersion: 2,
@@ -1274,6 +1559,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1274
1559
  root,
1275
1560
  records,
1276
1561
  folders,
1562
+ trash,
1277
1563
  ops
1278
1564
  };
1279
1565
  };
@@ -1365,7 +1651,12 @@ export const buildFirestoreV2DocumentChangeSet = (
1365
1651
  nextDocuments?.ops,
1366
1652
  allowDeletes
1367
1653
  );
1368
- const hasCollectionChanges = [records, folders, ops].some((changeSet) => (
1654
+ const trash = buildFirestoreV2CollectionChangeSet(
1655
+ previousDocuments?.trash,
1656
+ nextDocuments?.trash,
1657
+ allowDeletes
1658
+ );
1659
+ const hasCollectionChanges = [records, folders, trash, ops].some((changeSet) => (
1369
1660
  Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
1370
1661
  ));
1371
1662
 
@@ -1374,6 +1665,7 @@ export const buildFirestoreV2DocumentChangeSet = (
1374
1665
  root: { upsert: rootUpsert },
1375
1666
  records,
1376
1667
  folders,
1668
+ trash,
1377
1669
  ops
1378
1670
  };
1379
1671
  };
@@ -1418,6 +1710,25 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1418
1710
  })
1419
1711
  .filter(Boolean);
1420
1712
 
1713
+ const trashEntries = normalizeTrashEntries(Object.fromEntries(
1714
+ mapValuesArray(documents?.trash)
1715
+ .map((entry) => [entry?.id, removeV2Metadata(entry)])
1716
+ .filter(([id]) => normalizeId(id))
1717
+ ));
1718
+ Object.values(trashEntries).forEach((entry) => {
1719
+ const parentEntryId = normalizeId(entry.parentEntryId);
1720
+ const parent = parentEntryId ? trashEntries[parentEntryId] : null;
1721
+ const record = entry.hidden === true ? entry.payload?.record : null;
1722
+ if (!parent || parent.kind !== 'folder' || !record) return;
1723
+ const existingRecords = toArray(parent.payload?.records);
1724
+ if (existingRecords.some((candidate) => getRecordId(candidate) === getRecordId(record))) return;
1725
+ parent.payload = {
1726
+ ...(parent.payload || {}),
1727
+ records: [...existingRecords, record]
1728
+ };
1729
+ parent.recordCount = Math.max(Number(parent.recordCount || 0), parent.payload.records.length);
1730
+ });
1731
+
1421
1732
  const state = normalizeState({
1422
1733
  folders,
1423
1734
  records: reorderV2RecordsByFolderRecordOrder(records, recordOrderByFolder),
@@ -1428,6 +1739,7 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1428
1739
  rtlSyncMeta: rootDoc.rtlSyncMeta || {},
1429
1740
  deletedRecordTombstones: rootDoc.deletedRecordTombstones || {},
1430
1741
  deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
1742
+ trashEntries,
1431
1743
  lastModified: rootDoc.lastModified || 0
1432
1744
  });
1433
1745
 
@@ -1616,6 +1928,7 @@ const buildSyncStateInput = (data = {}, operationTime, options = {}) => ({
1616
1928
  rtlSyncMeta: data?.rtlSyncMeta || {},
1617
1929
  deletedRecordTombstones: data?.deletedRecordTombstones || {},
1618
1930
  deletedFolderTombstones: data?.deletedFolderTombstones || {},
1931
+ trashEntries: data?.trashEntries || {},
1619
1932
  lastModified: operationTime
1620
1933
  });
1621
1934
 
@@ -2207,7 +2520,13 @@ export const createSyncEngine = ({
2207
2520
 
2208
2521
  const RTL_SYNC_CLIENT_APPS = new Set(['extension', 'flowmoor']);
2209
2522
  const RTL_SUPPORTED_OPERATION_TYPES = new Set(Object.values(OPERATION_TYPES));
2210
- const RTL_BULK_OPERATION_TYPES = new Set([OPERATION_TYPES.FOLDER_DELETE]);
2523
+ const RTL_BULK_OPERATION_TYPES = new Set([
2524
+ OPERATION_TYPES.FOLDER_DELETE,
2525
+ OPERATION_TYPES.FOLDER_RESTORE,
2526
+ OPERATION_TYPES.TRASH_RESTORE_BATCH,
2527
+ OPERATION_TYPES.TRASH_PURGE_BATCH,
2528
+ OPERATION_TYPES.TRASH_EMPTY
2529
+ ]);
2211
2530
  const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
2212
2531
 
2213
2532
  const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
@@ -2217,10 +2536,14 @@ const rtlOperationRecordId = (operation = {}) => normalizeId(
2217
2536
  const rtlOperationFolderId = (operation = {}) => normalizeId(
2218
2537
  operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
2219
2538
  );
2539
+ const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
2540
+ operation?.payload?.trashEntryId || operation?.payload?.id
2541
+ );
2220
2542
  const rtlCloneDocuments = (documents = {}) => ({
2221
2543
  root: clone(documents.root || null),
2222
2544
  records: clone(documents.records || {}),
2223
2545
  folders: clone(documents.folders || {}),
2546
+ trash: clone(documents.trash || {}),
2224
2547
  ops: {}
2225
2548
  });
2226
2549
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
@@ -2276,6 +2599,9 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
2276
2599
  case OPERATION_TYPES.RECORD_DELETE:
2277
2600
  if (!recordId) errors.push(`operation_${index}_missing_record_id`);
2278
2601
  break;
2602
+ case OPERATION_TYPES.RECORD_RESTORE:
2603
+ if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
2604
+ break;
2279
2605
  case OPERATION_TYPES.RECORD_REORDER:
2280
2606
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
2281
2607
  rtlValidateIdList(payload.recordIds, index, 'record_ids', errors);
@@ -2292,6 +2618,17 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
2292
2618
  case OPERATION_TYPES.FOLDER_DELETE:
2293
2619
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
2294
2620
  break;
2621
+ case OPERATION_TYPES.FOLDER_RESTORE:
2622
+ case OPERATION_TYPES.TRASH_PURGE:
2623
+ if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
2624
+ break;
2625
+ case OPERATION_TYPES.TRASH_RESTORE_BATCH:
2626
+ case OPERATION_TYPES.TRASH_PURGE_BATCH:
2627
+ if (!normalizeId(payload.batchId)) errors.push(`operation_${index}_missing_batch_id`);
2628
+ break;
2629
+ case OPERATION_TYPES.TRASH_EMPTY:
2630
+ if (!toFiniteTimestamp(payload.cutoffAt)) errors.push(`operation_${index}_missing_cutoff_at`);
2631
+ break;
2295
2632
  case OPERATION_TYPES.FOLDER_REORDER:
2296
2633
  rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
2297
2634
  break;
@@ -2390,6 +2727,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2390
2727
  const recordIds = new Set();
2391
2728
  const folderIds = new Set();
2392
2729
  const folderDeleteIds = new Set();
2730
+ const trashEntryIds = new Set();
2393
2731
  toArray(operations).filter(Boolean).forEach((operation) => {
2394
2732
  const payload = operation.payload || {};
2395
2733
  switch (operation.type) {
@@ -2408,6 +2746,12 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2408
2746
  if (recordId) recordIds.add(recordId);
2409
2747
  break;
2410
2748
  }
2749
+ case OPERATION_TYPES.RECORD_RESTORE:
2750
+ case OPERATION_TYPES.TRASH_PURGE: {
2751
+ const trashEntryId = rtlOperationTrashEntryId(operation);
2752
+ if (trashEntryId) trashEntryIds.add(trashEntryId);
2753
+ break;
2754
+ }
2411
2755
  case OPERATION_TYPES.RECORD_REORDER:
2412
2756
  folderIds.add(safeFolderId(payload.folderId));
2413
2757
  break;
@@ -2425,14 +2769,15 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2425
2769
  break;
2426
2770
  }
2427
2771
  });
2428
- return {recordIds, folderIds, folderDeleteIds};
2772
+ return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
2429
2773
  };
2430
2774
 
2431
2775
  export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
2432
2776
  const next = {
2433
2777
  recordIds: new Set(readPlan?.recordIds || []),
2434
2778
  folderIds: new Set(readPlan?.folderIds || []),
2435
- folderDeleteIds: new Set(readPlan?.folderDeleteIds || [])
2779
+ folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2780
+ trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2436
2781
  };
2437
2782
  Object.values(records || {}).forEach((record) => {
2438
2783
  next.folderIds.add(safeFolderId(record?.folderId));
@@ -2440,9 +2785,23 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
2440
2785
  return next;
2441
2786
  };
2442
2787
 
2443
- const rtlApplyOperationToPartialDocuments = ({root, records, folders, operation, now}) => {
2788
+ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntries = {}) => {
2789
+ const next = {
2790
+ recordIds: new Set(readPlan?.recordIds || []),
2791
+ folderIds: new Set(readPlan?.folderIds || []),
2792
+ folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2793
+ trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2794
+ };
2795
+ Object.values(trashEntries || {}).forEach((entry) => {
2796
+ if (entry?.kind === 'record') next.folderIds.add(safeFolderId(entry.originalFolderId));
2797
+ });
2798
+ next.folderIds.add(DEFAULT_FOLDER_ID);
2799
+ return next;
2800
+ };
2801
+
2802
+ const rtlApplyOperationToPartialDocuments = ({root, records, folders, trash, operation, now}) => {
2444
2803
  const {state} = buildStateFromFirestoreV2Documents({
2445
- root: root || {}, records: records || {}, folders: folders || {}, ops: {}
2804
+ root: root || {}, records: records || {}, folders: folders || {}, trash: trash || {}, ops: {}
2446
2805
  });
2447
2806
  const nextState = applyRecordTimeLabelOperation(state, operation);
2448
2807
  return {
@@ -2454,12 +2813,7 @@ const rtlApplyOperationToPartialDocuments = ({root, records, folders, operation,
2454
2813
  const rtlCanRestoreLocalFolder = (root, folder) => {
2455
2814
  if (!folder?.id) return false;
2456
2815
  const tombstone = root?.deletedFolderTombstones?.[folder.id];
2457
- if (!tombstone) return true;
2458
- const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt || folder.lastModified);
2459
- const deletedAt = toFiniteTimestamp(
2460
- tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt
2461
- );
2462
- return Boolean(folderTime) && Boolean(deletedAt) && folderTime > deletedAt;
2816
+ return !tombstone;
2463
2817
  };
2464
2818
 
2465
2819
  const rtlBuildLocalFolderDocument = (folder, now) => {
@@ -2526,21 +2880,23 @@ export const planFirestoreV2OperationChanges = ({
2526
2880
  });
2527
2881
  const recordDocument = applied.documents.records[recordId];
2528
2882
  if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
2529
- rtlSetRoot(nextDocuments.root, applied.documents.root);
2530
- nextDocuments.records[recordId] = recordDocument;
2883
+ const candidateRoot = clone(applied.documents.root);
2884
+ const candidateFolders = clone(nextDocuments.folders);
2531
2885
  const targetFolder = rtlEnsureTargetFolder({
2532
- root: nextDocuments.root,
2533
- folders: nextDocuments.folders,
2886
+ root: candidateRoot,
2887
+ folders: candidateFolders,
2534
2888
  localState: normalizedLocalState,
2535
2889
  folderId: targetFolderId,
2536
2890
  now: operationNow
2537
2891
  });
2538
2892
  if (!targetFolder) {
2539
2893
  reason = 'folder_not_found';
2540
- delete nextDocuments.records[recordId];
2541
2894
  break;
2542
2895
  }
2543
2896
  targetFolder.recordOrder = rtlMergeOrder([recordId], targetFolder.recordOrder);
2897
+ rtlSetRoot(nextDocuments.root, candidateRoot);
2898
+ nextDocuments.folders = candidateFolders;
2899
+ nextDocuments.records[recordId] = recordDocument;
2544
2900
  changed = true;
2545
2901
  break;
2546
2902
  }
@@ -2614,6 +2970,9 @@ export const planFirestoreV2OperationChanges = ({
2614
2970
  const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
2615
2971
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
2616
2972
  rtlSetRoot(nextDocuments.root, applied.documents.root);
2973
+ const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
2974
+ const trashDocument = applied.documents.trash?.[trashEntryId];
2975
+ if (trashDocument) nextDocuments.trash[trashEntryId] = trashDocument;
2617
2976
  delete nextDocuments.records[recordId];
2618
2977
  if (nextDocuments.folders[sourceFolderId]) {
2619
2978
  nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
@@ -2623,6 +2982,50 @@ export const planFirestoreV2OperationChanges = ({
2623
2982
  changed = true;
2624
2983
  break;
2625
2984
  }
2985
+ case OPERATION_TYPES.RECORD_RESTORE: {
2986
+ const trashEntryId = rtlOperationTrashEntryId(operation);
2987
+ const trashEntry = nextDocuments.trash[trashEntryId];
2988
+ if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
2989
+ if (toFiniteTimestamp(trashEntry.purgeAt) <= operationNow) {
2990
+ reason = 'trash_entry_expired';
2991
+ break;
2992
+ }
2993
+ const restoredRecordId = normalizeId(trashEntry.entityId);
2994
+ if (!restoredRecordId || nextDocuments.records[restoredRecordId]) {
2995
+ reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
2996
+ break;
2997
+ }
2998
+ applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
2999
+ const restoredRecord = applied.documents.records[restoredRecordId];
3000
+ if (!restoredRecord) { reason = 'restore_conflict'; break; }
3001
+ const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
3002
+ const targetFolder = nextDocuments.folders[targetFolderId] ||
3003
+ nextDocuments.folders[DEFAULT_FOLDER_ID];
3004
+ if (!targetFolder) { reason = 'folder_not_found'; break; }
3005
+ rtlSetRoot(nextDocuments.root, applied.documents.root);
3006
+ nextDocuments.records[restoredRecordId] = restoredRecord;
3007
+ delete nextDocuments.trash[trashEntryId];
3008
+ targetFolder.recordOrder = rtlMergeOrder(
3009
+ targetFolder.recordOrder.slice(0, Number(trashEntry.originalRecordIndex || 0)),
3010
+ [restoredRecordId],
3011
+ targetFolder.recordOrder.slice(Number(trashEntry.originalRecordIndex || 0))
3012
+ );
3013
+ changed = true;
3014
+ break;
3015
+ }
3016
+ case OPERATION_TYPES.TRASH_PURGE: {
3017
+ const trashEntryId = rtlOperationTrashEntryId(operation);
3018
+ const trashEntry = nextDocuments.trash[trashEntryId];
3019
+ if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
3020
+ if (payload.expectedGeneration &&
3021
+ Number(payload.expectedGeneration) !== Number(trashEntry.lifecycleGeneration)) {
3022
+ reason = 'lifecycle_conflict';
3023
+ break;
3024
+ }
3025
+ delete nextDocuments.trash[trashEntryId];
3026
+ changed = true;
3027
+ break;
3028
+ }
2626
3029
  case OPERATION_TYPES.FOLDER_CREATE:
2627
3030
  case OPERATION_TYPES.FOLDER_UPDATE: {
2628
3031
  if (!folderId) { reason = 'missing_folder_id'; break; }
@@ -2643,6 +3046,10 @@ export const planFirestoreV2OperationChanges = ({
2643
3046
  break;
2644
3047
  }
2645
3048
  case OPERATION_TYPES.FOLDER_DELETE:
3049
+ case OPERATION_TYPES.FOLDER_RESTORE:
3050
+ case OPERATION_TYPES.TRASH_RESTORE_BATCH:
3051
+ case OPERATION_TYPES.TRASH_PURGE_BATCH:
3052
+ case OPERATION_TYPES.TRASH_EMPTY:
2646
3053
  reason = 'bulk_required';
2647
3054
  break;
2648
3055
  case OPERATION_TYPES.FOLDER_REORDER:
@@ -2687,7 +3094,7 @@ export const planFirestoreV2OperationChanges = ({
2687
3094
 
2688
3095
  if (changed) {
2689
3096
  const {state} = buildStateFromFirestoreV2Documents({
2690
- root: nextDocuments.root, records: {}, folders: {}, ops: {}
3097
+ root: nextDocuments.root, records: {}, folders: {}, trash: {}, ops: {}
2691
3098
  });
2692
3099
  nextDocuments.root = buildFirestoreV2DocumentsFromState(state, {
2693
3100
  now,
@@ -2710,7 +3117,7 @@ export const planFirestoreV2OperationChanges = ({
2710
3117
  * Counts all billable writes before a transaction reserves quota.
2711
3118
  */
2712
3119
  export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
2713
- const collectionWrites = ['records', 'folders', 'ops'].reduce((count, key) => (
3120
+ const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
2714
3121
  count + Object.keys(changes?.[key]?.upserts || {}).length +
2715
3122
  toArray(changes?.[key]?.deleteIds).length
2716
3123
  ), 0);
@@ -2751,6 +3158,7 @@ export const buildOperationsFromSnapshotDiff = ({
2751
3158
  const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
2752
3159
  const nextFolders = new Map(next.folders.map((folder) => [folder.id, folder]));
2753
3160
  const drafts = [];
3161
+ const bulkDrafts = [];
2754
3162
 
2755
3163
  nextFolders.forEach((folder, folderId) => {
2756
3164
  if (RTL_PROTECTED_FOLDER_IDS.has(folderId)) return;
@@ -2787,7 +3195,10 @@ export const buildOperationsFromSnapshotDiff = ({
2787
3195
 
2788
3196
  previousFolders.forEach((folder, folderId) => {
2789
3197
  if (!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)) {
2790
- drafts.push({type: OPERATION_TYPES.FOLDER_DELETE, payload: {folderId}});
3198
+ bulkDrafts.push({
3199
+ type: OPERATION_TYPES.FOLDER_DELETE,
3200
+ payload: {folderId, deletedAt: now}
3201
+ });
2791
3202
  }
2792
3203
  });
2793
3204
 
@@ -2812,6 +3223,11 @@ export const buildOperationsFromSnapshotDiff = ({
2812
3223
  createdAt: now + index,
2813
3224
  ...draft
2814
3225
  }));
3226
+ const bulkOperations = bulkDrafts.map((draft, index) => ({
3227
+ id: `${operationIdPrefix}:bulk:${String(index).padStart(6, '0')}`,
3228
+ createdAt: now + operations.length + index,
3229
+ ...draft
3230
+ }));
2815
3231
  const boundedBatchSize = Math.max(1, Math.min(
2816
3232
  RTL_MAX_OPERATIONS_PER_REQUEST,
2817
3233
  Number.isSafeInteger(batchSize) ? batchSize : RTL_MAX_OPERATIONS_PER_REQUEST
@@ -2826,7 +3242,14 @@ export const buildOperationsFromSnapshotDiff = ({
2826
3242
  const changes = buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, {
2827
3243
  allowDeletes: true
2828
3244
  });
2829
- return {operations, batches, changes, previousDocuments, nextDocuments};
3245
+ return {
3246
+ operations,
3247
+ batches,
3248
+ bulkOperations,
3249
+ changes,
3250
+ previousDocuments,
3251
+ nextDocuments
3252
+ };
2830
3253
  };
2831
3254
 
2832
3255
  export default {
@@ -2835,11 +3258,13 @@ export default {
2835
3258
  RTL_MAX_OPERATIONS_PER_REQUEST,
2836
3259
  RTL_MAX_REQUEST_BYTES,
2837
3260
  RTL_MAX_TARGET_WRITES,
3261
+ RTL_TRASH_RETENTION_MS,
2838
3262
  OPERATION_TYPES,
2839
3263
  RECORD_TIMELABEL_SYNC_MODES,
2840
3264
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
2841
3265
  FIRESTORE_V2_SETTINGS_DOC_ID,
2842
3266
  normalizeState,
3267
+ getActiveTrashEntries,
2843
3268
  hasMeaningfulRecordTimeLabelCloudState,
2844
3269
  buildRecordTimeLabelContentFingerprint,
2845
3270
  buildMigratedRecordTimeLabelV2State,
@@ -2862,6 +3287,7 @@ export default {
2862
3287
  validateRecordTimeLabelOperationBatch,
2863
3288
  buildFirestoreV2OperationReadPlan,
2864
3289
  extendFirestoreV2OperationReadPlanWithRecords,
3290
+ extendFirestoreV2OperationReadPlanWithTrash,
2865
3291
  planFirestoreV2OperationChanges,
2866
3292
  estimateFirestoreV2WriteUnits,
2867
3293
  buildOperationsFromSnapshotDiff,