@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.
- package/README.md +7 -1
- package/package.json +1 -1
- 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.
|
|
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
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.
|
|
9
|
-
export const RTL_SYNC_PROTOCOL_VERSION =
|
|
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
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
321
|
+
if (tombstone) return;
|
|
262
322
|
const folderTombstone = folderTombstones[targetFolderId];
|
|
263
|
-
if (folderTombstone
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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
|
|
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
|
-
|
|
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,
|