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