@recordtimelabel/core 0.2.1 → 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 +7 -1
  2. package/package.json +1 -1
  3. package/src/index.js +433 -36
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.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,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,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.1",
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.1';
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([
@@ -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,20 @@ 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
+
182
244
  const resolveRecordFolderId = (folderId, record = {}) => {
183
245
  const candidates = [
184
246
  record.folderId,
@@ -256,13 +318,9 @@ export const normalizeRecords = (records = {}, options = {}) => {
256
318
  delete normalizedRecord.sortIndex;
257
319
 
258
320
  const tombstone = tombstones[id];
259
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
260
- return;
261
- }
321
+ if (tombstone) return;
262
322
  const folderTombstone = folderTombstones[targetFolderId];
263
- if (folderTombstone && toFiniteTimestamp(folderTombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
264
- return;
265
- }
323
+ if (folderTombstone) return;
266
324
 
267
325
  if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
268
326
 
@@ -363,9 +421,7 @@ const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombst
363
421
  if (!id) return;
364
422
 
365
423
  const tombstone = deletedFolderTombstones[id];
366
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
367
- return;
368
- }
424
+ if (tombstone) return;
369
425
 
370
426
  const current = byId.get(id);
371
427
  if (!current) {
@@ -401,9 +457,7 @@ export const normalizeState = (input = {}) => {
401
457
  const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
402
458
  const folders = ensureFolders(input.folders).filter((folder) => {
403
459
  const tombstone = deletedFolderTombstones[folder.id];
404
- if (!tombstone) return true;
405
- const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
406
- return folderTime > toFiniteTimestamp(tombstone.deletedAt);
460
+ return !tombstone;
407
461
  });
408
462
  const records = ensureRecordFolders(
409
463
  normalizeRecords(input.records, { deletedRecordTombstones, deletedFolderTombstones }),
@@ -423,6 +477,7 @@ export const normalizeState = (input = {}) => {
423
477
  },
424
478
  deletedRecordTombstones,
425
479
  deletedFolderTombstones,
480
+ trashEntries: normalizeTrashEntries(input.trashEntries),
426
481
  lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
427
482
  };
428
483
  };
@@ -445,6 +500,7 @@ export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {})
445
500
  Object.keys(state.settings || {}).length > 0 ||
446
501
  Object.keys(state.deletedRecordTombstones || {}).length > 0 ||
447
502
  Object.keys(state.deletedFolderTombstones || {}).length > 0 ||
503
+ Object.keys(state.trashEntries || {}).length > 0 ||
448
504
  hasNonDefaultGroupOrder ||
449
505
  state.folderOrder.length > 0 ||
450
506
  state.expandedGroups.length > 0;
@@ -460,7 +516,8 @@ export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
460
516
  folderOrder: state.folderOrder,
461
517
  expandedGroups: state.expandedGroups,
462
518
  deletedRecordTombstones: state.deletedRecordTombstones,
463
- deletedFolderTombstones: state.deletedFolderTombstones
519
+ deletedFolderTombstones: state.deletedFolderTombstones,
520
+ trashEntries: state.trashEntries
464
521
  });
465
522
  };
466
523
 
@@ -601,10 +658,7 @@ export const applyOperation = (state = {}, operation = {}) => {
601
658
  const recordId = normalizeId(record.id || payload.recordId);
602
659
  if (!recordId) return normalized;
603
660
  const tombstone = nextState.deletedRecordTombstones[recordId];
604
- const recordTime = getRecordTime(record) || operationTime;
605
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
606
- return normalized;
607
- }
661
+ if (tombstone) return normalized;
608
662
 
609
663
  const folderId = safeFolderId(payload.folderId || record.folderId);
610
664
  const cleanedRecord = {
@@ -709,20 +763,85 @@ export const applyOperation = (state = {}, operation = {}) => {
709
763
  case OPERATION_TYPES.RECORD_DELETE: {
710
764
  const recordId = normalizeId(payload.recordId || payload.id);
711
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
+ }
712
794
  nextState.records = removeRecordById(nextState.records, recordId);
713
795
  nextState.deletedRecordTombstones[recordId] = {
714
796
  id: recordId,
715
- deletedAt: payload.deletedAt || operationTime,
797
+ deletedAt,
798
+ lifecycleGeneration,
716
799
  clientId: operation.clientId || payload.clientId || null,
717
800
  operationId: operation.id || null
718
801
  };
719
802
  break;
720
803
  }
721
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
+
722
840
  case OPERATION_TYPES.FOLDER_CREATE: {
723
841
  const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
724
842
  const folderId = normalizeId(folder?.id || payload.folderId);
725
843
  if (!folderId) return normalized;
844
+ if (nextState.deletedFolderTombstones[folderId]) return normalized;
726
845
  const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
727
846
  const nextFolder = {
728
847
  ...(folder || {}),
@@ -753,16 +872,146 @@ export const applyOperation = (state = {}, operation = {}) => {
753
872
  case OPERATION_TYPES.FOLDER_DELETE: {
754
873
  const folderId = normalizeId(payload.folderId || payload.id);
755
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
+ }
756
906
  nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
757
907
  delete nextState.records[folderId];
758
908
  nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
759
909
  nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
760
910
  nextState.deletedFolderTombstones[folderId] = {
761
911
  id: folderId,
762
- deletedAt: payload.deletedAt || operationTime,
912
+ deletedAt,
913
+ lifecycleGeneration,
763
914
  clientId: operation.clientId || payload.clientId || null,
764
915
  operationId: operation.id || null
765
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
+ });
766
1015
  break;
767
1016
  }
768
1017
 
@@ -819,6 +1068,7 @@ export const mergeLocalRemote = ({
819
1068
  remote.deletedFolderTombstones,
820
1069
  local.deletedFolderTombstones
821
1070
  );
1071
+ const trashEntries = mergeTrashEntries(remote.trashEntries, local.trashEntries);
822
1072
  const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
823
1073
  const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
824
1074
 
@@ -832,6 +1082,7 @@ export const mergeLocalRemote = ({
832
1082
  settings: { ...remote.settings, ...local.settings },
833
1083
  deletedRecordTombstones,
834
1084
  deletedFolderTombstones,
1085
+ trashEntries,
835
1086
  rtlSyncMeta: {
836
1087
  ...remote.rtlSyncMeta,
837
1088
  ...local.rtlSyncMeta,
@@ -984,6 +1235,7 @@ export const buildLocalStateFromStorage = (data = {}, options = {}) => normalize
984
1235
  rtlSyncMeta: data.rtlSyncMeta || {},
985
1236
  deletedRecordTombstones: data.deletedRecordTombstones || {},
986
1237
  deletedFolderTombstones: data.deletedFolderTombstones || {},
1238
+ trashEntries: data.trashEntries || {},
987
1239
  lastModified: data.lastModified || data.updatedAt || 0
988
1240
  });
989
1241
 
@@ -1002,6 +1254,7 @@ export const buildStoragePatchFromState = (state = {}, options = {}) => {
1002
1254
  rtlSyncMeta: normalized.rtlSyncMeta,
1003
1255
  deletedRecordTombstones: normalized.deletedRecordTombstones,
1004
1256
  deletedFolderTombstones: normalized.deletedFolderTombstones,
1257
+ trashEntries: normalized.trashEntries,
1005
1258
  lastModified: normalized.lastModified || resolveNow(options.now)
1006
1259
  };
1007
1260
 
@@ -1030,6 +1283,7 @@ export const buildFirestoreV1UserPatch = (state = {}, options = {}) => {
1030
1283
  },
1031
1284
  deletedRecordTombstones: normalized.deletedRecordTombstones || {},
1032
1285
  deletedFolderTombstones: normalized.deletedFolderTombstones || {},
1286
+ trashEntries: normalized.trashEntries || {},
1033
1287
  lastModified: normalized.lastModified || resolveNow(options.now)
1034
1288
  };
1035
1289
  };
@@ -1041,6 +1295,8 @@ export const buildFirestoreV2LogicalPaths = (userId = '{uid}') => {
1041
1295
  root,
1042
1296
  records: `${root}/records`,
1043
1297
  folders: `${root}/folders`,
1298
+ trash: `${root}/trash`,
1299
+ lifecycleTombstones: `${root}/lifecycleTombstones`,
1044
1300
  settings: root,
1045
1301
  ops: `${root}/ops`
1046
1302
  };
@@ -1234,6 +1490,18 @@ const cleanV2OperationDocument = (operation = {}, now) => {
1234
1490
  };
1235
1491
  };
1236
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
+
1237
1505
  export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
1238
1506
  const normalized = normalizeState(state || {});
1239
1507
  const now = resolveNow(options.now);
@@ -1263,6 +1531,12 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1263
1531
  if (operationDoc) ops[operationDoc.id] = operationDoc;
1264
1532
  });
1265
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
+
1266
1540
  const root = {
1267
1541
  id: FIRESTORE_V2_SETTINGS_DOC_ID,
1268
1542
  schemaVersion: 2,
@@ -1285,6 +1559,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1285
1559
  root,
1286
1560
  records,
1287
1561
  folders,
1562
+ trash,
1288
1563
  ops
1289
1564
  };
1290
1565
  };
@@ -1376,7 +1651,12 @@ export const buildFirestoreV2DocumentChangeSet = (
1376
1651
  nextDocuments?.ops,
1377
1652
  allowDeletes
1378
1653
  );
1379
- 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) => (
1380
1660
  Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
1381
1661
  ));
1382
1662
 
@@ -1385,6 +1665,7 @@ export const buildFirestoreV2DocumentChangeSet = (
1385
1665
  root: { upsert: rootUpsert },
1386
1666
  records,
1387
1667
  folders,
1668
+ trash,
1388
1669
  ops
1389
1670
  };
1390
1671
  };
@@ -1429,6 +1710,25 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1429
1710
  })
1430
1711
  .filter(Boolean);
1431
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
+
1432
1732
  const state = normalizeState({
1433
1733
  folders,
1434
1734
  records: reorderV2RecordsByFolderRecordOrder(records, recordOrderByFolder),
@@ -1439,6 +1739,7 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1439
1739
  rtlSyncMeta: rootDoc.rtlSyncMeta || {},
1440
1740
  deletedRecordTombstones: rootDoc.deletedRecordTombstones || {},
1441
1741
  deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
1742
+ trashEntries,
1442
1743
  lastModified: rootDoc.lastModified || 0
1443
1744
  });
1444
1745
 
@@ -1627,6 +1928,7 @@ const buildSyncStateInput = (data = {}, operationTime, options = {}) => ({
1627
1928
  rtlSyncMeta: data?.rtlSyncMeta || {},
1628
1929
  deletedRecordTombstones: data?.deletedRecordTombstones || {},
1629
1930
  deletedFolderTombstones: data?.deletedFolderTombstones || {},
1931
+ trashEntries: data?.trashEntries || {},
1630
1932
  lastModified: operationTime
1631
1933
  });
1632
1934
 
@@ -2218,7 +2520,13 @@ export const createSyncEngine = ({
2218
2520
 
2219
2521
  const RTL_SYNC_CLIENT_APPS = new Set(['extension', 'flowmoor']);
2220
2522
  const RTL_SUPPORTED_OPERATION_TYPES = new Set(Object.values(OPERATION_TYPES));
2221
- 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
+ ]);
2222
2530
  const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
2223
2531
 
2224
2532
  const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
@@ -2228,10 +2536,14 @@ const rtlOperationRecordId = (operation = {}) => normalizeId(
2228
2536
  const rtlOperationFolderId = (operation = {}) => normalizeId(
2229
2537
  operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
2230
2538
  );
2539
+ const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
2540
+ operation?.payload?.trashEntryId || operation?.payload?.id
2541
+ );
2231
2542
  const rtlCloneDocuments = (documents = {}) => ({
2232
2543
  root: clone(documents.root || null),
2233
2544
  records: clone(documents.records || {}),
2234
2545
  folders: clone(documents.folders || {}),
2546
+ trash: clone(documents.trash || {}),
2235
2547
  ops: {}
2236
2548
  });
2237
2549
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
@@ -2287,6 +2599,9 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
2287
2599
  case OPERATION_TYPES.RECORD_DELETE:
2288
2600
  if (!recordId) errors.push(`operation_${index}_missing_record_id`);
2289
2601
  break;
2602
+ case OPERATION_TYPES.RECORD_RESTORE:
2603
+ if (!rtlOperationTrashEntryId(operation)) errors.push(`operation_${index}_missing_trash_entry_id`);
2604
+ break;
2290
2605
  case OPERATION_TYPES.RECORD_REORDER:
2291
2606
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
2292
2607
  rtlValidateIdList(payload.recordIds, index, 'record_ids', errors);
@@ -2303,6 +2618,17 @@ const rtlValidateOperationPayload = (operation, index, errors) => {
2303
2618
  case OPERATION_TYPES.FOLDER_DELETE:
2304
2619
  if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
2305
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;
2306
2632
  case OPERATION_TYPES.FOLDER_REORDER:
2307
2633
  rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
2308
2634
  break;
@@ -2401,6 +2727,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2401
2727
  const recordIds = new Set();
2402
2728
  const folderIds = new Set();
2403
2729
  const folderDeleteIds = new Set();
2730
+ const trashEntryIds = new Set();
2404
2731
  toArray(operations).filter(Boolean).forEach((operation) => {
2405
2732
  const payload = operation.payload || {};
2406
2733
  switch (operation.type) {
@@ -2419,6 +2746,12 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2419
2746
  if (recordId) recordIds.add(recordId);
2420
2747
  break;
2421
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
+ }
2422
2755
  case OPERATION_TYPES.RECORD_REORDER:
2423
2756
  folderIds.add(safeFolderId(payload.folderId));
2424
2757
  break;
@@ -2436,14 +2769,15 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
2436
2769
  break;
2437
2770
  }
2438
2771
  });
2439
- return {recordIds, folderIds, folderDeleteIds};
2772
+ return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
2440
2773
  };
2441
2774
 
2442
2775
  export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
2443
2776
  const next = {
2444
2777
  recordIds: new Set(readPlan?.recordIds || []),
2445
2778
  folderIds: new Set(readPlan?.folderIds || []),
2446
- folderDeleteIds: new Set(readPlan?.folderDeleteIds || [])
2779
+ folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
2780
+ trashEntryIds: new Set(readPlan?.trashEntryIds || [])
2447
2781
  };
2448
2782
  Object.values(records || {}).forEach((record) => {
2449
2783
  next.folderIds.add(safeFolderId(record?.folderId));
@@ -2451,9 +2785,23 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
2451
2785
  return next;
2452
2786
  };
2453
2787
 
2454
- 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}) => {
2455
2803
  const {state} = buildStateFromFirestoreV2Documents({
2456
- root: root || {}, records: records || {}, folders: folders || {}, ops: {}
2804
+ root: root || {}, records: records || {}, folders: folders || {}, trash: trash || {}, ops: {}
2457
2805
  });
2458
2806
  const nextState = applyRecordTimeLabelOperation(state, operation);
2459
2807
  return {
@@ -2465,12 +2813,7 @@ const rtlApplyOperationToPartialDocuments = ({root, records, folders, operation,
2465
2813
  const rtlCanRestoreLocalFolder = (root, folder) => {
2466
2814
  if (!folder?.id) return false;
2467
2815
  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;
2816
+ return !tombstone;
2474
2817
  };
2475
2818
 
2476
2819
  const rtlBuildLocalFolderDocument = (folder, now) => {
@@ -2627,6 +2970,9 @@ export const planFirestoreV2OperationChanges = ({
2627
2970
  const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
2628
2971
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
2629
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;
2630
2976
  delete nextDocuments.records[recordId];
2631
2977
  if (nextDocuments.folders[sourceFolderId]) {
2632
2978
  nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
@@ -2636,6 +2982,50 @@ export const planFirestoreV2OperationChanges = ({
2636
2982
  changed = true;
2637
2983
  break;
2638
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
+ }
2639
3029
  case OPERATION_TYPES.FOLDER_CREATE:
2640
3030
  case OPERATION_TYPES.FOLDER_UPDATE: {
2641
3031
  if (!folderId) { reason = 'missing_folder_id'; break; }
@@ -2656,6 +3046,10 @@ export const planFirestoreV2OperationChanges = ({
2656
3046
  break;
2657
3047
  }
2658
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:
2659
3053
  reason = 'bulk_required';
2660
3054
  break;
2661
3055
  case OPERATION_TYPES.FOLDER_REORDER:
@@ -2700,7 +3094,7 @@ export const planFirestoreV2OperationChanges = ({
2700
3094
 
2701
3095
  if (changed) {
2702
3096
  const {state} = buildStateFromFirestoreV2Documents({
2703
- root: nextDocuments.root, records: {}, folders: {}, ops: {}
3097
+ root: nextDocuments.root, records: {}, folders: {}, trash: {}, ops: {}
2704
3098
  });
2705
3099
  nextDocuments.root = buildFirestoreV2DocumentsFromState(state, {
2706
3100
  now,
@@ -2723,7 +3117,7 @@ export const planFirestoreV2OperationChanges = ({
2723
3117
  * Counts all billable writes before a transaction reserves quota.
2724
3118
  */
2725
3119
  export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
2726
- const collectionWrites = ['records', 'folders', 'ops'].reduce((count, key) => (
3120
+ const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
2727
3121
  count + Object.keys(changes?.[key]?.upserts || {}).length +
2728
3122
  toArray(changes?.[key]?.deleteIds).length
2729
3123
  ), 0);
@@ -2864,11 +3258,13 @@ export default {
2864
3258
  RTL_MAX_OPERATIONS_PER_REQUEST,
2865
3259
  RTL_MAX_REQUEST_BYTES,
2866
3260
  RTL_MAX_TARGET_WRITES,
3261
+ RTL_TRASH_RETENTION_MS,
2867
3262
  OPERATION_TYPES,
2868
3263
  RECORD_TIMELABEL_SYNC_MODES,
2869
3264
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
2870
3265
  FIRESTORE_V2_SETTINGS_DOC_ID,
2871
3266
  normalizeState,
3267
+ getActiveTrashEntries,
2872
3268
  hasMeaningfulRecordTimeLabelCloudState,
2873
3269
  buildRecordTimeLabelContentFingerprint,
2874
3270
  buildMigratedRecordTimeLabelV2State,
@@ -2891,6 +3287,7 @@ export default {
2891
3287
  validateRecordTimeLabelOperationBatch,
2892
3288
  buildFirestoreV2OperationReadPlan,
2893
3289
  extendFirestoreV2OperationReadPlanWithRecords,
3290
+ extendFirestoreV2OperationReadPlanWithTrash,
2894
3291
  planFirestoreV2OperationChanges,
2895
3292
  estimateFirestoreV2WriteUnits,
2896
3293
  buildOperationsFromSnapshotDiff,