@recordtimelabel/core 0.3.2 → 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/package.json +1 -1
- package/src/index.js +218 -40
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
|
});
|
|
@@ -843,7 +847,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
843
847
|
const recordSnapshot = payload.record && typeof payload.record === 'object'
|
|
844
848
|
? {...payload.record, id: recordId}
|
|
845
849
|
: existingEntry?.record;
|
|
846
|
-
|
|
850
|
+
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
851
|
+
if (!recordSnapshot && nextState.trashEntries[trashEntryId]) return normalized;
|
|
847
852
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
848
853
|
const previousGeneration = Math.max(
|
|
849
854
|
Number(recordSnapshot?.lifecycleGeneration || 0),
|
|
@@ -851,7 +856,6 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
851
856
|
);
|
|
852
857
|
const lifecycleGeneration = previousGeneration + 1;
|
|
853
858
|
if (recordSnapshot) {
|
|
854
|
-
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
855
859
|
nextState.trashEntries[trashEntryId] = {
|
|
856
860
|
id: trashEntryId,
|
|
857
861
|
kind: 'record',
|
|
@@ -967,6 +971,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
967
971
|
entityId: folderId,
|
|
968
972
|
batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
|
|
969
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),
|
|
970
976
|
lifecycleGeneration,
|
|
971
977
|
deletedAt,
|
|
972
978
|
purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
|
|
@@ -1036,6 +1042,15 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1036
1042
|
);
|
|
1037
1043
|
folderOrder.splice(restoreIndex, 0, folderId);
|
|
1038
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
|
+
}
|
|
1039
1054
|
delete nextState.deletedFolderTombstones[folderId];
|
|
1040
1055
|
nextState.records[folderId].forEach((record) => {
|
|
1041
1056
|
delete nextState.deletedRecordTombstones[record.id];
|
|
@@ -1585,6 +1600,29 @@ const cleanV2TrashDocument = (entry = {}, now) => {
|
|
|
1585
1600
|
};
|
|
1586
1601
|
};
|
|
1587
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
|
+
|
|
1588
1626
|
export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
|
|
1589
1627
|
const normalized = normalizeState(state || {});
|
|
1590
1628
|
const now = resolveNow(options.now);
|
|
@@ -1620,6 +1658,11 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
|
|
|
1620
1658
|
if (trashDocument) trash[trashDocument.id] = trashDocument;
|
|
1621
1659
|
});
|
|
1622
1660
|
|
|
1661
|
+
const lifecycleTombstones = buildV2LifecycleTombstoneDocuments(
|
|
1662
|
+
normalized.deletedRecordTombstones,
|
|
1663
|
+
normalized.deletedFolderTombstones
|
|
1664
|
+
);
|
|
1665
|
+
|
|
1623
1666
|
const root = {
|
|
1624
1667
|
id: FIRESTORE_V2_SETTINGS_DOC_ID,
|
|
1625
1668
|
schemaVersion: 2,
|
|
@@ -1643,6 +1686,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
|
|
|
1643
1686
|
records,
|
|
1644
1687
|
folders,
|
|
1645
1688
|
trash,
|
|
1689
|
+
lifecycleTombstones,
|
|
1646
1690
|
ops
|
|
1647
1691
|
};
|
|
1648
1692
|
};
|
|
@@ -1739,7 +1783,12 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1739
1783
|
nextDocuments?.trash,
|
|
1740
1784
|
allowDeletes
|
|
1741
1785
|
);
|
|
1742
|
-
const
|
|
1786
|
+
const lifecycleTombstones = buildFirestoreV2CollectionChangeSet(
|
|
1787
|
+
previousDocuments?.lifecycleTombstones,
|
|
1788
|
+
nextDocuments?.lifecycleTombstones,
|
|
1789
|
+
allowDeletes
|
|
1790
|
+
);
|
|
1791
|
+
const hasCollectionChanges = [records, folders, trash, lifecycleTombstones, ops].some((changeSet) => (
|
|
1743
1792
|
Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
|
|
1744
1793
|
));
|
|
1745
1794
|
|
|
@@ -1749,6 +1798,7 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1749
1798
|
records,
|
|
1750
1799
|
folders,
|
|
1751
1800
|
trash,
|
|
1801
|
+
lifecycleTombstones,
|
|
1752
1802
|
ops
|
|
1753
1803
|
};
|
|
1754
1804
|
};
|
|
@@ -1758,11 +1808,54 @@ const removeV2Metadata = (data = {}) => {
|
|
|
1758
1808
|
return rest;
|
|
1759
1809
|
};
|
|
1760
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
|
+
|
|
1761
1851
|
export const buildStateFromFirestoreV2Documents = (documents = {}, options = {}) => {
|
|
1762
1852
|
const rootDoc = documents?.root ||
|
|
1763
1853
|
documents?.settings?.[FIRESTORE_V2_SETTINGS_DOC_ID] ||
|
|
1764
1854
|
documents?.settings ||
|
|
1765
1855
|
{};
|
|
1856
|
+
const lifecycleTombstones = splitLifecycleTombstoneDocuments(
|
|
1857
|
+
documents?.lifecycleTombstones
|
|
1858
|
+
);
|
|
1766
1859
|
const records = {};
|
|
1767
1860
|
mapValuesArray(documents?.records).forEach((recordDoc) => {
|
|
1768
1861
|
const recordId = normalizeId(recordDoc?.id);
|
|
@@ -1820,8 +1913,14 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
|
|
|
1820
1913
|
folderOrder: rootDoc.folderOrder || [],
|
|
1821
1914
|
expandedGroups: rootDoc.expandedGroups || [],
|
|
1822
1915
|
rtlSyncMeta: rootDoc.rtlSyncMeta || {},
|
|
1823
|
-
deletedRecordTombstones:
|
|
1824
|
-
|
|
1916
|
+
deletedRecordTombstones: mergeLifecycleTombstoneSources(
|
|
1917
|
+
rootDoc.deletedRecordTombstones,
|
|
1918
|
+
lifecycleTombstones.records
|
|
1919
|
+
),
|
|
1920
|
+
deletedFolderTombstones: mergeLifecycleTombstoneSources(
|
|
1921
|
+
rootDoc.deletedFolderTombstones,
|
|
1922
|
+
lifecycleTombstones.folders
|
|
1923
|
+
),
|
|
1825
1924
|
trashEntries,
|
|
1826
1925
|
lastModified: rootDoc.lastModified || 0
|
|
1827
1926
|
});
|
|
@@ -2144,7 +2243,7 @@ export const flushPendingOperations = async ({
|
|
|
2144
2243
|
}
|
|
2145
2244
|
|
|
2146
2245
|
const syncTime = resolveNow(now);
|
|
2147
|
-
const storageData = await storageAdapter.load(storageKeys);
|
|
2246
|
+
const storageData = (await storageAdapter.load(storageKeys)) || {};
|
|
2148
2247
|
const latestPendingOps = toArray(storageData.pendingOps);
|
|
2149
2248
|
const requestedIds = new Set(requestedOperations.map((operation) => operation?.id).filter(Boolean));
|
|
2150
2249
|
const pendingOpsForPayload = latestPendingOps.filter((operation) => requestedIds.has(operation?.id));
|
|
@@ -2622,16 +2721,23 @@ const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
|
2622
2721
|
const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
|
|
2623
2722
|
operation?.payload?.trashEntryId || operation?.payload?.id
|
|
2624
2723
|
);
|
|
2724
|
+
const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
|
|
2725
|
+
encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
|
|
2726
|
+
);
|
|
2625
2727
|
const rtlCloneDocuments = (documents = {}) => ({
|
|
2626
2728
|
root: clone(documents.root || null),
|
|
2627
2729
|
records: clone(documents.records || {}),
|
|
2628
2730
|
folders: clone(documents.folders || {}),
|
|
2629
2731
|
trash: clone(documents.trash || {}),
|
|
2732
|
+
lifecycleTombstones: clone(documents.lifecycleTombstones || {}),
|
|
2630
2733
|
ops: {}
|
|
2631
2734
|
});
|
|
2632
2735
|
const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
|
|
2633
|
-
|
|
2634
|
-
|
|
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);
|
|
2635
2741
|
const rtlPayloadOrder = (operation, keys) => {
|
|
2636
2742
|
for (const key of keys) {
|
|
2637
2743
|
if (Array.isArray(operation?.payload?.[key])) return operation.payload[key];
|
|
@@ -2811,13 +2917,17 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2811
2917
|
const folderIds = new Set();
|
|
2812
2918
|
const folderDeleteIds = new Set();
|
|
2813
2919
|
const trashEntryIds = new Set();
|
|
2920
|
+
const lifecycleTombstoneIds = new Set();
|
|
2814
2921
|
toArray(operations).filter(Boolean).forEach((operation) => {
|
|
2815
2922
|
const payload = operation.payload || {};
|
|
2816
2923
|
switch (operation.type) {
|
|
2817
2924
|
case OPERATION_TYPES.RECORD_CREATE:
|
|
2818
2925
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
2819
2926
|
const recordId = rtlOperationRecordId(operation);
|
|
2820
|
-
if (recordId)
|
|
2927
|
+
if (recordId) {
|
|
2928
|
+
recordIds.add(recordId);
|
|
2929
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
2930
|
+
}
|
|
2821
2931
|
folderIds.add(safeFolderId(
|
|
2822
2932
|
payload.targetFolderId || payload.folderId || payload.record?.folderId
|
|
2823
2933
|
));
|
|
@@ -2826,7 +2936,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2826
2936
|
case OPERATION_TYPES.RECORD_UPDATE:
|
|
2827
2937
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
2828
2938
|
const recordId = rtlOperationRecordId(operation);
|
|
2829
|
-
if (recordId)
|
|
2939
|
+
if (recordId) {
|
|
2940
|
+
recordIds.add(recordId);
|
|
2941
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
2942
|
+
}
|
|
2830
2943
|
break;
|
|
2831
2944
|
}
|
|
2832
2945
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
@@ -2842,7 +2955,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2842
2955
|
case OPERATION_TYPES.FOLDER_UPDATE:
|
|
2843
2956
|
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
2844
2957
|
const folderId = rtlOperationFolderId(operation);
|
|
2845
|
-
if (folderId)
|
|
2958
|
+
if (folderId) {
|
|
2959
|
+
folderIds.add(folderId);
|
|
2960
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', folderId));
|
|
2961
|
+
}
|
|
2846
2962
|
if (folderId && operation.type === OPERATION_TYPES.FOLDER_DELETE) {
|
|
2847
2963
|
folderDeleteIds.add(folderId);
|
|
2848
2964
|
}
|
|
@@ -2852,7 +2968,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2852
2968
|
break;
|
|
2853
2969
|
}
|
|
2854
2970
|
});
|
|
2855
|
-
return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
|
|
2971
|
+
return {recordIds, folderIds, folderDeleteIds, trashEntryIds, lifecycleTombstoneIds};
|
|
2856
2972
|
};
|
|
2857
2973
|
|
|
2858
2974
|
export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
|
|
@@ -2860,7 +2976,8 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
|
|
|
2860
2976
|
recordIds: new Set(readPlan?.recordIds || []),
|
|
2861
2977
|
folderIds: new Set(readPlan?.folderIds || []),
|
|
2862
2978
|
folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
|
|
2863
|
-
trashEntryIds: new Set(readPlan?.trashEntryIds || [])
|
|
2979
|
+
trashEntryIds: new Set(readPlan?.trashEntryIds || []),
|
|
2980
|
+
lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
|
|
2864
2981
|
};
|
|
2865
2982
|
Object.values(records || {}).forEach((record) => {
|
|
2866
2983
|
next.folderIds.add(safeFolderId(record?.folderId));
|
|
@@ -2873,18 +2990,40 @@ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntri
|
|
|
2873
2990
|
recordIds: new Set(readPlan?.recordIds || []),
|
|
2874
2991
|
folderIds: new Set(readPlan?.folderIds || []),
|
|
2875
2992
|
folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
|
|
2876
|
-
trashEntryIds: new Set(readPlan?.trashEntryIds || [])
|
|
2993
|
+
trashEntryIds: new Set(readPlan?.trashEntryIds || []),
|
|
2994
|
+
lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
|
|
2877
2995
|
};
|
|
2878
2996
|
Object.values(trashEntries || {}).forEach((entry) => {
|
|
2879
|
-
|
|
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
|
+
}
|
|
2880
3006
|
});
|
|
2881
3007
|
next.folderIds.add(DEFAULT_FOLDER_ID);
|
|
2882
3008
|
return next;
|
|
2883
3009
|
};
|
|
2884
3010
|
|
|
2885
|
-
const rtlApplyOperationToPartialDocuments = ({
|
|
3011
|
+
const rtlApplyOperationToPartialDocuments = ({
|
|
3012
|
+
root,
|
|
3013
|
+
records,
|
|
3014
|
+
folders,
|
|
3015
|
+
trash,
|
|
3016
|
+
lifecycleTombstones,
|
|
3017
|
+
operation,
|
|
3018
|
+
now
|
|
3019
|
+
}) => {
|
|
2886
3020
|
const {state} = buildStateFromFirestoreV2Documents({
|
|
2887
|
-
root: root || {},
|
|
3021
|
+
root: root || {},
|
|
3022
|
+
records: records || {},
|
|
3023
|
+
folders: folders || {},
|
|
3024
|
+
trash: trash || {},
|
|
3025
|
+
lifecycleTombstones: lifecycleTombstones || {},
|
|
3026
|
+
ops: {}
|
|
2888
3027
|
});
|
|
2889
3028
|
const nextState = applyRecordTimeLabelOperation(state, operation);
|
|
2890
3029
|
return {
|
|
@@ -2950,6 +3089,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
2950
3089
|
const folderId = rtlOperationFolderId(operation);
|
|
2951
3090
|
let applied = null;
|
|
2952
3091
|
let reason = null;
|
|
3092
|
+
let lifecycleTombstoneId = null;
|
|
2953
3093
|
|
|
2954
3094
|
switch (operation.type) {
|
|
2955
3095
|
case OPERATION_TYPES.RECORD_CREATE: {
|
|
@@ -3012,25 +3152,28 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3012
3152
|
});
|
|
3013
3153
|
const recordDocument = applied.documents.records[recordId];
|
|
3014
3154
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
3155
|
+
const candidateRoot = clone(applied.documents.root);
|
|
3156
|
+
const candidateFolders = clone(nextDocuments.folders);
|
|
3015
3157
|
const targetFolder = rtlEnsureTargetFolder({
|
|
3016
|
-
root:
|
|
3017
|
-
folders:
|
|
3158
|
+
root: candidateRoot,
|
|
3159
|
+
folders: candidateFolders,
|
|
3018
3160
|
localState: normalizedLocalState,
|
|
3019
3161
|
folderId: targetFolderId,
|
|
3020
3162
|
now: operationNow
|
|
3021
3163
|
});
|
|
3022
3164
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
3027
|
-
nextDocuments.folders[sourceFolderId].recordOrder
|
|
3165
|
+
if (candidateFolders[sourceFolderId]) {
|
|
3166
|
+
candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
3167
|
+
candidateFolders[sourceFolderId].recordOrder
|
|
3028
3168
|
).filter((id) => id !== recordId);
|
|
3029
3169
|
}
|
|
3030
3170
|
targetFolder.recordOrder = [
|
|
3031
3171
|
...rtlMergeOrder(targetFolder.recordOrder).filter((id) => id !== recordId),
|
|
3032
3172
|
recordId
|
|
3033
3173
|
];
|
|
3174
|
+
rtlSetRoot(nextDocuments.root, candidateRoot);
|
|
3175
|
+
nextDocuments.folders = candidateFolders;
|
|
3176
|
+
nextDocuments.records[recordId] = recordDocument;
|
|
3034
3177
|
changed = true;
|
|
3035
3178
|
break;
|
|
3036
3179
|
}
|
|
@@ -3050,6 +3193,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3050
3193
|
}
|
|
3051
3194
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
3052
3195
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
3196
|
+
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
|
|
3053
3197
|
const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
|
|
3054
3198
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3055
3199
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
@@ -3078,6 +3222,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3078
3222
|
reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
|
|
3079
3223
|
break;
|
|
3080
3224
|
}
|
|
3225
|
+
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
|
|
3081
3226
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3082
3227
|
const restoredRecord = applied.documents.records[restoredRecordId];
|
|
3083
3228
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
@@ -3088,10 +3233,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3088
3233
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
3089
3234
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
3090
3235
|
delete nextDocuments.trash[trashEntryId];
|
|
3236
|
+
const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
|
|
3237
|
+
const restoreIndex = Number(trashEntry.originalRecordIndex || 0);
|
|
3091
3238
|
targetFolder.recordOrder = rtlMergeOrder(
|
|
3092
|
-
|
|
3239
|
+
previousRecordOrder.slice(0, restoreIndex),
|
|
3093
3240
|
[restoredRecordId],
|
|
3094
|
-
|
|
3241
|
+
previousRecordOrder.slice(restoreIndex)
|
|
3095
3242
|
);
|
|
3096
3243
|
changed = true;
|
|
3097
3244
|
break;
|
|
@@ -3155,9 +3302,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3155
3302
|
));
|
|
3156
3303
|
} else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
3157
3304
|
appliedRoot.expandedGroups = rtlMergeOrder(
|
|
3158
|
-
rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
|
|
3159
|
-
nextDocuments.root.expandedGroups,
|
|
3160
|
-
normalizedLocalState.expandedGroups
|
|
3305
|
+
rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
|
|
3161
3306
|
);
|
|
3162
3307
|
}
|
|
3163
3308
|
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
@@ -3167,6 +3312,14 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3167
3312
|
default:
|
|
3168
3313
|
reason = 'unsupported_operation';
|
|
3169
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
|
+
}
|
|
3170
3323
|
operationResults.push({
|
|
3171
3324
|
id: operation.id || null,
|
|
3172
3325
|
type: operation.type || null,
|
|
@@ -3200,7 +3353,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3200
3353
|
* Counts all billable writes before a transaction reserves quota.
|
|
3201
3354
|
*/
|
|
3202
3355
|
export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
|
|
3203
|
-
const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
|
|
3356
|
+
const collectionWrites = ['records', 'folders', 'trash', 'lifecycleTombstones', 'ops'].reduce((count, key) => (
|
|
3204
3357
|
count + Object.keys(changes?.[key]?.upserts || {}).length +
|
|
3205
3358
|
toArray(changes?.[key]?.deleteIds).length
|
|
3206
3359
|
), 0);
|
|
@@ -3222,6 +3375,15 @@ const rtlStateRecordMap = (state) => {
|
|
|
3222
3375
|
return records;
|
|
3223
3376
|
};
|
|
3224
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
|
+
|
|
3225
3387
|
const rtlComparableJson = (value) => JSON.stringify(value || {});
|
|
3226
3388
|
|
|
3227
3389
|
/**
|
|
@@ -3240,6 +3402,11 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3240
3402
|
const nextRecords = rtlStateRecordMap(next);
|
|
3241
3403
|
const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
|
|
3242
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
|
+
);
|
|
3243
3410
|
const drafts = [];
|
|
3244
3411
|
const bulkDrafts = [];
|
|
3245
3412
|
|
|
@@ -3271,18 +3438,29 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3271
3438
|
});
|
|
3272
3439
|
|
|
3273
3440
|
previousRecords.forEach((entry, recordId) => {
|
|
3274
|
-
if (!nextRecords.has(recordId)) {
|
|
3441
|
+
if (!nextRecords.has(recordId) && !deletedFolderIds.has(entry.folderId)) {
|
|
3275
3442
|
drafts.push({type: OPERATION_TYPES.RECORD_DELETE, payload: {recordId, deletedAt: now}});
|
|
3276
3443
|
}
|
|
3277
3444
|
});
|
|
3278
3445
|
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
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
|
+
});
|
|
3286
3464
|
});
|
|
3287
3465
|
|
|
3288
3466
|
if (rtlComparableJson(previous.folderOrder) !== rtlComparableJson(next.folderOrder)) {
|