@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.
- package/README.md +8 -2
- package/package.json +1 -1
- 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.
|
|
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
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([
|
|
@@ -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
|
|
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
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
321
|
+
if (tombstone) return;
|
|
251
322
|
const folderTombstone = folderTombstones[targetFolderId];
|
|
252
|
-
if (folderTombstone
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2530
|
-
|
|
2883
|
+
const candidateRoot = clone(applied.documents.root);
|
|
2884
|
+
const candidateFolders = clone(nextDocuments.folders);
|
|
2531
2885
|
const targetFolder = rtlEnsureTargetFolder({
|
|
2532
|
-
root:
|
|
2533
|
-
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
|
-
|
|
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 {
|
|
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,
|