@recordtimelabel/core 0.3.1 → 0.3.3
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 +10 -5
- package/package.json +1 -1
- package/src/index.js +247 -53
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.3.
|
|
23
|
+
"@recordtimelabel/core": "0.3.2"
|
|
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.
|
|
@@ -84,10 +84,15 @@ The core also normalizes and preserves these compatibility metadata fields:
|
|
|
84
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.
|
|
85
85
|
|
|
86
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
trash entry is dropped when either side carries that entity as active and newer, and restore
|
|
88
|
+
operations are what bump the generation. When generations tie — an active copy written outside the
|
|
89
|
+
delete/restore path — the active copy's own timestamp breaks the tie against `deletedAt`. A stale
|
|
90
|
+
snapshot on either side therefore cannot re-delete an entity that was already restored.
|
|
91
|
+
|
|
92
|
+
Consumers must not reimplement this reconciliation in an app-level wrapper. In particular, never
|
|
93
|
+
clear a local tombstone just because the remote snapshot still carries the entity as active: that
|
|
94
|
+
is presence-based inference, the same anti-pattern as the five-minute heuristic above, and it is not
|
|
95
|
+
order-independent, so two clients using different rules will not converge on shared cloud data.
|
|
91
96
|
|
|
92
97
|
## Firestore v2 Gateway Contract
|
|
93
98
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@ const REQUIRED_FOLDERS = [
|
|
|
5
5
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
6
6
|
];
|
|
7
7
|
|
|
8
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.3.
|
|
8
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.3.3';
|
|
9
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;
|
|
@@ -220,7 +220,11 @@ const mergeTombstones = (left = {}, right = {}) => {
|
|
|
220
220
|
const merged = { ...left };
|
|
221
221
|
Object.entries(right).forEach(([id, tombstone]) => {
|
|
222
222
|
const current = merged[id];
|
|
223
|
-
|
|
223
|
+
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
224
|
+
const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
|
|
225
|
+
if (!current || nextGeneration > currentGeneration ||
|
|
226
|
+
(nextGeneration === currentGeneration &&
|
|
227
|
+
toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt))) {
|
|
224
228
|
merged[id] = { ...tombstone };
|
|
225
229
|
}
|
|
226
230
|
});
|
|
@@ -241,30 +245,42 @@ const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
|
|
|
241
245
|
return merged;
|
|
242
246
|
};
|
|
243
247
|
|
|
244
|
-
const
|
|
248
|
+
const collectActiveLifecycleStates = (...states) => {
|
|
245
249
|
const records = new Map();
|
|
246
250
|
const folders = new Map();
|
|
247
|
-
const track = (target, id, entity) => {
|
|
251
|
+
const track = (target, id, entity, time) => {
|
|
248
252
|
if (!id) return;
|
|
249
253
|
const generation = Number(entity?.lifecycleGeneration || 0);
|
|
250
|
-
|
|
254
|
+
const current = target.get(id);
|
|
255
|
+
if (current && (current.generation > generation ||
|
|
256
|
+
(current.generation === generation && current.time >= time))) return;
|
|
257
|
+
target.set(id, { generation, time });
|
|
251
258
|
};
|
|
252
259
|
states.forEach((state) => {
|
|
253
260
|
Object.entries(state?.records || {}).forEach(([folderId, folderRecords]) => {
|
|
254
261
|
if (VIRTUAL_FOLDER_IDS.has(normalizeId(folderId))) return;
|
|
255
|
-
toArray(folderRecords).forEach((record) =>
|
|
262
|
+
toArray(folderRecords).forEach((record) => (
|
|
263
|
+
track(records, getRecordId(record), record, getRecordTime(record))
|
|
264
|
+
));
|
|
256
265
|
});
|
|
257
|
-
toArray(state?.folders).forEach((folder) =>
|
|
266
|
+
toArray(state?.folders).forEach((folder) => (
|
|
267
|
+
track(folders, normalizeId(folder?.id), folder, getFolderTime(folder))
|
|
268
|
+
));
|
|
258
269
|
});
|
|
259
270
|
return { records, folders };
|
|
260
271
|
};
|
|
261
272
|
|
|
262
273
|
/**
|
|
263
274
|
* 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
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
275
|
+
* its entity. Once either side carries that entity as active and newer, the deletion is stale and
|
|
276
|
+
* must be dropped, otherwise mergeTombstones' union lets a stale snapshot re-delete a record that
|
|
277
|
+
* was already restored.
|
|
278
|
+
*
|
|
279
|
+
* "Newer" is decided by lifecycleGeneration first — restore operations bump it, and generations
|
|
280
|
+
* default to 0 so legacy data never outranks a real tombstone. Generations only tie when an active
|
|
281
|
+
* copy was written outside the delete/restore path, and there the active copy's own timestamp
|
|
282
|
+
* breaks the tie. That tie-break also makes this consistent with mergeLocalRemote's local-record
|
|
283
|
+
* loop, which already intends a record newer than `deletedAt` to survive its tombstone.
|
|
268
284
|
*/
|
|
269
285
|
const dropSupersededLifecycleDeletions = ({
|
|
270
286
|
deletedRecordTombstones = {},
|
|
@@ -272,9 +288,13 @@ const dropSupersededLifecycleDeletions = ({
|
|
|
272
288
|
trashEntries = {},
|
|
273
289
|
activeGenerations
|
|
274
290
|
}) => {
|
|
275
|
-
const isSuperseded = (target, id, deletion) =>
|
|
276
|
-
|
|
277
|
-
|
|
291
|
+
const isSuperseded = (target, id, deletion) => {
|
|
292
|
+
const active = target.get(normalizeId(id));
|
|
293
|
+
if (!active) return false;
|
|
294
|
+
const deletionGeneration = Number(deletion?.lifecycleGeneration || 0);
|
|
295
|
+
if (active.generation !== deletionGeneration) return active.generation > deletionGeneration;
|
|
296
|
+
return active.time > toFiniteTimestamp(deletion?.deletedAt);
|
|
297
|
+
};
|
|
278
298
|
const nextRecordTombstones = { ...deletedRecordTombstones };
|
|
279
299
|
Object.entries(nextRecordTombstones).forEach(([recordId, tombstone]) => {
|
|
280
300
|
if (isSuperseded(activeGenerations.records, recordId, tombstone)) {
|
|
@@ -827,7 +847,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
827
847
|
const recordSnapshot = payload.record && typeof payload.record === 'object'
|
|
828
848
|
? {...payload.record, id: recordId}
|
|
829
849
|
: existingEntry?.record;
|
|
830
|
-
|
|
850
|
+
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
851
|
+
if (!recordSnapshot && nextState.trashEntries[trashEntryId]) return normalized;
|
|
831
852
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
832
853
|
const previousGeneration = Math.max(
|
|
833
854
|
Number(recordSnapshot?.lifecycleGeneration || 0),
|
|
@@ -835,7 +856,6 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
835
856
|
);
|
|
836
857
|
const lifecycleGeneration = previousGeneration + 1;
|
|
837
858
|
if (recordSnapshot) {
|
|
838
|
-
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
839
859
|
nextState.trashEntries[trashEntryId] = {
|
|
840
860
|
id: trashEntryId,
|
|
841
861
|
kind: 'record',
|
|
@@ -951,6 +971,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
951
971
|
entityId: folderId,
|
|
952
972
|
batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
|
|
953
973
|
originalFolderOrderIndex: Math.max(0, nextState.folderOrder.indexOf(folderId)),
|
|
974
|
+
// -1 records "was not in groupOrder", so restore does not invent a placement.
|
|
975
|
+
originalGroupOrderIndex: nextState.groupOrder.indexOf(folderId),
|
|
954
976
|
lifecycleGeneration,
|
|
955
977
|
deletedAt,
|
|
956
978
|
purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
|
|
@@ -1020,6 +1042,15 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1020
1042
|
);
|
|
1021
1043
|
folderOrder.splice(restoreIndex, 0, folderId);
|
|
1022
1044
|
nextState.folderOrder = folderOrder;
|
|
1045
|
+
// folder.delete strips the id from groupOrder too, so restore has to put it back.
|
|
1046
|
+
const originalGroupOrderIndex = Number.isInteger(trashEntry.originalGroupOrderIndex)
|
|
1047
|
+
? trashEntry.originalGroupOrderIndex
|
|
1048
|
+
: -1;
|
|
1049
|
+
if (originalGroupOrderIndex >= 0) {
|
|
1050
|
+
const groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
|
|
1051
|
+
groupOrder.splice(Math.min(originalGroupOrderIndex, groupOrder.length), 0, folderId);
|
|
1052
|
+
nextState.groupOrder = groupOrder;
|
|
1053
|
+
}
|
|
1023
1054
|
delete nextState.deletedFolderTombstones[folderId];
|
|
1024
1055
|
nextState.records[folderId].forEach((record) => {
|
|
1025
1056
|
delete nextState.deletedRecordTombstones[record.id];
|
|
@@ -1134,7 +1165,7 @@ export const mergeLocalRemote = ({
|
|
|
1134
1165
|
local.deletedFolderTombstones
|
|
1135
1166
|
),
|
|
1136
1167
|
trashEntries: mergeTrashEntries(remote.trashEntries, local.trashEntries),
|
|
1137
|
-
activeGenerations:
|
|
1168
|
+
activeGenerations: collectActiveLifecycleStates(remote, local)
|
|
1138
1169
|
});
|
|
1139
1170
|
const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
|
|
1140
1171
|
const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
|
|
@@ -1569,6 +1600,29 @@ const cleanV2TrashDocument = (entry = {}, now) => {
|
|
|
1569
1600
|
};
|
|
1570
1601
|
};
|
|
1571
1602
|
|
|
1603
|
+
const buildV2LifecycleTombstoneDocuments = (
|
|
1604
|
+
deletedRecordTombstones = {},
|
|
1605
|
+
deletedFolderTombstones = {}
|
|
1606
|
+
) => {
|
|
1607
|
+
const documents = {};
|
|
1608
|
+
const append = (kind, tombstones) => {
|
|
1609
|
+
Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
|
|
1610
|
+
const id = `${kind}:${entityId}`;
|
|
1611
|
+
documents[encodeURIComponent(id)] = {
|
|
1612
|
+
id,
|
|
1613
|
+
kind,
|
|
1614
|
+
entityId,
|
|
1615
|
+
lifecycleGeneration: Math.max(1, Number(tombstone.lifecycleGeneration || 1)),
|
|
1616
|
+
deletedAt: toFiniteTimestamp(tombstone.deletedAt),
|
|
1617
|
+
schemaVersion: 2
|
|
1618
|
+
};
|
|
1619
|
+
});
|
|
1620
|
+
};
|
|
1621
|
+
append('record', deletedRecordTombstones);
|
|
1622
|
+
append('folder', deletedFolderTombstones);
|
|
1623
|
+
return documents;
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1572
1626
|
export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
|
|
1573
1627
|
const normalized = normalizeState(state || {});
|
|
1574
1628
|
const now = resolveNow(options.now);
|
|
@@ -1604,6 +1658,11 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
|
|
|
1604
1658
|
if (trashDocument) trash[trashDocument.id] = trashDocument;
|
|
1605
1659
|
});
|
|
1606
1660
|
|
|
1661
|
+
const lifecycleTombstones = buildV2LifecycleTombstoneDocuments(
|
|
1662
|
+
normalized.deletedRecordTombstones,
|
|
1663
|
+
normalized.deletedFolderTombstones
|
|
1664
|
+
);
|
|
1665
|
+
|
|
1607
1666
|
const root = {
|
|
1608
1667
|
id: FIRESTORE_V2_SETTINGS_DOC_ID,
|
|
1609
1668
|
schemaVersion: 2,
|
|
@@ -1627,6 +1686,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
|
|
|
1627
1686
|
records,
|
|
1628
1687
|
folders,
|
|
1629
1688
|
trash,
|
|
1689
|
+
lifecycleTombstones,
|
|
1630
1690
|
ops
|
|
1631
1691
|
};
|
|
1632
1692
|
};
|
|
@@ -1723,7 +1783,12 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1723
1783
|
nextDocuments?.trash,
|
|
1724
1784
|
allowDeletes
|
|
1725
1785
|
);
|
|
1726
|
-
const
|
|
1786
|
+
const lifecycleTombstones = buildFirestoreV2CollectionChangeSet(
|
|
1787
|
+
previousDocuments?.lifecycleTombstones,
|
|
1788
|
+
nextDocuments?.lifecycleTombstones,
|
|
1789
|
+
allowDeletes
|
|
1790
|
+
);
|
|
1791
|
+
const hasCollectionChanges = [records, folders, trash, lifecycleTombstones, ops].some((changeSet) => (
|
|
1727
1792
|
Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
|
|
1728
1793
|
));
|
|
1729
1794
|
|
|
@@ -1733,6 +1798,7 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1733
1798
|
records,
|
|
1734
1799
|
folders,
|
|
1735
1800
|
trash,
|
|
1801
|
+
lifecycleTombstones,
|
|
1736
1802
|
ops
|
|
1737
1803
|
};
|
|
1738
1804
|
};
|
|
@@ -1742,11 +1808,54 @@ const removeV2Metadata = (data = {}) => {
|
|
|
1742
1808
|
return rest;
|
|
1743
1809
|
};
|
|
1744
1810
|
|
|
1811
|
+
/**
|
|
1812
|
+
* lifecycleTombstones 子集合的文件形狀是 { id: 'record:abc', kind, entityId, lifecycleGeneration,
|
|
1813
|
+
* deletedAt },拆成 record / folder 兩張以 entityId 為鍵的表,好跟 root 上的舊資料合併。
|
|
1814
|
+
*/
|
|
1815
|
+
const splitLifecycleTombstoneDocuments = (value) => {
|
|
1816
|
+
const records = {};
|
|
1817
|
+
const folders = {};
|
|
1818
|
+
mapValuesArray(value).forEach((entry) => {
|
|
1819
|
+
const entityId = normalizeId(entry?.entityId);
|
|
1820
|
+
const kind = normalizeId(entry?.kind);
|
|
1821
|
+
const target = kind === 'folder' ? folders : kind === 'record' ? records : null;
|
|
1822
|
+
if (!entityId || !target) return;
|
|
1823
|
+
target[entityId] = {
|
|
1824
|
+
id: entityId,
|
|
1825
|
+
lifecycleGeneration: Math.max(1, Number(entry.lifecycleGeneration || 1)),
|
|
1826
|
+
deletedAt: toFiniteTimestamp(entry.deletedAt)
|
|
1827
|
+
};
|
|
1828
|
+
});
|
|
1829
|
+
return { records, folders };
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* root 的 tombstone 表已停止成長,但既有帳號上仍留著歷史資料,因此兩邊都要讀。同一個 id 同時出現時
|
|
1834
|
+
* 以世代優先、時間次之,跟 dropSupersededLifecycleDeletions 的判準一致。
|
|
1835
|
+
*/
|
|
1836
|
+
const mergeLifecycleTombstoneSources = (legacy = {}, subcollection = {}) => {
|
|
1837
|
+
const merged = normalizeTombstones(legacy);
|
|
1838
|
+
Object.entries(normalizeTombstones(subcollection)).forEach(([id, tombstone]) => {
|
|
1839
|
+
const current = merged[id];
|
|
1840
|
+
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
1841
|
+
const nextGeneration = Number(tombstone.lifecycleGeneration || 0);
|
|
1842
|
+
if (!current || nextGeneration > currentGeneration ||
|
|
1843
|
+
(nextGeneration === currentGeneration &&
|
|
1844
|
+
toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt))) {
|
|
1845
|
+
merged[id] = tombstone;
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
return merged;
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1745
1851
|
export const buildStateFromFirestoreV2Documents = (documents = {}, options = {}) => {
|
|
1746
1852
|
const rootDoc = documents?.root ||
|
|
1747
1853
|
documents?.settings?.[FIRESTORE_V2_SETTINGS_DOC_ID] ||
|
|
1748
1854
|
documents?.settings ||
|
|
1749
1855
|
{};
|
|
1856
|
+
const lifecycleTombstones = splitLifecycleTombstoneDocuments(
|
|
1857
|
+
documents?.lifecycleTombstones
|
|
1858
|
+
);
|
|
1750
1859
|
const records = {};
|
|
1751
1860
|
mapValuesArray(documents?.records).forEach((recordDoc) => {
|
|
1752
1861
|
const recordId = normalizeId(recordDoc?.id);
|
|
@@ -1804,8 +1913,14 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
|
|
|
1804
1913
|
folderOrder: rootDoc.folderOrder || [],
|
|
1805
1914
|
expandedGroups: rootDoc.expandedGroups || [],
|
|
1806
1915
|
rtlSyncMeta: rootDoc.rtlSyncMeta || {},
|
|
1807
|
-
deletedRecordTombstones:
|
|
1808
|
-
|
|
1916
|
+
deletedRecordTombstones: mergeLifecycleTombstoneSources(
|
|
1917
|
+
rootDoc.deletedRecordTombstones,
|
|
1918
|
+
lifecycleTombstones.records
|
|
1919
|
+
),
|
|
1920
|
+
deletedFolderTombstones: mergeLifecycleTombstoneSources(
|
|
1921
|
+
rootDoc.deletedFolderTombstones,
|
|
1922
|
+
lifecycleTombstones.folders
|
|
1923
|
+
),
|
|
1809
1924
|
trashEntries,
|
|
1810
1925
|
lastModified: rootDoc.lastModified || 0
|
|
1811
1926
|
});
|
|
@@ -2128,7 +2243,7 @@ export const flushPendingOperations = async ({
|
|
|
2128
2243
|
}
|
|
2129
2244
|
|
|
2130
2245
|
const syncTime = resolveNow(now);
|
|
2131
|
-
const storageData = await storageAdapter.load(storageKeys);
|
|
2246
|
+
const storageData = (await storageAdapter.load(storageKeys)) || {};
|
|
2132
2247
|
const latestPendingOps = toArray(storageData.pendingOps);
|
|
2133
2248
|
const requestedIds = new Set(requestedOperations.map((operation) => operation?.id).filter(Boolean));
|
|
2134
2249
|
const pendingOpsForPayload = latestPendingOps.filter((operation) => requestedIds.has(operation?.id));
|
|
@@ -2606,16 +2721,23 @@ const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
|
2606
2721
|
const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
|
|
2607
2722
|
operation?.payload?.trashEntryId || operation?.payload?.id
|
|
2608
2723
|
);
|
|
2724
|
+
const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
|
|
2725
|
+
encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
|
|
2726
|
+
);
|
|
2609
2727
|
const rtlCloneDocuments = (documents = {}) => ({
|
|
2610
2728
|
root: clone(documents.root || null),
|
|
2611
2729
|
records: clone(documents.records || {}),
|
|
2612
2730
|
folders: clone(documents.folders || {}),
|
|
2613
2731
|
trash: clone(documents.trash || {}),
|
|
2732
|
+
lifecycleTombstones: clone(documents.lifecycleTombstones || {}),
|
|
2614
2733
|
ops: {}
|
|
2615
2734
|
});
|
|
2616
2735
|
const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
|
|
2617
|
-
|
|
2618
|
-
|
|
2736
|
+
/**
|
|
2737
|
+
* Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
|
|
2738
|
+
* so the planner must not drop ids on a prefix guess either.
|
|
2739
|
+
*/
|
|
2740
|
+
const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order);
|
|
2619
2741
|
const rtlPayloadOrder = (operation, keys) => {
|
|
2620
2742
|
for (const key of keys) {
|
|
2621
2743
|
if (Array.isArray(operation?.payload?.[key])) return operation.payload[key];
|
|
@@ -2795,13 +2917,17 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2795
2917
|
const folderIds = new Set();
|
|
2796
2918
|
const folderDeleteIds = new Set();
|
|
2797
2919
|
const trashEntryIds = new Set();
|
|
2920
|
+
const lifecycleTombstoneIds = new Set();
|
|
2798
2921
|
toArray(operations).filter(Boolean).forEach((operation) => {
|
|
2799
2922
|
const payload = operation.payload || {};
|
|
2800
2923
|
switch (operation.type) {
|
|
2801
2924
|
case OPERATION_TYPES.RECORD_CREATE:
|
|
2802
2925
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
2803
2926
|
const recordId = rtlOperationRecordId(operation);
|
|
2804
|
-
if (recordId)
|
|
2927
|
+
if (recordId) {
|
|
2928
|
+
recordIds.add(recordId);
|
|
2929
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
2930
|
+
}
|
|
2805
2931
|
folderIds.add(safeFolderId(
|
|
2806
2932
|
payload.targetFolderId || payload.folderId || payload.record?.folderId
|
|
2807
2933
|
));
|
|
@@ -2810,7 +2936,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2810
2936
|
case OPERATION_TYPES.RECORD_UPDATE:
|
|
2811
2937
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
2812
2938
|
const recordId = rtlOperationRecordId(operation);
|
|
2813
|
-
if (recordId)
|
|
2939
|
+
if (recordId) {
|
|
2940
|
+
recordIds.add(recordId);
|
|
2941
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
2942
|
+
}
|
|
2814
2943
|
break;
|
|
2815
2944
|
}
|
|
2816
2945
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
@@ -2826,7 +2955,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2826
2955
|
case OPERATION_TYPES.FOLDER_UPDATE:
|
|
2827
2956
|
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
2828
2957
|
const folderId = rtlOperationFolderId(operation);
|
|
2829
|
-
if (folderId)
|
|
2958
|
+
if (folderId) {
|
|
2959
|
+
folderIds.add(folderId);
|
|
2960
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', folderId));
|
|
2961
|
+
}
|
|
2830
2962
|
if (folderId && operation.type === OPERATION_TYPES.FOLDER_DELETE) {
|
|
2831
2963
|
folderDeleteIds.add(folderId);
|
|
2832
2964
|
}
|
|
@@ -2836,7 +2968,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2836
2968
|
break;
|
|
2837
2969
|
}
|
|
2838
2970
|
});
|
|
2839
|
-
return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
|
|
2971
|
+
return {recordIds, folderIds, folderDeleteIds, trashEntryIds, lifecycleTombstoneIds};
|
|
2840
2972
|
};
|
|
2841
2973
|
|
|
2842
2974
|
export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
|
|
@@ -2844,7 +2976,8 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
|
|
|
2844
2976
|
recordIds: new Set(readPlan?.recordIds || []),
|
|
2845
2977
|
folderIds: new Set(readPlan?.folderIds || []),
|
|
2846
2978
|
folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
|
|
2847
|
-
trashEntryIds: new Set(readPlan?.trashEntryIds || [])
|
|
2979
|
+
trashEntryIds: new Set(readPlan?.trashEntryIds || []),
|
|
2980
|
+
lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
|
|
2848
2981
|
};
|
|
2849
2982
|
Object.values(records || {}).forEach((record) => {
|
|
2850
2983
|
next.folderIds.add(safeFolderId(record?.folderId));
|
|
@@ -2857,18 +2990,40 @@ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntri
|
|
|
2857
2990
|
recordIds: new Set(readPlan?.recordIds || []),
|
|
2858
2991
|
folderIds: new Set(readPlan?.folderIds || []),
|
|
2859
2992
|
folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
|
|
2860
|
-
trashEntryIds: new Set(readPlan?.trashEntryIds || [])
|
|
2993
|
+
trashEntryIds: new Set(readPlan?.trashEntryIds || []),
|
|
2994
|
+
lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
|
|
2861
2995
|
};
|
|
2862
2996
|
Object.values(trashEntries || {}).forEach((entry) => {
|
|
2863
|
-
|
|
2997
|
+
const entityId = normalizeId(entry?.entityId);
|
|
2998
|
+
if (entry?.kind === 'record') {
|
|
2999
|
+
next.folderIds.add(safeFolderId(entry.originalFolderId));
|
|
3000
|
+
if (entityId) {
|
|
3001
|
+
next.lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', entityId));
|
|
3002
|
+
}
|
|
3003
|
+
} else if (entry?.kind === 'folder' && entityId) {
|
|
3004
|
+
next.lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', entityId));
|
|
3005
|
+
}
|
|
2864
3006
|
});
|
|
2865
3007
|
next.folderIds.add(DEFAULT_FOLDER_ID);
|
|
2866
3008
|
return next;
|
|
2867
3009
|
};
|
|
2868
3010
|
|
|
2869
|
-
const rtlApplyOperationToPartialDocuments = ({
|
|
3011
|
+
const rtlApplyOperationToPartialDocuments = ({
|
|
3012
|
+
root,
|
|
3013
|
+
records,
|
|
3014
|
+
folders,
|
|
3015
|
+
trash,
|
|
3016
|
+
lifecycleTombstones,
|
|
3017
|
+
operation,
|
|
3018
|
+
now
|
|
3019
|
+
}) => {
|
|
2870
3020
|
const {state} = buildStateFromFirestoreV2Documents({
|
|
2871
|
-
root: root || {},
|
|
3021
|
+
root: root || {},
|
|
3022
|
+
records: records || {},
|
|
3023
|
+
folders: folders || {},
|
|
3024
|
+
trash: trash || {},
|
|
3025
|
+
lifecycleTombstones: lifecycleTombstones || {},
|
|
3026
|
+
ops: {}
|
|
2872
3027
|
});
|
|
2873
3028
|
const nextState = applyRecordTimeLabelOperation(state, operation);
|
|
2874
3029
|
return {
|
|
@@ -2934,6 +3089,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
2934
3089
|
const folderId = rtlOperationFolderId(operation);
|
|
2935
3090
|
let applied = null;
|
|
2936
3091
|
let reason = null;
|
|
3092
|
+
let lifecycleTombstoneId = null;
|
|
2937
3093
|
|
|
2938
3094
|
switch (operation.type) {
|
|
2939
3095
|
case OPERATION_TYPES.RECORD_CREATE: {
|
|
@@ -2996,25 +3152,28 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
2996
3152
|
});
|
|
2997
3153
|
const recordDocument = applied.documents.records[recordId];
|
|
2998
3154
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
3155
|
+
const candidateRoot = clone(applied.documents.root);
|
|
3156
|
+
const candidateFolders = clone(nextDocuments.folders);
|
|
2999
3157
|
const targetFolder = rtlEnsureTargetFolder({
|
|
3000
|
-
root:
|
|
3001
|
-
folders:
|
|
3158
|
+
root: candidateRoot,
|
|
3159
|
+
folders: candidateFolders,
|
|
3002
3160
|
localState: normalizedLocalState,
|
|
3003
3161
|
folderId: targetFolderId,
|
|
3004
3162
|
now: operationNow
|
|
3005
3163
|
});
|
|
3006
3164
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
3011
|
-
nextDocuments.folders[sourceFolderId].recordOrder
|
|
3165
|
+
if (candidateFolders[sourceFolderId]) {
|
|
3166
|
+
candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
3167
|
+
candidateFolders[sourceFolderId].recordOrder
|
|
3012
3168
|
).filter((id) => id !== recordId);
|
|
3013
3169
|
}
|
|
3014
3170
|
targetFolder.recordOrder = [
|
|
3015
3171
|
...rtlMergeOrder(targetFolder.recordOrder).filter((id) => id !== recordId),
|
|
3016
3172
|
recordId
|
|
3017
3173
|
];
|
|
3174
|
+
rtlSetRoot(nextDocuments.root, candidateRoot);
|
|
3175
|
+
nextDocuments.folders = candidateFolders;
|
|
3176
|
+
nextDocuments.records[recordId] = recordDocument;
|
|
3018
3177
|
changed = true;
|
|
3019
3178
|
break;
|
|
3020
3179
|
}
|
|
@@ -3034,6 +3193,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3034
3193
|
}
|
|
3035
3194
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
3036
3195
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
3196
|
+
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
|
|
3037
3197
|
const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
|
|
3038
3198
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3039
3199
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
@@ -3062,6 +3222,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3062
3222
|
reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
|
|
3063
3223
|
break;
|
|
3064
3224
|
}
|
|
3225
|
+
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
|
|
3065
3226
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3066
3227
|
const restoredRecord = applied.documents.records[restoredRecordId];
|
|
3067
3228
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
@@ -3072,10 +3233,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3072
3233
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
3073
3234
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
3074
3235
|
delete nextDocuments.trash[trashEntryId];
|
|
3236
|
+
const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
|
|
3237
|
+
const restoreIndex = Number(trashEntry.originalRecordIndex || 0);
|
|
3075
3238
|
targetFolder.recordOrder = rtlMergeOrder(
|
|
3076
|
-
|
|
3239
|
+
previousRecordOrder.slice(0, restoreIndex),
|
|
3077
3240
|
[restoredRecordId],
|
|
3078
|
-
|
|
3241
|
+
previousRecordOrder.slice(restoreIndex)
|
|
3079
3242
|
);
|
|
3080
3243
|
changed = true;
|
|
3081
3244
|
break;
|
|
@@ -3139,9 +3302,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3139
3302
|
));
|
|
3140
3303
|
} else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
3141
3304
|
appliedRoot.expandedGroups = rtlMergeOrder(
|
|
3142
|
-
rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
|
|
3143
|
-
nextDocuments.root.expandedGroups,
|
|
3144
|
-
normalizedLocalState.expandedGroups
|
|
3305
|
+
rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
|
|
3145
3306
|
);
|
|
3146
3307
|
}
|
|
3147
3308
|
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
@@ -3151,6 +3312,14 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3151
3312
|
default:
|
|
3152
3313
|
reason = 'unsupported_operation';
|
|
3153
3314
|
}
|
|
3315
|
+
if (applied && !reason && lifecycleTombstoneId) {
|
|
3316
|
+
const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
|
|
3317
|
+
if (nextTombstone) {
|
|
3318
|
+
nextDocuments.lifecycleTombstones[lifecycleTombstoneId] = clone(nextTombstone);
|
|
3319
|
+
} else {
|
|
3320
|
+
delete nextDocuments.lifecycleTombstones[lifecycleTombstoneId];
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3154
3323
|
operationResults.push({
|
|
3155
3324
|
id: operation.id || null,
|
|
3156
3325
|
type: operation.type || null,
|
|
@@ -3184,7 +3353,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3184
3353
|
* Counts all billable writes before a transaction reserves quota.
|
|
3185
3354
|
*/
|
|
3186
3355
|
export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
|
|
3187
|
-
const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
|
|
3356
|
+
const collectionWrites = ['records', 'folders', 'trash', 'lifecycleTombstones', 'ops'].reduce((count, key) => (
|
|
3188
3357
|
count + Object.keys(changes?.[key]?.upserts || {}).length +
|
|
3189
3358
|
toArray(changes?.[key]?.deleteIds).length
|
|
3190
3359
|
), 0);
|
|
@@ -3206,6 +3375,15 @@ const rtlStateRecordMap = (state) => {
|
|
|
3206
3375
|
return records;
|
|
3207
3376
|
};
|
|
3208
3377
|
|
|
3378
|
+
const rtlStateRecordOrders = (state) => {
|
|
3379
|
+
const orders = new Map();
|
|
3380
|
+
Object.entries(normalizeState(state || {}).records).forEach(([folderId, entries]) => {
|
|
3381
|
+
if (VIRTUAL_FOLDER_IDS.has(folderId)) return;
|
|
3382
|
+
orders.set(folderId, toArray(entries).map(getRecordId).filter(Boolean));
|
|
3383
|
+
});
|
|
3384
|
+
return orders;
|
|
3385
|
+
};
|
|
3386
|
+
|
|
3209
3387
|
const rtlComparableJson = (value) => JSON.stringify(value || {});
|
|
3210
3388
|
|
|
3211
3389
|
/**
|
|
@@ -3224,6 +3402,11 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3224
3402
|
const nextRecords = rtlStateRecordMap(next);
|
|
3225
3403
|
const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
|
|
3226
3404
|
const nextFolders = new Map(next.folders.map((folder) => [folder.id, folder]));
|
|
3405
|
+
const deletedFolderIds = new Set(
|
|
3406
|
+
[...previousFolders.keys()].filter((folderId) => (
|
|
3407
|
+
!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)
|
|
3408
|
+
))
|
|
3409
|
+
);
|
|
3227
3410
|
const drafts = [];
|
|
3228
3411
|
const bulkDrafts = [];
|
|
3229
3412
|
|
|
@@ -3255,18 +3438,29 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3255
3438
|
});
|
|
3256
3439
|
|
|
3257
3440
|
previousRecords.forEach((entry, recordId) => {
|
|
3258
|
-
if (!nextRecords.has(recordId)) {
|
|
3441
|
+
if (!nextRecords.has(recordId) && !deletedFolderIds.has(entry.folderId)) {
|
|
3259
3442
|
drafts.push({type: OPERATION_TYPES.RECORD_DELETE, payload: {recordId, deletedAt: now}});
|
|
3260
3443
|
}
|
|
3261
3444
|
});
|
|
3262
3445
|
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3446
|
+
// Create prepends and move appends records, so neither operation can reproduce an arbitrary
|
|
3447
|
+
// snapshot position. Emit the complete target order whenever a folder's order differs; preceding
|
|
3448
|
+
// create/move/delete operations make the planner's fallback order contain only live target ids.
|
|
3449
|
+
const previousRecordOrders = rtlStateRecordOrders(previous);
|
|
3450
|
+
rtlStateRecordOrders(next).forEach((nextOrder, folderId) => {
|
|
3451
|
+
const previousOrder = toArray(previousRecordOrders.get(folderId));
|
|
3452
|
+
if (nextOrder.length === 0 || rtlComparableJson(previousOrder) === rtlComparableJson(nextOrder)) return;
|
|
3453
|
+
drafts.push({
|
|
3454
|
+
type: OPERATION_TYPES.RECORD_REORDER,
|
|
3455
|
+
payload: {folderId, recordIds: nextOrder}
|
|
3456
|
+
});
|
|
3457
|
+
});
|
|
3458
|
+
|
|
3459
|
+
deletedFolderIds.forEach((folderId) => {
|
|
3460
|
+
bulkDrafts.push({
|
|
3461
|
+
type: OPERATION_TYPES.FOLDER_DELETE,
|
|
3462
|
+
payload: {folderId, deletedAt: now}
|
|
3463
|
+
});
|
|
3270
3464
|
});
|
|
3271
3465
|
|
|
3272
3466
|
if (rtlComparableJson(previous.folderOrder) !== rtlComparableJson(next.folderOrder)) {
|