@recordtimelabel/core 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -2
- package/package.json +5 -2
- package/src/compat.js +8 -0
- package/src/firestore-v2.js +27 -0
- package/src/index.js +1345 -59
- package/src/protocol.js +128 -0
package/src/index.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
3
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
4
|
+
normalizeRecordTimeLabelOperationResults
|
|
5
|
+
} from './protocol.js';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
9
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
10
|
+
normalizeRecordTimeLabelOperationResults
|
|
11
|
+
} from './protocol.js';
|
|
12
|
+
|
|
1
13
|
const DEFAULT_FOLDER_ID = 'uncategorized';
|
|
2
14
|
const VIRTUAL_FOLDER_IDS = new Set(['all']);
|
|
3
15
|
const REQUIRED_FOLDERS = [
|
|
@@ -5,7 +17,7 @@ const REQUIRED_FOLDERS = [
|
|
|
5
17
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
6
18
|
];
|
|
7
19
|
|
|
8
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.
|
|
20
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.0';
|
|
9
21
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
10
22
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
11
23
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
@@ -52,6 +64,11 @@ export const RECORD_TIMELABEL_CLOUD_SCHEMAS = Object.freeze({
|
|
|
52
64
|
|
|
53
65
|
export const FIRESTORE_V2_SETTINGS_DOC_ID = 'main';
|
|
54
66
|
|
|
67
|
+
export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
|
|
68
|
+
OWNER_MISMATCH: 'operation_owner_mismatch',
|
|
69
|
+
WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch'
|
|
70
|
+
});
|
|
71
|
+
|
|
55
72
|
const toArray = (value) => (Array.isArray(value) ? value : []);
|
|
56
73
|
const clone = (value, seen = new WeakMap()) => {
|
|
57
74
|
if (value === undefined || value === null || typeof value !== 'object') return value;
|
|
@@ -220,7 +237,11 @@ const mergeTombstones = (left = {}, right = {}) => {
|
|
|
220
237
|
const merged = { ...left };
|
|
221
238
|
Object.entries(right).forEach(([id, tombstone]) => {
|
|
222
239
|
const current = merged[id];
|
|
223
|
-
|
|
240
|
+
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
241
|
+
const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
|
|
242
|
+
if (!current || nextGeneration > currentGeneration ||
|
|
243
|
+
(nextGeneration === currentGeneration &&
|
|
244
|
+
toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt))) {
|
|
224
245
|
merged[id] = { ...tombstone };
|
|
225
246
|
}
|
|
226
247
|
});
|
|
@@ -558,6 +579,49 @@ export const normalizeState = (input = {}) => {
|
|
|
558
579
|
};
|
|
559
580
|
};
|
|
560
581
|
|
|
582
|
+
// `expandedGroups` is renderer navigation state. Keep the legacy normalizer
|
|
583
|
+
// compatible for compat callers, but make the durable/core domain boundary
|
|
584
|
+
// explicit so new engine and Firestore paths never carry it as cloud data.
|
|
585
|
+
export const normalizeRecordTimeLabelDomainState = (input = {}) => {
|
|
586
|
+
const normalized = normalizeState(input);
|
|
587
|
+
const domain = {...normalized};
|
|
588
|
+
delete domain.expandedGroups;
|
|
589
|
+
return domain;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const expandedGroupsOperationType = 'expandedGroups.update';
|
|
593
|
+
|
|
594
|
+
const expandedGroupsFromOperation = (operation = {}) => {
|
|
595
|
+
const payload = operation?.payload || {};
|
|
596
|
+
const values = [payload.expandedGroups, payload.groupIds, payload.order, payload.ids]
|
|
597
|
+
.find((candidate) => Array.isArray(candidate)) || [];
|
|
598
|
+
return normalizeOrder(values);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Migrate legacy view operations without inventing a cloud operation. Applying
|
|
603
|
+
* the operations in request order makes the helper idempotent after the
|
|
604
|
+
* returned operations are persisted and removed from the outbox.
|
|
605
|
+
*/
|
|
606
|
+
export const migrateRecordTimeLabelExpandedGroups = ({
|
|
607
|
+
pendingOperations = [],
|
|
608
|
+
currentView = []
|
|
609
|
+
} = {}) => {
|
|
610
|
+
let expandedGroups = normalizeOrder(currentView);
|
|
611
|
+
const remainingOperations = [];
|
|
612
|
+
toArray(pendingOperations).forEach((operation) => {
|
|
613
|
+
if (operation?.type !== expandedGroupsOperationType) {
|
|
614
|
+
remainingOperations.push(clone(operation));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
expandedGroups = expandedGroupsFromOperation(operation);
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
expandedGroups,
|
|
621
|
+
pendingOperations: remainingOperations
|
|
622
|
+
};
|
|
623
|
+
};
|
|
624
|
+
|
|
561
625
|
export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {}) => {
|
|
562
626
|
const state = normalizeState(input || {});
|
|
563
627
|
const defaultFolderIds = new Set(options.defaultFolderIds || ['all', 'uncategorized']);
|
|
@@ -578,8 +642,7 @@ export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {})
|
|
|
578
642
|
Object.keys(state.deletedFolderTombstones || {}).length > 0 ||
|
|
579
643
|
Object.keys(state.trashEntries || {}).length > 0 ||
|
|
580
644
|
hasNonDefaultGroupOrder ||
|
|
581
|
-
state.folderOrder.length > 0
|
|
582
|
-
state.expandedGroups.length > 0;
|
|
645
|
+
state.folderOrder.length > 0;
|
|
583
646
|
};
|
|
584
647
|
|
|
585
648
|
export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
|
|
@@ -590,7 +653,6 @@ export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
|
|
|
590
653
|
settings: state.settings,
|
|
591
654
|
groupOrder: state.groupOrder,
|
|
592
655
|
folderOrder: state.folderOrder,
|
|
593
|
-
expandedGroups: state.expandedGroups,
|
|
594
656
|
deletedRecordTombstones: state.deletedRecordTombstones,
|
|
595
657
|
deletedFolderTombstones: state.deletedFolderTombstones,
|
|
596
658
|
trashEntries: state.trashEntries
|
|
@@ -843,7 +905,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
843
905
|
const recordSnapshot = payload.record && typeof payload.record === 'object'
|
|
844
906
|
? {...payload.record, id: recordId}
|
|
845
907
|
: existingEntry?.record;
|
|
846
|
-
|
|
908
|
+
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
909
|
+
if (!recordSnapshot && nextState.trashEntries[trashEntryId]) return normalized;
|
|
847
910
|
const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
|
|
848
911
|
const previousGeneration = Math.max(
|
|
849
912
|
Number(recordSnapshot?.lifecycleGeneration || 0),
|
|
@@ -851,7 +914,6 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
851
914
|
);
|
|
852
915
|
const lifecycleGeneration = previousGeneration + 1;
|
|
853
916
|
if (recordSnapshot) {
|
|
854
|
-
const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
|
|
855
917
|
nextState.trashEntries[trashEntryId] = {
|
|
856
918
|
id: trashEntryId,
|
|
857
919
|
kind: 'record',
|
|
@@ -967,6 +1029,8 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
967
1029
|
entityId: folderId,
|
|
968
1030
|
batchId: normalizeId(payload.batchId) || operation.id || trashEntryId,
|
|
969
1031
|
originalFolderOrderIndex: Math.max(0, nextState.folderOrder.indexOf(folderId)),
|
|
1032
|
+
// -1 records "was not in groupOrder", so restore does not invent a placement.
|
|
1033
|
+
originalGroupOrderIndex: nextState.groupOrder.indexOf(folderId),
|
|
970
1034
|
lifecycleGeneration,
|
|
971
1035
|
deletedAt,
|
|
972
1036
|
purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
|
|
@@ -1036,6 +1100,15 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1036
1100
|
);
|
|
1037
1101
|
folderOrder.splice(restoreIndex, 0, folderId);
|
|
1038
1102
|
nextState.folderOrder = folderOrder;
|
|
1103
|
+
// folder.delete strips the id from groupOrder too, so restore has to put it back.
|
|
1104
|
+
const originalGroupOrderIndex = Number.isInteger(trashEntry.originalGroupOrderIndex)
|
|
1105
|
+
? trashEntry.originalGroupOrderIndex
|
|
1106
|
+
: -1;
|
|
1107
|
+
if (originalGroupOrderIndex >= 0) {
|
|
1108
|
+
const groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
|
|
1109
|
+
groupOrder.splice(Math.min(originalGroupOrderIndex, groupOrder.length), 0, folderId);
|
|
1110
|
+
nextState.groupOrder = groupOrder;
|
|
1111
|
+
}
|
|
1039
1112
|
delete nextState.deletedFolderTombstones[folderId];
|
|
1040
1113
|
nextState.records[folderId].forEach((record) => {
|
|
1041
1114
|
delete nextState.deletedRecordTombstones[record.id];
|
|
@@ -1100,8 +1173,9 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1100
1173
|
break;
|
|
1101
1174
|
|
|
1102
1175
|
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
1103
|
-
|
|
1104
|
-
|
|
1176
|
+
// Legacy view operations are migrated by the app adapter and are never
|
|
1177
|
+
// part of the cloud/domain reducer.
|
|
1178
|
+
return normalized;
|
|
1105
1179
|
|
|
1106
1180
|
case OPERATION_TYPES.SETTINGS_UPDATE:
|
|
1107
1181
|
nextState.settings = {
|
|
@@ -1585,6 +1659,29 @@ const cleanV2TrashDocument = (entry = {}, now) => {
|
|
|
1585
1659
|
};
|
|
1586
1660
|
};
|
|
1587
1661
|
|
|
1662
|
+
const buildV2LifecycleTombstoneDocuments = (
|
|
1663
|
+
deletedRecordTombstones = {},
|
|
1664
|
+
deletedFolderTombstones = {}
|
|
1665
|
+
) => {
|
|
1666
|
+
const documents = {};
|
|
1667
|
+
const append = (kind, tombstones) => {
|
|
1668
|
+
Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
|
|
1669
|
+
const id = `${kind}:${entityId}`;
|
|
1670
|
+
documents[encodeURIComponent(id)] = {
|
|
1671
|
+
id,
|
|
1672
|
+
kind,
|
|
1673
|
+
entityId,
|
|
1674
|
+
lifecycleGeneration: Math.max(1, Number(tombstone.lifecycleGeneration || 1)),
|
|
1675
|
+
deletedAt: toFiniteTimestamp(tombstone.deletedAt),
|
|
1676
|
+
schemaVersion: 2
|
|
1677
|
+
};
|
|
1678
|
+
});
|
|
1679
|
+
};
|
|
1680
|
+
append('record', deletedRecordTombstones);
|
|
1681
|
+
append('folder', deletedFolderTombstones);
|
|
1682
|
+
return documents;
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1588
1685
|
export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
|
|
1589
1686
|
const normalized = normalizeState(state || {});
|
|
1590
1687
|
const now = resolveNow(options.now);
|
|
@@ -1620,13 +1717,17 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
|
|
|
1620
1717
|
if (trashDocument) trash[trashDocument.id] = trashDocument;
|
|
1621
1718
|
});
|
|
1622
1719
|
|
|
1720
|
+
const lifecycleTombstones = buildV2LifecycleTombstoneDocuments(
|
|
1721
|
+
normalized.deletedRecordTombstones,
|
|
1722
|
+
normalized.deletedFolderTombstones
|
|
1723
|
+
);
|
|
1724
|
+
|
|
1623
1725
|
const root = {
|
|
1624
1726
|
id: FIRESTORE_V2_SETTINGS_DOC_ID,
|
|
1625
1727
|
schemaVersion: 2,
|
|
1626
1728
|
settings: normalized.settings || {},
|
|
1627
1729
|
groupOrder: normalizeGroupOrderForOption(normalized.groupOrder, options.groupOrderNormalizer),
|
|
1628
1730
|
folderOrder: normalized.folderOrder,
|
|
1629
|
-
expandedGroups: normalized.expandedGroups,
|
|
1630
1731
|
rtlSyncMeta: {
|
|
1631
1732
|
...(normalized.rtlSyncMeta || {}),
|
|
1632
1733
|
schemaVersion: 2,
|
|
@@ -1643,6 +1744,7 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
|
|
|
1643
1744
|
records,
|
|
1644
1745
|
folders,
|
|
1645
1746
|
trash,
|
|
1747
|
+
lifecycleTombstones,
|
|
1646
1748
|
ops
|
|
1647
1749
|
};
|
|
1648
1750
|
};
|
|
@@ -1716,8 +1818,15 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1716
1818
|
const allowDeletes = options.allowDeletes === true;
|
|
1717
1819
|
const previousRoot = previousDocuments?.root;
|
|
1718
1820
|
const nextRoot = nextDocuments?.root;
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1821
|
+
const rootForComparison = nextRoot ? {...nextRoot} : nextRoot;
|
|
1822
|
+
if (rootForComparison && previousRoot &&
|
|
1823
|
+
Object.prototype.hasOwnProperty.call(previousRoot, 'expandedGroups')) {
|
|
1824
|
+
rootForComparison.expandedGroups = previousRoot.expandedGroups;
|
|
1825
|
+
} else if (rootForComparison) {
|
|
1826
|
+
delete rootForComparison.expandedGroups;
|
|
1827
|
+
}
|
|
1828
|
+
const rootUpsert = rootForComparison && !areFirestoreV2DocumentValuesEqual(previousRoot, rootForComparison)
|
|
1829
|
+
? rootForComparison
|
|
1721
1830
|
: null;
|
|
1722
1831
|
const records = buildFirestoreV2CollectionChangeSet(
|
|
1723
1832
|
previousDocuments?.records,
|
|
@@ -1739,7 +1848,12 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1739
1848
|
nextDocuments?.trash,
|
|
1740
1849
|
allowDeletes
|
|
1741
1850
|
);
|
|
1742
|
-
const
|
|
1851
|
+
const lifecycleTombstones = buildFirestoreV2CollectionChangeSet(
|
|
1852
|
+
previousDocuments?.lifecycleTombstones,
|
|
1853
|
+
nextDocuments?.lifecycleTombstones,
|
|
1854
|
+
allowDeletes
|
|
1855
|
+
);
|
|
1856
|
+
const hasCollectionChanges = [records, folders, trash, lifecycleTombstones, ops].some((changeSet) => (
|
|
1743
1857
|
Object.keys(changeSet.upserts).length > 0 || changeSet.deleteIds.length > 0
|
|
1744
1858
|
));
|
|
1745
1859
|
|
|
@@ -1749,6 +1863,7 @@ export const buildFirestoreV2DocumentChangeSet = (
|
|
|
1749
1863
|
records,
|
|
1750
1864
|
folders,
|
|
1751
1865
|
trash,
|
|
1866
|
+
lifecycleTombstones,
|
|
1752
1867
|
ops
|
|
1753
1868
|
};
|
|
1754
1869
|
};
|
|
@@ -1758,11 +1873,54 @@ const removeV2Metadata = (data = {}) => {
|
|
|
1758
1873
|
return rest;
|
|
1759
1874
|
};
|
|
1760
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* lifecycleTombstones 子集合的文件形狀是 { id: 'record:abc', kind, entityId, lifecycleGeneration,
|
|
1878
|
+
* deletedAt },拆成 record / folder 兩張以 entityId 為鍵的表,好跟 root 上的舊資料合併。
|
|
1879
|
+
*/
|
|
1880
|
+
const splitLifecycleTombstoneDocuments = (value) => {
|
|
1881
|
+
const records = {};
|
|
1882
|
+
const folders = {};
|
|
1883
|
+
mapValuesArray(value).forEach((entry) => {
|
|
1884
|
+
const entityId = normalizeId(entry?.entityId);
|
|
1885
|
+
const kind = normalizeId(entry?.kind);
|
|
1886
|
+
const target = kind === 'folder' ? folders : kind === 'record' ? records : null;
|
|
1887
|
+
if (!entityId || !target) return;
|
|
1888
|
+
target[entityId] = {
|
|
1889
|
+
id: entityId,
|
|
1890
|
+
lifecycleGeneration: Math.max(1, Number(entry.lifecycleGeneration || 1)),
|
|
1891
|
+
deletedAt: toFiniteTimestamp(entry.deletedAt)
|
|
1892
|
+
};
|
|
1893
|
+
});
|
|
1894
|
+
return { records, folders };
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* root 的 tombstone 表已停止成長,但既有帳號上仍留著歷史資料,因此兩邊都要讀。同一個 id 同時出現時
|
|
1899
|
+
* 以世代優先、時間次之,跟 dropSupersededLifecycleDeletions 的判準一致。
|
|
1900
|
+
*/
|
|
1901
|
+
const mergeLifecycleTombstoneSources = (legacy = {}, subcollection = {}) => {
|
|
1902
|
+
const merged = normalizeTombstones(legacy);
|
|
1903
|
+
Object.entries(normalizeTombstones(subcollection)).forEach(([id, tombstone]) => {
|
|
1904
|
+
const current = merged[id];
|
|
1905
|
+
const currentGeneration = Number(current?.lifecycleGeneration || 0);
|
|
1906
|
+
const nextGeneration = Number(tombstone.lifecycleGeneration || 0);
|
|
1907
|
+
if (!current || nextGeneration > currentGeneration ||
|
|
1908
|
+
(nextGeneration === currentGeneration &&
|
|
1909
|
+
toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt))) {
|
|
1910
|
+
merged[id] = tombstone;
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
return merged;
|
|
1914
|
+
};
|
|
1915
|
+
|
|
1761
1916
|
export const buildStateFromFirestoreV2Documents = (documents = {}, options = {}) => {
|
|
1762
1917
|
const rootDoc = documents?.root ||
|
|
1763
1918
|
documents?.settings?.[FIRESTORE_V2_SETTINGS_DOC_ID] ||
|
|
1764
1919
|
documents?.settings ||
|
|
1765
1920
|
{};
|
|
1921
|
+
const lifecycleTombstones = splitLifecycleTombstoneDocuments(
|
|
1922
|
+
documents?.lifecycleTombstones
|
|
1923
|
+
);
|
|
1766
1924
|
const records = {};
|
|
1767
1925
|
mapValuesArray(documents?.records).forEach((recordDoc) => {
|
|
1768
1926
|
const recordId = normalizeId(recordDoc?.id);
|
|
@@ -1818,10 +1976,15 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
|
|
|
1818
1976
|
settings: rootDoc.settings || {},
|
|
1819
1977
|
groupOrder: normalizeGroupOrderForOption(rootDoc.groupOrder, options.groupOrderNormalizer),
|
|
1820
1978
|
folderOrder: rootDoc.folderOrder || [],
|
|
1821
|
-
expandedGroups: rootDoc.expandedGroups || [],
|
|
1822
1979
|
rtlSyncMeta: rootDoc.rtlSyncMeta || {},
|
|
1823
|
-
deletedRecordTombstones:
|
|
1824
|
-
|
|
1980
|
+
deletedRecordTombstones: mergeLifecycleTombstoneSources(
|
|
1981
|
+
rootDoc.deletedRecordTombstones,
|
|
1982
|
+
lifecycleTombstones.records
|
|
1983
|
+
),
|
|
1984
|
+
deletedFolderTombstones: mergeLifecycleTombstoneSources(
|
|
1985
|
+
rootDoc.deletedFolderTombstones,
|
|
1986
|
+
lifecycleTombstones.folders
|
|
1987
|
+
),
|
|
1825
1988
|
trashEntries,
|
|
1826
1989
|
lastModified: rootDoc.lastModified || 0
|
|
1827
1990
|
});
|
|
@@ -2144,7 +2307,7 @@ export const flushPendingOperations = async ({
|
|
|
2144
2307
|
}
|
|
2145
2308
|
|
|
2146
2309
|
const syncTime = resolveNow(now);
|
|
2147
|
-
const storageData = await storageAdapter.load(storageKeys);
|
|
2310
|
+
const storageData = (await storageAdapter.load(storageKeys)) || {};
|
|
2148
2311
|
const latestPendingOps = toArray(storageData.pendingOps);
|
|
2149
2312
|
const requestedIds = new Set(requestedOperations.map((operation) => operation?.id).filter(Boolean));
|
|
2150
2313
|
const pendingOpsForPayload = latestPendingOps.filter((operation) => requestedIds.has(operation?.id));
|
|
@@ -2439,6 +2602,1037 @@ export const createRecordTimeLabelController = ({
|
|
|
2439
2602
|
return controller;
|
|
2440
2603
|
};
|
|
2441
2604
|
|
|
2605
|
+
/*
|
|
2606
|
+
* Durable workspace engine
|
|
2607
|
+
*
|
|
2608
|
+
* The v1 engine above deliberately keeps its old storage/cloud contract. The
|
|
2609
|
+
* engine below is the platform-neutral contract used by newer adapters. It
|
|
2610
|
+
* stores a remote baseline and a queue of operations, then derives the visible
|
|
2611
|
+
* state by replaying that queue. Keeping those three concerns separate is what
|
|
2612
|
+
* makes a restart, a rejected operation, and a remote snapshot converge to the
|
|
2613
|
+
* same result.
|
|
2614
|
+
*/
|
|
2615
|
+
const RTL_DURABLE_SCHEMA_VERSION = 1;
|
|
2616
|
+
const RTL_RETRY_BASE_MS = 1000;
|
|
2617
|
+
const RTL_RETRY_MAX_MS = 60 * 1000;
|
|
2618
|
+
|
|
2619
|
+
const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
2620
|
+
|
|
2621
|
+
const rtlToFiniteNumber = (value, fallback = 0) => {
|
|
2622
|
+
const number = Number(value);
|
|
2623
|
+
return Number.isFinite(number) ? number : fallback;
|
|
2624
|
+
};
|
|
2625
|
+
|
|
2626
|
+
const rtlNormalizeUid = (value) => {
|
|
2627
|
+
if (value === undefined || value === null) return null;
|
|
2628
|
+
const uid = String(value).trim();
|
|
2629
|
+
return uid || null;
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
const rtlNormalizeWorkspaceEpoch = (value, fallback = 0) => {
|
|
2633
|
+
if (value === undefined || value === null || value === '') {
|
|
2634
|
+
value = fallback;
|
|
2635
|
+
}
|
|
2636
|
+
const number = Number(value);
|
|
2637
|
+
if (Number.isFinite(number)) return Math.max(0, Math.floor(number));
|
|
2638
|
+
const fallbackNumber = Number(fallback);
|
|
2639
|
+
return Number.isFinite(fallbackNumber)
|
|
2640
|
+
? Math.max(0, Math.floor(fallbackNumber))
|
|
2641
|
+
: 0;
|
|
2642
|
+
};
|
|
2643
|
+
|
|
2644
|
+
const rtlCloneOperation = (operation) => clone(operation && typeof operation === 'object' ? operation : {});
|
|
2645
|
+
|
|
2646
|
+
const rtlStripSessionTokens = (value) => {
|
|
2647
|
+
if (Array.isArray(value)) return value.map((entry) => rtlStripSessionTokens(entry));
|
|
2648
|
+
if (!rtlDurableIsObject(value)) return value;
|
|
2649
|
+
const result = {};
|
|
2650
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
2651
|
+
if (key === 'sessionToken' || key === 'sessionTokenId' || key === 'authToken') return;
|
|
2652
|
+
result[key] = rtlStripSessionTokens(entry);
|
|
2653
|
+
});
|
|
2654
|
+
return result;
|
|
2655
|
+
};
|
|
2656
|
+
|
|
2657
|
+
const rtlNormalizeOperation = (operation = {}, {
|
|
2658
|
+
client,
|
|
2659
|
+
clientId,
|
|
2660
|
+
now,
|
|
2661
|
+
ownerUid = null,
|
|
2662
|
+
workspaceEpoch = 0
|
|
2663
|
+
} = {}) => {
|
|
2664
|
+
const input = operation && typeof operation === 'object' ? operation : {};
|
|
2665
|
+
const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
|
|
2666
|
+
const normalizedPayload = payload;
|
|
2667
|
+
const trimPayloadId = (key) => {
|
|
2668
|
+
if (Object.prototype.hasOwnProperty.call(normalizedPayload, key)) {
|
|
2669
|
+
normalizedPayload[key] = normalizeId(normalizedPayload[key]) || normalizedPayload[key];
|
|
2670
|
+
}
|
|
2671
|
+
};
|
|
2672
|
+
[
|
|
2673
|
+
'recordId', 'folderId', 'targetFolderId', 'trashEntryId', 'batchId', 'id',
|
|
2674
|
+
'clientInstanceId', 'instanceId'
|
|
2675
|
+
].forEach(trimPayloadId);
|
|
2676
|
+
['recordIds', 'folderOrder', 'groupOrder', 'expandedGroups', 'groupIds', 'ids'].forEach((key) => {
|
|
2677
|
+
if (Array.isArray(normalizedPayload[key])) {
|
|
2678
|
+
normalizedPayload[key] = normalizeIdList(normalizedPayload[key]);
|
|
2679
|
+
}
|
|
2680
|
+
});
|
|
2681
|
+
if (rtlDurableIsObject(normalizedPayload.record)) {
|
|
2682
|
+
normalizedPayload.record = clone(normalizedPayload.record);
|
|
2683
|
+
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'id')) {
|
|
2684
|
+
normalizedPayload.record.id = normalizeId(normalizedPayload.record.id) || normalizedPayload.record.id;
|
|
2685
|
+
}
|
|
2686
|
+
if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'folderId')) {
|
|
2687
|
+
normalizedPayload.record.folderId = normalizeId(normalizedPayload.record.folderId) || normalizedPayload.record.folderId;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
if (rtlDurableIsObject(normalizedPayload.folder)) {
|
|
2691
|
+
normalizedPayload.folder = clone(normalizedPayload.folder);
|
|
2692
|
+
if (Object.prototype.hasOwnProperty.call(normalizedPayload.folder, 'id')) {
|
|
2693
|
+
normalizedPayload.folder.id = normalizeId(normalizedPayload.folder.id) || normalizedPayload.folder.id;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
|
|
2698
|
+
const resolvedNow = Number.isFinite(operationNow)
|
|
2699
|
+
? operationNow
|
|
2700
|
+
: (typeof now === 'function' ? now() : now);
|
|
2701
|
+
const resolvedClientId = input.clientId || clientId ||
|
|
2702
|
+
(typeof client === 'string' ? client : client?.id) || null;
|
|
2703
|
+
const generatedId = createOperation(input.type, normalizedPayload, {
|
|
2704
|
+
clientId: resolvedClientId || 'recordtimelabel-client',
|
|
2705
|
+
now: Number.isFinite(Number(resolvedNow)) ? Number(resolvedNow) : Date.now()
|
|
2706
|
+
}).id;
|
|
2707
|
+
|
|
2708
|
+
return {
|
|
2709
|
+
...rtlStripSessionTokens(input),
|
|
2710
|
+
id: normalizeId(input.id) || generatedId,
|
|
2711
|
+
type: typeof input.type === 'string' ? input.type.trim() : input.type,
|
|
2712
|
+
payload: normalizedPayload,
|
|
2713
|
+
clientId: resolvedClientId,
|
|
2714
|
+
createdAt: Number.isFinite(Number(resolvedNow)) ? Number(resolvedNow) : Date.now(),
|
|
2715
|
+
ownerUid: rtlNormalizeUid(input.ownerUid) || rtlNormalizeUid(ownerUid),
|
|
2716
|
+
workspaceEpoch: rtlNormalizeWorkspaceEpoch(input.workspaceEpoch, workspaceEpoch)
|
|
2717
|
+
};
|
|
2718
|
+
};
|
|
2719
|
+
|
|
2720
|
+
const rtlNormalizePendingOperation = (operation, options = {}) => {
|
|
2721
|
+
const normalized = rtlNormalizeOperation(operation, options);
|
|
2722
|
+
const retryCount = Math.max(
|
|
2723
|
+
0,
|
|
2724
|
+
Math.floor(rtlToFiniteNumber(operation?.retryCount ?? operation?.retryAttempts, 0))
|
|
2725
|
+
);
|
|
2726
|
+
const nextRetryAt = rtlToFiniteNumber(operation?.nextRetryAt, NaN);
|
|
2727
|
+
if (retryCount > 0) normalized.retryCount = retryCount;
|
|
2728
|
+
if (Number.isFinite(nextRetryAt)) normalized.nextRetryAt = nextRetryAt;
|
|
2729
|
+
return normalized;
|
|
2730
|
+
};
|
|
2731
|
+
|
|
2732
|
+
const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
2733
|
+
const baseline = rtlDurableIsObject(value) ? value : {};
|
|
2734
|
+
return {
|
|
2735
|
+
state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
|
|
2736
|
+
revision: Math.max(0, rtlToFiniteNumber(baseline.revision, 0)),
|
|
2737
|
+
changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
|
|
2738
|
+
? null
|
|
2739
|
+
: String(baseline.changeCursor)
|
|
2740
|
+
};
|
|
2741
|
+
};
|
|
2742
|
+
|
|
2743
|
+
const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) => ({
|
|
2744
|
+
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
2745
|
+
ownerUid: rtlNormalizeUid(ownerUid),
|
|
2746
|
+
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0),
|
|
2747
|
+
remoteBaseline: rtlNormalizeRemoteBaseline({}),
|
|
2748
|
+
pendingOperations: [],
|
|
2749
|
+
rejectedOperations: {},
|
|
2750
|
+
syncMeta: {}
|
|
2751
|
+
});
|
|
2752
|
+
|
|
2753
|
+
const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
2754
|
+
const source = rtlDurableIsObject(input) ? input : {};
|
|
2755
|
+
const legacy = !source.remoteBaseline && (
|
|
2756
|
+
Object.prototype.hasOwnProperty.call(source, 'state') ||
|
|
2757
|
+
Object.prototype.hasOwnProperty.call(source, 'pendingOps')
|
|
2758
|
+
);
|
|
2759
|
+
const ownerUid = rtlNormalizeUid(source.ownerUid ?? options.ownerUid);
|
|
2760
|
+
const workspaceEpoch = rtlNormalizeWorkspaceEpoch(
|
|
2761
|
+
source.workspaceEpoch ?? options.workspaceEpoch,
|
|
2762
|
+
options.workspaceEpoch
|
|
2763
|
+
);
|
|
2764
|
+
const baseline = legacy
|
|
2765
|
+
? rtlNormalizeRemoteBaseline({
|
|
2766
|
+
state: source.state || {},
|
|
2767
|
+
revision: source.revision ?? source.syncMeta?.revision,
|
|
2768
|
+
changeCursor: source.changeCursor ?? source.syncMeta?.changeCursor
|
|
2769
|
+
})
|
|
2770
|
+
: rtlNormalizeRemoteBaseline(source.remoteBaseline || {});
|
|
2771
|
+
const pendingSource = source.pendingOperations ?? source.pendingOps ?? [];
|
|
2772
|
+
const pendingOperations = toArray(pendingSource)
|
|
2773
|
+
.filter(Boolean)
|
|
2774
|
+
.map((operation) => rtlNormalizePendingOperation(operation, options));
|
|
2775
|
+
const legacyExpandedGroups = Array.isArray(source.syncMeta?.legacyExpandedGroups)
|
|
2776
|
+
? normalizeOrder(source.syncMeta.legacyExpandedGroups)
|
|
2777
|
+
: Array.isArray(source.state?.expandedGroups)
|
|
2778
|
+
? normalizeOrder(source.state.expandedGroups)
|
|
2779
|
+
: Array.isArray(source.remoteBaseline?.state?.expandedGroups)
|
|
2780
|
+
? normalizeOrder(source.remoteBaseline.state.expandedGroups)
|
|
2781
|
+
: null;
|
|
2782
|
+
const rejectedSource = rtlDurableIsObject(source.rejectedOperations) ? source.rejectedOperations : {};
|
|
2783
|
+
const rejectedOperations = {};
|
|
2784
|
+
Object.entries(rejectedSource).forEach(([rawId, value]) => {
|
|
2785
|
+
const id = normalizeId(rawId || value?.id || value?.operationId);
|
|
2786
|
+
if (!id) return;
|
|
2787
|
+
rejectedOperations[id] = clone(value && typeof value === 'object' ? value : {reason: value});
|
|
2788
|
+
if (rejectedOperations[id].operation) {
|
|
2789
|
+
rejectedOperations[id].operation = rtlNormalizePendingOperation(
|
|
2790
|
+
rejectedOperations[id].operation,
|
|
2791
|
+
options
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
});
|
|
2795
|
+
return {
|
|
2796
|
+
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
2797
|
+
ownerUid,
|
|
2798
|
+
workspaceEpoch,
|
|
2799
|
+
remoteBaseline: baseline,
|
|
2800
|
+
pendingOperations,
|
|
2801
|
+
rejectedOperations,
|
|
2802
|
+
syncMeta: {
|
|
2803
|
+
...(rtlDurableIsObject(source.syncMeta) ? rtlStripSessionTokens(source.syncMeta) : {}),
|
|
2804
|
+
...(legacyExpandedGroups && !Array.isArray(source.syncMeta?.migratedExpandedGroups)
|
|
2805
|
+
? {legacyExpandedGroups}
|
|
2806
|
+
: {})
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
};
|
|
2810
|
+
|
|
2811
|
+
const rtlOperationIdentityReason = (operation, workspace) => {
|
|
2812
|
+
const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
|
|
2813
|
+
const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
|
|
2814
|
+
if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
|
|
2815
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.OWNER_MISMATCH;
|
|
2816
|
+
}
|
|
2817
|
+
const operationEpoch = rtlNormalizeWorkspaceEpoch(
|
|
2818
|
+
operation?.workspaceEpoch,
|
|
2819
|
+
workspace?.workspaceEpoch
|
|
2820
|
+
);
|
|
2821
|
+
const workspaceEpoch = rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0);
|
|
2822
|
+
if (operationEpoch !== workspaceEpoch) {
|
|
2823
|
+
return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.WORKSPACE_EPOCH_MISMATCH;
|
|
2824
|
+
}
|
|
2825
|
+
return null;
|
|
2826
|
+
};
|
|
2827
|
+
|
|
2828
|
+
const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now()) => {
|
|
2829
|
+
const accepted = [];
|
|
2830
|
+
const rejected = {...(workspace?.rejectedOperations || {})};
|
|
2831
|
+
let rejectedCount = 0;
|
|
2832
|
+
toArray(operations).forEach((operation, index) => {
|
|
2833
|
+
const reason = rtlOperationIdentityReason(operation, workspace);
|
|
2834
|
+
if (!reason) {
|
|
2835
|
+
accepted.push(operation);
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
rejectedCount += 1;
|
|
2839
|
+
const id = normalizeId(operation?.id) || `identity-rejected:${timestamp}:${index}`;
|
|
2840
|
+
rejected[id] = {
|
|
2841
|
+
id,
|
|
2842
|
+
operation: clone(operation),
|
|
2843
|
+
status: 'rejected',
|
|
2844
|
+
reason,
|
|
2845
|
+
rejectedAt: timestamp,
|
|
2846
|
+
identity: {
|
|
2847
|
+
operationOwnerUid: rtlNormalizeUid(operation?.ownerUid),
|
|
2848
|
+
workspaceOwnerUid: rtlNormalizeUid(workspace?.ownerUid),
|
|
2849
|
+
operationWorkspaceEpoch: rtlNormalizeWorkspaceEpoch(
|
|
2850
|
+
operation?.workspaceEpoch,
|
|
2851
|
+
workspace?.workspaceEpoch
|
|
2852
|
+
),
|
|
2853
|
+
workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
});
|
|
2857
|
+
return {accepted, rejected, rejectedCount};
|
|
2858
|
+
};
|
|
2859
|
+
|
|
2860
|
+
const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
2861
|
+
let current = null;
|
|
2862
|
+
try {
|
|
2863
|
+
current = typeof session?.current === 'function' ? session.current() : null;
|
|
2864
|
+
} catch {
|
|
2865
|
+
current = null;
|
|
2866
|
+
}
|
|
2867
|
+
// A session port is intentionally small and adapters use both token and
|
|
2868
|
+
// sessionToken spellings. Preserve the original token (including objects)
|
|
2869
|
+
// for isCurrent, but never put it in a workspace blob.
|
|
2870
|
+
const token = current && typeof current === 'object'
|
|
2871
|
+
? (current.sessionToken ?? current.token ?? current.id ?? current)
|
|
2872
|
+
: current;
|
|
2873
|
+
const uid = rtlNormalizeUid(current && typeof current === 'object'
|
|
2874
|
+
? (current.uid ?? current.ownerUid)
|
|
2875
|
+
: null);
|
|
2876
|
+
const rawEpoch = current && typeof current === 'object'
|
|
2877
|
+
? (current.workspaceEpoch ?? current.epoch)
|
|
2878
|
+
: undefined;
|
|
2879
|
+
const hasEpoch = rawEpoch !== undefined && rawEpoch !== null && Number.isFinite(Number(rawEpoch));
|
|
2880
|
+
return {
|
|
2881
|
+
current,
|
|
2882
|
+
hasSession: typeof session?.current === 'function',
|
|
2883
|
+
token,
|
|
2884
|
+
sessionToken: token,
|
|
2885
|
+
uid,
|
|
2886
|
+
workspaceEpoch: hasEpoch
|
|
2887
|
+
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
2888
|
+
: rtlNormalizeWorkspaceEpoch(fallbackEpoch, 0),
|
|
2889
|
+
hasEpoch
|
|
2890
|
+
};
|
|
2891
|
+
};
|
|
2892
|
+
|
|
2893
|
+
const rtlSessionContext = (captured, workspace, client) => ({
|
|
2894
|
+
sessionToken: captured?.sessionToken ?? null,
|
|
2895
|
+
token: captured?.token ?? null,
|
|
2896
|
+
uid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
2897
|
+
ownerUid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
2898
|
+
workspaceEpoch: workspace?.workspaceEpoch ?? captured?.workspaceEpoch ?? 0,
|
|
2899
|
+
client: clone(client)
|
|
2900
|
+
});
|
|
2901
|
+
|
|
2902
|
+
const rtlEnvelopePayload = (value) => {
|
|
2903
|
+
if (value && typeof value === 'object') {
|
|
2904
|
+
if (value.remoteBaseline) return value.remoteBaseline;
|
|
2905
|
+
if (value.baseline) return value.baseline;
|
|
2906
|
+
if (value.state !== undefined || value.data !== undefined || value.revision !== undefined || value.changeCursor !== undefined) {
|
|
2907
|
+
return value;
|
|
2908
|
+
}
|
|
2909
|
+
if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
|
|
2910
|
+
// Adapters migrating from the v1 cloud port may still return a raw
|
|
2911
|
+
// normalized state. Treat the presence of state-owned keys as an implicit
|
|
2912
|
+
// baseline with the persisted revision.
|
|
2913
|
+
if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
|
|
2914
|
+
return {state: value, revision: value.revision, changeCursor: value.changeCursor};
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return null;
|
|
2918
|
+
};
|
|
2919
|
+
|
|
2920
|
+
const rtlEnvelopeSuccess = (value) => {
|
|
2921
|
+
if (!value || typeof value !== 'object') return false;
|
|
2922
|
+
if (value.success === false || value.ok === false || value.error || value.code === 'error') return false;
|
|
2923
|
+
return value.success === true || value.ok === true || (
|
|
2924
|
+
value.success === undefined && value.ok === undefined && !value.error
|
|
2925
|
+
);
|
|
2926
|
+
};
|
|
2927
|
+
|
|
2928
|
+
const rtlRetryableEnvelopeError = (value) => {
|
|
2929
|
+
const error = value?.error && typeof value.error === 'object' ? value.error : value;
|
|
2930
|
+
const status = Number(error?.status ?? error?.statusCode ?? value?.status ?? value?.statusCode);
|
|
2931
|
+
const code = String(error?.code ?? value?.code ?? '').toLowerCase();
|
|
2932
|
+
return Boolean(
|
|
2933
|
+
error?.retryable === true ||
|
|
2934
|
+
value?.retryable === true ||
|
|
2935
|
+
status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 ||
|
|
2936
|
+
code === 'bulk_job_in_progress' || code === 'retryable' || code === 'temporarily_unavailable' ||
|
|
2937
|
+
code === 'unavailable' || code === 'deadline_exceeded'
|
|
2938
|
+
);
|
|
2939
|
+
};
|
|
2940
|
+
|
|
2941
|
+
const rtlRetryAfterMs = (value) => {
|
|
2942
|
+
const error = value?.error && typeof value.error === 'object' ? value.error : value;
|
|
2943
|
+
const retryAfter = Number(error?.retryAfterMs ?? value?.retryAfterMs);
|
|
2944
|
+
return Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2947
|
+
const rtlResultOperationId = (result = {}) => normalizeId(
|
|
2948
|
+
result.id ?? result.operationId ?? result.opId ?? result.operation?.id
|
|
2949
|
+
);
|
|
2950
|
+
|
|
2951
|
+
const rtlIsStaleRevision = (revision, baselineRevision) => (
|
|
2952
|
+
Number.isFinite(Number(revision)) && Number(revision) < Number(baselineRevision)
|
|
2953
|
+
);
|
|
2954
|
+
|
|
2955
|
+
const rtlDeriveDurableVisibleState = (workspace) => {
|
|
2956
|
+
let state = normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {});
|
|
2957
|
+
toArray(workspace?.pendingOperations).forEach((operation) => {
|
|
2958
|
+
state = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
2959
|
+
? state
|
|
2960
|
+
: normalizeRecordTimeLabelDomainState(applyRecordTimeLabelOperation(state, operation));
|
|
2961
|
+
});
|
|
2962
|
+
return state;
|
|
2963
|
+
};
|
|
2964
|
+
|
|
2965
|
+
const rtlDurableWorkspaceSnapshot = (workspace) => {
|
|
2966
|
+
const persisted = clone(workspace);
|
|
2967
|
+
const state = rtlDeriveDurableVisibleState(workspace);
|
|
2968
|
+
return {
|
|
2969
|
+
...persisted,
|
|
2970
|
+
state: clone(state),
|
|
2971
|
+
visibleState: clone(state),
|
|
2972
|
+
// These aliases make migration from the v1 engine less surprising while
|
|
2973
|
+
// the persisted contract remains strictly pendingOperations-based.
|
|
2974
|
+
pendingOps: clone(workspace.pendingOperations),
|
|
2975
|
+
syncMeta: clone(workspace.syncMeta)
|
|
2976
|
+
};
|
|
2977
|
+
};
|
|
2978
|
+
|
|
2979
|
+
export const createRecordTimeLabelSyncEngine = ({
|
|
2980
|
+
storage,
|
|
2981
|
+
cloud,
|
|
2982
|
+
session,
|
|
2983
|
+
client = 'recordtimelabel-client',
|
|
2984
|
+
clock = () => Date.now(),
|
|
2985
|
+
logger = console
|
|
2986
|
+
} = {}) => {
|
|
2987
|
+
if (!storage?.load || !storage?.save) {
|
|
2988
|
+
throw new Error('createRecordTimeLabelSyncEngine requires storage.load() and storage.save()');
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
let workspace = rtlEmptyDurableWorkspace();
|
|
2992
|
+
let initialized = false;
|
|
2993
|
+
let destroyed = false;
|
|
2994
|
+
let unsubscribeCloud = null;
|
|
2995
|
+
let unsubscribeSession = null;
|
|
2996
|
+
let subscriptionContext = null;
|
|
2997
|
+
const listeners = new Set();
|
|
2998
|
+
let queue = Promise.resolve();
|
|
2999
|
+
|
|
3000
|
+
const now = () => {
|
|
3001
|
+
try {
|
|
3002
|
+
const value = typeof clock === 'function' ? clock() : clock;
|
|
3003
|
+
return Number.isFinite(Number(value)) ? Number(value) : Date.now();
|
|
3004
|
+
} catch {
|
|
3005
|
+
return Date.now();
|
|
3006
|
+
}
|
|
3007
|
+
};
|
|
3008
|
+
|
|
3009
|
+
const getSnapshot = () => rtlDurableWorkspaceSnapshot(workspace);
|
|
3010
|
+
|
|
3011
|
+
const notify = (event = {}) => {
|
|
3012
|
+
if (destroyed) return;
|
|
3013
|
+
const snapshot = getSnapshot();
|
|
3014
|
+
listeners.forEach((listener) => {
|
|
3015
|
+
try {
|
|
3016
|
+
listener({...event, snapshot: clone(snapshot)});
|
|
3017
|
+
} catch (error) {
|
|
3018
|
+
logger?.error?.('[RecordTimeLabelCore] durable listener failed', error);
|
|
3019
|
+
}
|
|
3020
|
+
});
|
|
3021
|
+
};
|
|
3022
|
+
|
|
3023
|
+
const enqueue = (task) => {
|
|
3024
|
+
const run = queue.then(async () => {
|
|
3025
|
+
if (destroyed) return {stale: true, reason: 'destroyed'};
|
|
3026
|
+
return task();
|
|
3027
|
+
});
|
|
3028
|
+
queue = run.catch(() => {});
|
|
3029
|
+
return run;
|
|
3030
|
+
};
|
|
3031
|
+
|
|
3032
|
+
const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
|
|
3033
|
+
|
|
3034
|
+
const isCurrent = async (captured) => {
|
|
3035
|
+
if (destroyed || !captured) return false;
|
|
3036
|
+
if (typeof session?.isCurrent === 'function') {
|
|
3037
|
+
try {
|
|
3038
|
+
return Boolean(await session.isCurrent(
|
|
3039
|
+
captured.sessionToken,
|
|
3040
|
+
captured.uid,
|
|
3041
|
+
captured.workspaceEpoch
|
|
3042
|
+
));
|
|
3043
|
+
} catch {
|
|
3044
|
+
return false;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
if (typeof session?.current !== 'function') return true;
|
|
3048
|
+
const current = rtlExtractSession(session, captured.workspaceEpoch);
|
|
3049
|
+
if (captured.token !== undefined && captured.token !== null && current.token !== captured.token) return false;
|
|
3050
|
+
if (captured.uid !== current.uid) return false;
|
|
3051
|
+
return !captured.hasEpoch || current.workspaceEpoch === captured.workspaceEpoch;
|
|
3052
|
+
};
|
|
3053
|
+
|
|
3054
|
+
const persist = async (candidate, captured) => {
|
|
3055
|
+
if (!(await isCurrent(captured))) return false;
|
|
3056
|
+
// The second argument is an optional adapter-side fence. The public
|
|
3057
|
+
// StoragePort remains compatible with save(workspace); adapters that can
|
|
3058
|
+
// enforce an atomic session/epoch check should consume this context before
|
|
3059
|
+
// mutating durable storage.
|
|
3060
|
+
await storage.save(clone(candidate), {
|
|
3061
|
+
sessionToken: captured?.sessionToken ?? null,
|
|
3062
|
+
uid: captured?.uid ?? null,
|
|
3063
|
+
ownerUid: captured?.uid ?? null,
|
|
3064
|
+
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
|
|
3065
|
+
});
|
|
3066
|
+
if (!(await isCurrent(captured))) return false;
|
|
3067
|
+
return true;
|
|
3068
|
+
};
|
|
3069
|
+
|
|
3070
|
+
const applyRemoteBaseline = (candidate, remoteValue) => {
|
|
3071
|
+
const remote = rtlEnvelopePayload(remoteValue);
|
|
3072
|
+
if (!remote) return {changed: false, stale: false};
|
|
3073
|
+
const revision = Number(remote.revision);
|
|
3074
|
+
if (rtlIsStaleRevision(revision, candidate.remoteBaseline.revision)) {
|
|
3075
|
+
return {changed: false, stale: true};
|
|
3076
|
+
}
|
|
3077
|
+
const nextBaseline = rtlNormalizeRemoteBaseline({
|
|
3078
|
+
state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
|
|
3079
|
+
revision: Number.isFinite(revision) ? revision : candidate.remoteBaseline.revision,
|
|
3080
|
+
changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
|
|
3081
|
+
? remote.changeCursor
|
|
3082
|
+
: candidate.remoteBaseline.changeCursor
|
|
3083
|
+
});
|
|
3084
|
+
const changed = JSON.stringify(nextBaseline) !== JSON.stringify(candidate.remoteBaseline);
|
|
3085
|
+
candidate.remoteBaseline = nextBaseline;
|
|
3086
|
+
return {changed, stale: false};
|
|
3087
|
+
};
|
|
3088
|
+
|
|
3089
|
+
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
3090
|
+
const source = loaded && typeof loaded === 'object' ? loaded : {};
|
|
3091
|
+
const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
|
|
3092
|
+
const sourceOwnerUid = rtlNormalizeUid(source.ownerUid);
|
|
3093
|
+
const sourcePending = toArray(source.pendingOperations ?? source.pendingOps);
|
|
3094
|
+
const sourceHasBoundOperation = sourcePending.some((operation) => rtlNormalizeUid(operation?.ownerUid));
|
|
3095
|
+
const loadedWorkspace = rtlNormalizeDurableWorkspace(
|
|
3096
|
+
hasDurableShape ? source : {
|
|
3097
|
+
...source,
|
|
3098
|
+
state: source.state || source,
|
|
3099
|
+
pendingOps: source.pendingOps || source.pendingOperations || [],
|
|
3100
|
+
syncMeta: source.syncMeta || {}
|
|
3101
|
+
},
|
|
3102
|
+
{
|
|
3103
|
+
ownerUid: captured?.uid,
|
|
3104
|
+
workspaceEpoch: captured?.workspaceEpoch,
|
|
3105
|
+
client,
|
|
3106
|
+
now
|
|
3107
|
+
}
|
|
3108
|
+
);
|
|
3109
|
+
const currentUid = captured?.uid;
|
|
3110
|
+
const currentEpoch = captured?.workspaceEpoch ?? loadedWorkspace.workspaceEpoch;
|
|
3111
|
+
const anonymousMigration = Boolean(
|
|
3112
|
+
currentUid &&
|
|
3113
|
+
!sourceOwnerUid &&
|
|
3114
|
+
!sourceHasBoundOperation &&
|
|
3115
|
+
!loadedWorkspace.syncMeta?.anonymousMigrationAt
|
|
3116
|
+
);
|
|
3117
|
+
if (anonymousMigration) {
|
|
3118
|
+
const anonymousState = loadedWorkspace.remoteBaseline.state;
|
|
3119
|
+
const migrationTime = Math.max(1, rtlToFiniteNumber(anonymousState.lastModified, now()));
|
|
3120
|
+
const migratedOperations = [];
|
|
3121
|
+
const seenIds = new Set();
|
|
3122
|
+
const addMigrationOperation = (type, payload, id) => {
|
|
3123
|
+
const normalizedId = normalizeId(id);
|
|
3124
|
+
if (!normalizedId || seenIds.has(normalizedId)) return;
|
|
3125
|
+
seenIds.add(normalizedId);
|
|
3126
|
+
migratedOperations.push(rtlNormalizePendingOperation({
|
|
3127
|
+
id: normalizedId,
|
|
3128
|
+
type,
|
|
3129
|
+
payload,
|
|
3130
|
+
clientId: 'anonymous-migration',
|
|
3131
|
+
createdAt: migrationTime
|
|
3132
|
+
}, {
|
|
3133
|
+
client,
|
|
3134
|
+
now,
|
|
3135
|
+
ownerUid: currentUid,
|
|
3136
|
+
workspaceEpoch: currentEpoch
|
|
3137
|
+
}));
|
|
3138
|
+
};
|
|
3139
|
+
toArray(anonymousState.folders).forEach((folder) => {
|
|
3140
|
+
const id = normalizeId(folder?.id);
|
|
3141
|
+
if (!id || id === 'all' || id === 'uncategorized') return;
|
|
3142
|
+
addMigrationOperation(
|
|
3143
|
+
OPERATION_TYPES.FOLDER_CREATE,
|
|
3144
|
+
{folder: clone(folder)},
|
|
3145
|
+
`anonymous:folder.create:${id}`
|
|
3146
|
+
);
|
|
3147
|
+
});
|
|
3148
|
+
Object.entries(anonymousState.records || {}).forEach(([folderId, records]) => {
|
|
3149
|
+
if (folderId === 'all') return;
|
|
3150
|
+
toArray(records).forEach((record) => {
|
|
3151
|
+
const id = normalizeId(record?.id);
|
|
3152
|
+
if (!id) return;
|
|
3153
|
+
const migratedRecord = clone(record);
|
|
3154
|
+
if (migratedRecord && typeof migratedRecord === 'object') {
|
|
3155
|
+
delete migratedRecord.pendingSync;
|
|
3156
|
+
delete migratedRecord.syncAttempts;
|
|
3157
|
+
}
|
|
3158
|
+
addMigrationOperation(
|
|
3159
|
+
OPERATION_TYPES.RECORD_CREATE,
|
|
3160
|
+
{folderId, record: migratedRecord},
|
|
3161
|
+
`anonymous:record.create:${id}`
|
|
3162
|
+
);
|
|
3163
|
+
});
|
|
3164
|
+
});
|
|
3165
|
+
loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
|
|
3166
|
+
loadedWorkspace.pendingOperations = [
|
|
3167
|
+
...migratedOperations,
|
|
3168
|
+
...loadedWorkspace.pendingOperations
|
|
3169
|
+
.filter((operation) => (
|
|
3170
|
+
operation?.type === OPERATION_TYPES.FOLDER_CREATE ||
|
|
3171
|
+
operation?.type === OPERATION_TYPES.RECORD_CREATE
|
|
3172
|
+
))
|
|
3173
|
+
.map((operation) => rtlNormalizePendingOperation(operation, {
|
|
3174
|
+
client,
|
|
3175
|
+
now,
|
|
3176
|
+
ownerUid: currentUid,
|
|
3177
|
+
workspaceEpoch: currentEpoch
|
|
3178
|
+
}))
|
|
3179
|
+
];
|
|
3180
|
+
loadedWorkspace.ownerUid = currentUid;
|
|
3181
|
+
loadedWorkspace.workspaceEpoch = currentEpoch;
|
|
3182
|
+
loadedWorkspace.syncMeta = {
|
|
3183
|
+
...loadedWorkspace.syncMeta,
|
|
3184
|
+
anonymousMigrationAt: now(),
|
|
3185
|
+
anonymousMigrationMode: 'create_only'
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
const ownerMismatch = captured?.hasSession && loadedWorkspace.ownerUid !== currentUid && !anonymousMigration;
|
|
3189
|
+
const epochMismatch = Boolean(
|
|
3190
|
+
captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
|
|
3191
|
+
);
|
|
3192
|
+
if (ownerMismatch || epochMismatch) {
|
|
3193
|
+
return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
|
|
3194
|
+
}
|
|
3195
|
+
const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
|
|
3196
|
+
? loadedWorkspace.syncMeta.migratedExpandedGroups
|
|
3197
|
+
: loadedWorkspace.syncMeta?.legacyExpandedGroups || [];
|
|
3198
|
+
const migratedView = migrateRecordTimeLabelExpandedGroups({
|
|
3199
|
+
pendingOperations: loadedWorkspace.pendingOperations,
|
|
3200
|
+
currentView
|
|
3201
|
+
});
|
|
3202
|
+
const hadLegacyView = migratedView.pendingOperations.length !==
|
|
3203
|
+
loadedWorkspace.pendingOperations.length ||
|
|
3204
|
+
Object.prototype.hasOwnProperty.call(loadedWorkspace.syncMeta || {}, 'legacyExpandedGroups');
|
|
3205
|
+
loadedWorkspace.pendingOperations = migratedView.pendingOperations;
|
|
3206
|
+
loadedWorkspace.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
3207
|
+
loadedWorkspace.remoteBaseline.state
|
|
3208
|
+
);
|
|
3209
|
+
if (hadLegacyView) {
|
|
3210
|
+
const syncMeta = {...loadedWorkspace.syncMeta};
|
|
3211
|
+
delete syncMeta.legacyExpandedGroups;
|
|
3212
|
+
loadedWorkspace.syncMeta = {
|
|
3213
|
+
...syncMeta,
|
|
3214
|
+
migratedExpandedGroups: migratedView.expandedGroups,
|
|
3215
|
+
expandedGroupsMigrationAt: now()
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
loadedWorkspace.ownerUid = currentUid ?? loadedWorkspace.ownerUid;
|
|
3219
|
+
if (captured?.hasEpoch) loadedWorkspace.workspaceEpoch = currentEpoch;
|
|
3220
|
+
return loadedWorkspace;
|
|
3221
|
+
};
|
|
3222
|
+
|
|
3223
|
+
const normalizeOperationResults = (response, sentOperations) => {
|
|
3224
|
+
let results;
|
|
3225
|
+
try {
|
|
3226
|
+
// Keep the Plan 017 engine's private input aliases compatible while the
|
|
3227
|
+
// public shared contract remains strict about operationResults arrays.
|
|
3228
|
+
const legacyOperationResults = response?.operationResults;
|
|
3229
|
+
const compatibleResponse = rtlDurableIsObject(legacyOperationResults)
|
|
3230
|
+
? {
|
|
3231
|
+
...response,
|
|
3232
|
+
operationResults: Object.entries(legacyOperationResults).map(([id, result]) => ({
|
|
3233
|
+
...(result || {}),
|
|
3234
|
+
id: result?.id || id
|
|
3235
|
+
}))
|
|
3236
|
+
}
|
|
3237
|
+
: (
|
|
3238
|
+
!Object.prototype.hasOwnProperty.call(response || {}, 'operationResults') &&
|
|
3239
|
+
Array.isArray(response?.results)
|
|
3240
|
+
? {...response, operationResults: response.results}
|
|
3241
|
+
: response
|
|
3242
|
+
);
|
|
3243
|
+
results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, compatibleResponse);
|
|
3244
|
+
} catch (error) {
|
|
3245
|
+
return {error, results: null};
|
|
3246
|
+
}
|
|
3247
|
+
const byId = new Map(sentOperations.map((operation) => [operation.id, operation]));
|
|
3248
|
+
const normalized = [];
|
|
3249
|
+
const seen = new Set();
|
|
3250
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
3251
|
+
const result = results[index] || {};
|
|
3252
|
+
const resultId = rtlResultOperationId(result) || sentOperations[index]?.id;
|
|
3253
|
+
if (!resultId || !byId.has(resultId) || seen.has(resultId)) {
|
|
3254
|
+
return {error: new Error('sync_protocol_invalid_operation_result'), results: null};
|
|
3255
|
+
}
|
|
3256
|
+
seen.add(resultId);
|
|
3257
|
+
normalized.push({...clone(result), id: resultId});
|
|
3258
|
+
}
|
|
3259
|
+
if (seen.size !== sentOperations.length) {
|
|
3260
|
+
return {error: new Error('sync_protocol_operation_result_count_mismatch'), results: null};
|
|
3261
|
+
}
|
|
3262
|
+
return {results: normalized};
|
|
3263
|
+
};
|
|
3264
|
+
|
|
3265
|
+
const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
|
|
3266
|
+
const direct = Number(result?.nextRetryAt);
|
|
3267
|
+
if (Number.isFinite(direct)) return direct;
|
|
3268
|
+
const after = Number(result?.retryAfterMs ?? retryAfterMs);
|
|
3269
|
+
if (Number.isFinite(after) && after >= 0) return timestamp + after;
|
|
3270
|
+
const attempts = Math.max(0, Number(operation?.retryCount || operation?.retryAttempts || 0));
|
|
3271
|
+
return timestamp + Math.min(RTL_RETRY_MAX_MS, RTL_RETRY_BASE_MS * (2 ** attempts));
|
|
3272
|
+
};
|
|
3273
|
+
|
|
3274
|
+
const makeRetryOperation = (operation, result, retryAfterMs, timestamp) => ({
|
|
3275
|
+
...rtlCloneOperation(operation),
|
|
3276
|
+
retryCount: Math.max(0, Number(operation?.retryCount || operation?.retryAttempts || 0)) + 1,
|
|
3277
|
+
nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
const processRemote = async (remoteValue, captured) => {
|
|
3281
|
+
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3282
|
+
const candidate = clone(workspace);
|
|
3283
|
+
const applied = applyRemoteBaseline(candidate, remoteValue);
|
|
3284
|
+
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
3285
|
+
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
3286
|
+
workspace = candidate;
|
|
3287
|
+
notify({type: 'remote_merged'});
|
|
3288
|
+
return getSnapshot();
|
|
3289
|
+
};
|
|
3290
|
+
|
|
3291
|
+
const initialize = async () => {
|
|
3292
|
+
const captured = capture();
|
|
3293
|
+
const loaded = await storage.load();
|
|
3294
|
+
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3295
|
+
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
3296
|
+
const identityChecked = rtlQuarantineOperations(
|
|
3297
|
+
candidate,
|
|
3298
|
+
candidate.pendingOperations,
|
|
3299
|
+
now()
|
|
3300
|
+
);
|
|
3301
|
+
candidate.pendingOperations = identityChecked.accepted;
|
|
3302
|
+
candidate.rejectedOperations = identityChecked.rejected;
|
|
3303
|
+
const context = rtlSessionContext(captured, candidate, client);
|
|
3304
|
+
|
|
3305
|
+
if (captured?.uid && typeof cloud?.bootstrap === 'function') {
|
|
3306
|
+
let bootstrap;
|
|
3307
|
+
try {
|
|
3308
|
+
bootstrap = await cloud.bootstrap(context);
|
|
3309
|
+
} catch (error) {
|
|
3310
|
+
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3311
|
+
throw error;
|
|
3312
|
+
}
|
|
3313
|
+
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3314
|
+
applyRemoteBaseline(candidate, bootstrap);
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
// Always persist the normalized durable shape before subscribing. This
|
|
3318
|
+
// also makes legacy migration atomic from the engine's point of view.
|
|
3319
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
3320
|
+
workspace = candidate;
|
|
3321
|
+
initialized = true;
|
|
3322
|
+
|
|
3323
|
+
if (typeof cloud?.subscribe === 'function') {
|
|
3324
|
+
subscriptionContext = context;
|
|
3325
|
+
unsubscribeCloud = cloud.subscribe((remoteValue) => {
|
|
3326
|
+
if (destroyed) return;
|
|
3327
|
+
return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
|
|
3328
|
+
logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
|
|
3329
|
+
return {success: false, error};
|
|
3330
|
+
});
|
|
3331
|
+
}, context);
|
|
3332
|
+
}
|
|
3333
|
+
if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
|
|
3334
|
+
unsubscribeSession = session.subscribe(() => {});
|
|
3335
|
+
}
|
|
3336
|
+
notify({type: 'initialized'});
|
|
3337
|
+
return getSnapshot();
|
|
3338
|
+
};
|
|
3339
|
+
|
|
3340
|
+
const dispatchInternal = async (operations) => {
|
|
3341
|
+
const captured = capture();
|
|
3342
|
+
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3343
|
+
const input = Array.isArray(operations)
|
|
3344
|
+
? operations
|
|
3345
|
+
: (operations && typeof operations === 'object' ? [operations] : []);
|
|
3346
|
+
if (input.length === 0) return getSnapshot();
|
|
3347
|
+
const candidate = clone(workspace);
|
|
3348
|
+
if (captured?.uid !== undefined && captured?.uid !== null) {
|
|
3349
|
+
candidate.ownerUid = captured.uid;
|
|
3350
|
+
}
|
|
3351
|
+
if (captured?.hasEpoch) candidate.workspaceEpoch = captured.workspaceEpoch;
|
|
3352
|
+
const normalized = input.map((operation) => rtlNormalizePendingOperation(operation, {
|
|
3353
|
+
client,
|
|
3354
|
+
clientId: typeof client === 'string' ? client : client?.id,
|
|
3355
|
+
now,
|
|
3356
|
+
ownerUid: captured?.uid ?? candidate.ownerUid,
|
|
3357
|
+
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
|
|
3358
|
+
}));
|
|
3359
|
+
const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
|
|
3360
|
+
const viewOperations = identityChecked.accepted.filter((operation) => (
|
|
3361
|
+
operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
3362
|
+
));
|
|
3363
|
+
const durableOperations = identityChecked.accepted.filter((operation) => (
|
|
3364
|
+
operation.type !== OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
3365
|
+
));
|
|
3366
|
+
const viewMigration = migrateRecordTimeLabelExpandedGroups({
|
|
3367
|
+
pendingOperations: viewOperations,
|
|
3368
|
+
currentView: candidate.syncMeta?.migratedExpandedGroups || []
|
|
3369
|
+
});
|
|
3370
|
+
candidate.pendingOperations = [
|
|
3371
|
+
...candidate.pendingOperations,
|
|
3372
|
+
...durableOperations
|
|
3373
|
+
];
|
|
3374
|
+
candidate.rejectedOperations = identityChecked.rejected;
|
|
3375
|
+
if (viewOperations.length > 0) {
|
|
3376
|
+
candidate.syncMeta = {
|
|
3377
|
+
...candidate.syncMeta,
|
|
3378
|
+
migratedExpandedGroups: viewMigration.expandedGroups,
|
|
3379
|
+
expandedGroupsMigrationAt: now(),
|
|
3380
|
+
lastLocalViewOperationAt: viewOperations[viewOperations.length - 1].createdAt,
|
|
3381
|
+
lastLocalViewOperationId: viewOperations[viewOperations.length - 1].id
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
if (durableOperations.length > 0) {
|
|
3385
|
+
const lastOperation = durableOperations[durableOperations.length - 1];
|
|
3386
|
+
candidate.syncMeta = {
|
|
3387
|
+
...candidate.syncMeta,
|
|
3388
|
+
lastLocalOperationAt: lastOperation.createdAt,
|
|
3389
|
+
lastLocalOperationId: lastOperation.id,
|
|
3390
|
+
lastLocalOperationType: lastOperation.type
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
3394
|
+
workspace = candidate;
|
|
3395
|
+
if (durableOperations.length > 0 || viewOperations.length > 0) {
|
|
3396
|
+
notify({
|
|
3397
|
+
type: 'local_applied',
|
|
3398
|
+
operations: clone([...durableOperations, ...viewOperations]),
|
|
3399
|
+
operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
|
|
3400
|
+
rejectedCount: identityChecked.rejectedCount
|
|
3401
|
+
});
|
|
3402
|
+
} else {
|
|
3403
|
+
notify({type: 'operations_rejected', rejectedCount: identityChecked.rejectedCount});
|
|
3404
|
+
}
|
|
3405
|
+
return getSnapshot();
|
|
3406
|
+
};
|
|
3407
|
+
|
|
3408
|
+
const syncInternal = async (reason) => {
|
|
3409
|
+
const captured = capture();
|
|
3410
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3411
|
+
const timestamp = now();
|
|
3412
|
+
const identityChecked = rtlQuarantineOperations(
|
|
3413
|
+
workspace,
|
|
3414
|
+
workspace.pendingOperations,
|
|
3415
|
+
timestamp
|
|
3416
|
+
);
|
|
3417
|
+
let identityRejectedCount = identityChecked.rejectedCount;
|
|
3418
|
+
if (identityRejectedCount > 0) {
|
|
3419
|
+
const candidate = clone(workspace);
|
|
3420
|
+
candidate.pendingOperations = identityChecked.accepted;
|
|
3421
|
+
candidate.rejectedOperations = identityChecked.rejected;
|
|
3422
|
+
candidate.syncMeta = {
|
|
3423
|
+
...candidate.syncMeta,
|
|
3424
|
+
lastIdentityRejectionAt: timestamp,
|
|
3425
|
+
lastIdentityRejectionCount: identityRejectedCount
|
|
3426
|
+
};
|
|
3427
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
3428
|
+
workspace = candidate;
|
|
3429
|
+
notify({type: 'operations_rejected', rejectedCount: identityRejectedCount});
|
|
3430
|
+
}
|
|
3431
|
+
const ready = workspace.pendingOperations.filter((operation) => (
|
|
3432
|
+
!Number.isFinite(Number(operation.nextRetryAt)) || Number(operation.nextRetryAt) <= timestamp
|
|
3433
|
+
));
|
|
3434
|
+
if (ready.length === 0) {
|
|
3435
|
+
return {
|
|
3436
|
+
success: true,
|
|
3437
|
+
skipped: workspace.pendingOperations.length > 0 ? 'retry_deadline' : 'empty',
|
|
3438
|
+
pendingCount: workspace.pendingOperations.length,
|
|
3439
|
+
rejectedCount: identityRejectedCount,
|
|
3440
|
+
identityRejectedCount
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
if (!captured?.uid) {
|
|
3444
|
+
return {
|
|
3445
|
+
success: true,
|
|
3446
|
+
skipped: 'anonymous',
|
|
3447
|
+
pendingCount: workspace.pendingOperations.length,
|
|
3448
|
+
rejectedCount: identityRejectedCount,
|
|
3449
|
+
identityRejectedCount
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
if (typeof cloud?.applyOperations !== 'function') {
|
|
3453
|
+
return {success: false, reason: 'missing_cloud_apply_operations'};
|
|
3454
|
+
}
|
|
3455
|
+
const context = rtlSessionContext(captured, workspace, client);
|
|
3456
|
+
let response;
|
|
3457
|
+
try {
|
|
3458
|
+
response = await cloud.applyOperations({
|
|
3459
|
+
schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
|
|
3460
|
+
ownerUid: workspace.ownerUid,
|
|
3461
|
+
workspaceEpoch: workspace.workspaceEpoch,
|
|
3462
|
+
client: clone(client),
|
|
3463
|
+
reason: reason ?? null,
|
|
3464
|
+
operations: clone(ready)
|
|
3465
|
+
}, context);
|
|
3466
|
+
} catch (error) {
|
|
3467
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3468
|
+
if (!rtlRetryableEnvelopeError(error)) {
|
|
3469
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync failed', error);
|
|
3470
|
+
return {success: false, error};
|
|
3471
|
+
}
|
|
3472
|
+
response = {
|
|
3473
|
+
success: false,
|
|
3474
|
+
error,
|
|
3475
|
+
retryAfterMs: rtlRetryAfterMs(error)
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3479
|
+
|
|
3480
|
+
if (!rtlEnvelopeSuccess(response)) {
|
|
3481
|
+
if (!rtlRetryableEnvelopeError(response)) {
|
|
3482
|
+
const error = response?.error instanceof Error
|
|
3483
|
+
? response.error
|
|
3484
|
+
: new Error(response?.error?.message || response?.message || response?.code || 'sync_envelope_failed');
|
|
3485
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
|
|
3486
|
+
return {success: false, error};
|
|
3487
|
+
}
|
|
3488
|
+
const retryAfterMs = rtlRetryAfterMs(response);
|
|
3489
|
+
const candidate = clone(workspace);
|
|
3490
|
+
const byId = new Set(ready.map((operation) => operation.id));
|
|
3491
|
+
candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
|
|
3492
|
+
byId.has(operation.id)
|
|
3493
|
+
? makeRetryOperation(operation, response, retryAfterMs, timestamp)
|
|
3494
|
+
: operation
|
|
3495
|
+
));
|
|
3496
|
+
candidate.syncMeta = {
|
|
3497
|
+
...candidate.syncMeta,
|
|
3498
|
+
lastSyncAttemptAt: timestamp,
|
|
3499
|
+
lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
|
|
3500
|
+
};
|
|
3501
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
3502
|
+
workspace = candidate;
|
|
3503
|
+
notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
|
|
3504
|
+
const retryAt = Math.min(...candidate.pendingOperations
|
|
3505
|
+
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
3506
|
+
.map((operation) => Number(operation.nextRetryAt)));
|
|
3507
|
+
return {
|
|
3508
|
+
success: false,
|
|
3509
|
+
retryable: true,
|
|
3510
|
+
retryAfterMs,
|
|
3511
|
+
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
3512
|
+
pendingCount: workspace.pendingOperations.length,
|
|
3513
|
+
identityRejectedCount
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
const parsed = normalizeOperationResults(response, ready);
|
|
3518
|
+
if (parsed.error) {
|
|
3519
|
+
logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
|
|
3520
|
+
return {success: false, error: parsed.error, protocolError: true};
|
|
3521
|
+
}
|
|
3522
|
+
const candidate = clone(workspace);
|
|
3523
|
+
const resultById = new Map(parsed.results.map((result) => [result.id, result]));
|
|
3524
|
+
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
3525
|
+
const appliedOperations = [];
|
|
3526
|
+
const nextPending = [];
|
|
3527
|
+
const nextRejected = {...candidate.rejectedOperations};
|
|
3528
|
+
for (const operation of candidate.pendingOperations) {
|
|
3529
|
+
if (!readyIds.has(operation.id)) {
|
|
3530
|
+
nextPending.push(operation);
|
|
3531
|
+
continue;
|
|
3532
|
+
}
|
|
3533
|
+
const result = resultById.get(operation.id);
|
|
3534
|
+
if (result.status === 'applied') {
|
|
3535
|
+
appliedOperations.push(operation);
|
|
3536
|
+
} else if (result.status === 'noop') {
|
|
3537
|
+
// A noop is acknowledged but intentionally not promoted onto the
|
|
3538
|
+
// baseline: the server says the operation had no effect.
|
|
3539
|
+
} else if (result.status === 'retryable') {
|
|
3540
|
+
nextPending.push(makeRetryOperation(operation, result, null, timestamp));
|
|
3541
|
+
} else if (result.status === 'rejected') {
|
|
3542
|
+
nextRejected[operation.id] = {
|
|
3543
|
+
id: operation.id,
|
|
3544
|
+
operation: clone(operation),
|
|
3545
|
+
status: 'rejected',
|
|
3546
|
+
reason: result.reason ?? result.error ?? result.code ?? 'rejected',
|
|
3547
|
+
rejectedAt: timestamp,
|
|
3548
|
+
response: rtlStripSessionTokens(result)
|
|
3549
|
+
};
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
appliedOperations.forEach((operation) => {
|
|
3553
|
+
candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
|
|
3554
|
+
applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
|
|
3555
|
+
);
|
|
3556
|
+
});
|
|
3557
|
+
const responseRevision = Number(response.revision ?? response.remoteRevision);
|
|
3558
|
+
if (Number.isFinite(responseRevision) && responseRevision >= candidate.remoteBaseline.revision) {
|
|
3559
|
+
candidate.remoteBaseline.revision = responseRevision;
|
|
3560
|
+
}
|
|
3561
|
+
if (Object.prototype.hasOwnProperty.call(response || {}, 'changeCursor')) {
|
|
3562
|
+
candidate.remoteBaseline.changeCursor = response.changeCursor === null || response.changeCursor === undefined
|
|
3563
|
+
? null
|
|
3564
|
+
: String(response.changeCursor);
|
|
3565
|
+
}
|
|
3566
|
+
candidate.pendingOperations = nextPending;
|
|
3567
|
+
candidate.rejectedOperations = nextRejected;
|
|
3568
|
+
candidate.syncMeta = {
|
|
3569
|
+
...candidate.syncMeta,
|
|
3570
|
+
lastSyncedAt: timestamp,
|
|
3571
|
+
lastSyncAttemptAt: timestamp,
|
|
3572
|
+
lastSyncReason: reason ?? null,
|
|
3573
|
+
lastSyncError: null
|
|
3574
|
+
};
|
|
3575
|
+
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
3576
|
+
workspace = candidate;
|
|
3577
|
+
notify({type: 'synced', operations: clone(parsed.results)});
|
|
3578
|
+
const retryAt = Math.min(...candidate.pendingOperations
|
|
3579
|
+
.filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
|
|
3580
|
+
.map((operation) => Number(operation.nextRetryAt)));
|
|
3581
|
+
return {
|
|
3582
|
+
success: true,
|
|
3583
|
+
appliedCount: appliedOperations.length,
|
|
3584
|
+
syncedCount: appliedOperations.length + parsed.results.filter((result) => result.status === 'noop').length,
|
|
3585
|
+
rejectedCount: parsed.results.filter((result) => result.status === 'rejected').length,
|
|
3586
|
+
retryCount: parsed.results.filter((result) => result.status === 'retryable').length,
|
|
3587
|
+
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
3588
|
+
pendingCount: workspace.pendingOperations.length,
|
|
3589
|
+
identityRejectedCount
|
|
3590
|
+
};
|
|
3591
|
+
};
|
|
3592
|
+
|
|
3593
|
+
const engine = {
|
|
3594
|
+
init() {
|
|
3595
|
+
return enqueue(async () => {
|
|
3596
|
+
if (initialized) return getSnapshot();
|
|
3597
|
+
return initialize();
|
|
3598
|
+
});
|
|
3599
|
+
},
|
|
3600
|
+
|
|
3601
|
+
dispatch(operations) {
|
|
3602
|
+
return enqueue(() => dispatchInternal(operations));
|
|
3603
|
+
},
|
|
3604
|
+
|
|
3605
|
+
sync(reason) {
|
|
3606
|
+
return enqueue(() => syncInternal(reason));
|
|
3607
|
+
},
|
|
3608
|
+
|
|
3609
|
+
getSnapshot,
|
|
3610
|
+
|
|
3611
|
+
subscribe(listener) {
|
|
3612
|
+
if (typeof listener !== 'function') return () => {};
|
|
3613
|
+
listeners.add(listener);
|
|
3614
|
+
return () => listeners.delete(listener);
|
|
3615
|
+
},
|
|
3616
|
+
|
|
3617
|
+
destroy() {
|
|
3618
|
+
destroyed = true;
|
|
3619
|
+
initialized = false;
|
|
3620
|
+
if (typeof unsubscribeCloud === 'function') {
|
|
3621
|
+
try { unsubscribeCloud(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error); }
|
|
3622
|
+
}
|
|
3623
|
+
if (typeof unsubscribeSession === 'function') {
|
|
3624
|
+
try { unsubscribeSession(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable session unsubscribe failed', error); }
|
|
3625
|
+
}
|
|
3626
|
+
unsubscribeCloud = null;
|
|
3627
|
+
unsubscribeSession = null;
|
|
3628
|
+
subscriptionContext = null;
|
|
3629
|
+
listeners.clear();
|
|
3630
|
+
}
|
|
3631
|
+
};
|
|
3632
|
+
|
|
3633
|
+
return engine;
|
|
3634
|
+
};
|
|
3635
|
+
|
|
2442
3636
|
export const createSyncEngine = ({
|
|
2443
3637
|
storageAdapter,
|
|
2444
3638
|
cloudAdapter,
|
|
@@ -2622,16 +3816,23 @@ const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
|
2622
3816
|
const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
|
|
2623
3817
|
operation?.payload?.trashEntryId || operation?.payload?.id
|
|
2624
3818
|
);
|
|
3819
|
+
const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
|
|
3820
|
+
encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
|
|
3821
|
+
);
|
|
2625
3822
|
const rtlCloneDocuments = (documents = {}) => ({
|
|
2626
3823
|
root: clone(documents.root || null),
|
|
2627
3824
|
records: clone(documents.records || {}),
|
|
2628
3825
|
folders: clone(documents.folders || {}),
|
|
2629
3826
|
trash: clone(documents.trash || {}),
|
|
3827
|
+
lifecycleTombstones: clone(documents.lifecycleTombstones || {}),
|
|
2630
3828
|
ops: {}
|
|
2631
3829
|
});
|
|
2632
3830
|
const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
|
|
2633
|
-
|
|
2634
|
-
|
|
3831
|
+
/**
|
|
3832
|
+
* Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
|
|
3833
|
+
* so the planner must not drop ids on a prefix guess either.
|
|
3834
|
+
*/
|
|
3835
|
+
const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order);
|
|
2635
3836
|
const rtlPayloadOrder = (operation, keys) => {
|
|
2636
3837
|
for (const key of keys) {
|
|
2637
3838
|
if (Array.isArray(operation?.payload?.[key])) return operation.payload[key];
|
|
@@ -2811,13 +4012,17 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2811
4012
|
const folderIds = new Set();
|
|
2812
4013
|
const folderDeleteIds = new Set();
|
|
2813
4014
|
const trashEntryIds = new Set();
|
|
4015
|
+
const lifecycleTombstoneIds = new Set();
|
|
2814
4016
|
toArray(operations).filter(Boolean).forEach((operation) => {
|
|
2815
4017
|
const payload = operation.payload || {};
|
|
2816
4018
|
switch (operation.type) {
|
|
2817
4019
|
case OPERATION_TYPES.RECORD_CREATE:
|
|
2818
4020
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
2819
4021
|
const recordId = rtlOperationRecordId(operation);
|
|
2820
|
-
if (recordId)
|
|
4022
|
+
if (recordId) {
|
|
4023
|
+
recordIds.add(recordId);
|
|
4024
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
4025
|
+
}
|
|
2821
4026
|
folderIds.add(safeFolderId(
|
|
2822
4027
|
payload.targetFolderId || payload.folderId || payload.record?.folderId
|
|
2823
4028
|
));
|
|
@@ -2826,7 +4031,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2826
4031
|
case OPERATION_TYPES.RECORD_UPDATE:
|
|
2827
4032
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
2828
4033
|
const recordId = rtlOperationRecordId(operation);
|
|
2829
|
-
if (recordId)
|
|
4034
|
+
if (recordId) {
|
|
4035
|
+
recordIds.add(recordId);
|
|
4036
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
|
|
4037
|
+
}
|
|
2830
4038
|
break;
|
|
2831
4039
|
}
|
|
2832
4040
|
case OPERATION_TYPES.RECORD_RESTORE:
|
|
@@ -2842,7 +4050,10 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2842
4050
|
case OPERATION_TYPES.FOLDER_UPDATE:
|
|
2843
4051
|
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
2844
4052
|
const folderId = rtlOperationFolderId(operation);
|
|
2845
|
-
if (folderId)
|
|
4053
|
+
if (folderId) {
|
|
4054
|
+
folderIds.add(folderId);
|
|
4055
|
+
lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', folderId));
|
|
4056
|
+
}
|
|
2846
4057
|
if (folderId && operation.type === OPERATION_TYPES.FOLDER_DELETE) {
|
|
2847
4058
|
folderDeleteIds.add(folderId);
|
|
2848
4059
|
}
|
|
@@ -2852,7 +4063,7 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
|
2852
4063
|
break;
|
|
2853
4064
|
}
|
|
2854
4065
|
});
|
|
2855
|
-
return {recordIds, folderIds, folderDeleteIds, trashEntryIds};
|
|
4066
|
+
return {recordIds, folderIds, folderDeleteIds, trashEntryIds, lifecycleTombstoneIds};
|
|
2856
4067
|
};
|
|
2857
4068
|
|
|
2858
4069
|
export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
|
|
@@ -2860,7 +4071,8 @@ export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records
|
|
|
2860
4071
|
recordIds: new Set(readPlan?.recordIds || []),
|
|
2861
4072
|
folderIds: new Set(readPlan?.folderIds || []),
|
|
2862
4073
|
folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
|
|
2863
|
-
trashEntryIds: new Set(readPlan?.trashEntryIds || [])
|
|
4074
|
+
trashEntryIds: new Set(readPlan?.trashEntryIds || []),
|
|
4075
|
+
lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
|
|
2864
4076
|
};
|
|
2865
4077
|
Object.values(records || {}).forEach((record) => {
|
|
2866
4078
|
next.folderIds.add(safeFolderId(record?.folderId));
|
|
@@ -2873,18 +4085,40 @@ export const extendFirestoreV2OperationReadPlanWithTrash = (readPlan, trashEntri
|
|
|
2873
4085
|
recordIds: new Set(readPlan?.recordIds || []),
|
|
2874
4086
|
folderIds: new Set(readPlan?.folderIds || []),
|
|
2875
4087
|
folderDeleteIds: new Set(readPlan?.folderDeleteIds || []),
|
|
2876
|
-
trashEntryIds: new Set(readPlan?.trashEntryIds || [])
|
|
4088
|
+
trashEntryIds: new Set(readPlan?.trashEntryIds || []),
|
|
4089
|
+
lifecycleTombstoneIds: new Set(readPlan?.lifecycleTombstoneIds || [])
|
|
2877
4090
|
};
|
|
2878
4091
|
Object.values(trashEntries || {}).forEach((entry) => {
|
|
2879
|
-
|
|
4092
|
+
const entityId = normalizeId(entry?.entityId);
|
|
4093
|
+
if (entry?.kind === 'record') {
|
|
4094
|
+
next.folderIds.add(safeFolderId(entry.originalFolderId));
|
|
4095
|
+
if (entityId) {
|
|
4096
|
+
next.lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', entityId));
|
|
4097
|
+
}
|
|
4098
|
+
} else if (entry?.kind === 'folder' && entityId) {
|
|
4099
|
+
next.lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('folder', entityId));
|
|
4100
|
+
}
|
|
2880
4101
|
});
|
|
2881
4102
|
next.folderIds.add(DEFAULT_FOLDER_ID);
|
|
2882
4103
|
return next;
|
|
2883
4104
|
};
|
|
2884
4105
|
|
|
2885
|
-
const rtlApplyOperationToPartialDocuments = ({
|
|
4106
|
+
const rtlApplyOperationToPartialDocuments = ({
|
|
4107
|
+
root,
|
|
4108
|
+
records,
|
|
4109
|
+
folders,
|
|
4110
|
+
trash,
|
|
4111
|
+
lifecycleTombstones,
|
|
4112
|
+
operation,
|
|
4113
|
+
now
|
|
4114
|
+
}) => {
|
|
2886
4115
|
const {state} = buildStateFromFirestoreV2Documents({
|
|
2887
|
-
root: root || {},
|
|
4116
|
+
root: root || {},
|
|
4117
|
+
records: records || {},
|
|
4118
|
+
folders: folders || {},
|
|
4119
|
+
trash: trash || {},
|
|
4120
|
+
lifecycleTombstones: lifecycleTombstones || {},
|
|
4121
|
+
ops: {}
|
|
2888
4122
|
});
|
|
2889
4123
|
const nextState = applyRecordTimeLabelOperation(state, operation);
|
|
2890
4124
|
return {
|
|
@@ -2936,6 +4170,10 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
2936
4170
|
} = {}) => {
|
|
2937
4171
|
const previousDocuments = rtlCloneDocuments(documents);
|
|
2938
4172
|
const nextDocuments = rtlCloneDocuments(documents);
|
|
4173
|
+
const preservedExpandedGroups = previousDocuments.root &&
|
|
4174
|
+
Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
|
|
4175
|
+
? clone(previousDocuments.root.expandedGroups)
|
|
4176
|
+
: undefined;
|
|
2939
4177
|
const normalizedLocalState = normalizeState(localState || {});
|
|
2940
4178
|
const operationResults = [];
|
|
2941
4179
|
let changed = false;
|
|
@@ -2944,12 +4182,23 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
2944
4182
|
}
|
|
2945
4183
|
|
|
2946
4184
|
for (const operation of toArray(operations).filter(Boolean)) {
|
|
4185
|
+
if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
4186
|
+
operationResults.push({
|
|
4187
|
+
id: operation.id || null,
|
|
4188
|
+
type: operation.type,
|
|
4189
|
+
applied: false,
|
|
4190
|
+
status: 'noop',
|
|
4191
|
+
reason: 'local_view_state'
|
|
4192
|
+
});
|
|
4193
|
+
continue;
|
|
4194
|
+
}
|
|
2947
4195
|
const payload = operation.payload || {};
|
|
2948
4196
|
const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
|
|
2949
4197
|
const recordId = rtlOperationRecordId(operation);
|
|
2950
4198
|
const folderId = rtlOperationFolderId(operation);
|
|
2951
4199
|
let applied = null;
|
|
2952
4200
|
let reason = null;
|
|
4201
|
+
let lifecycleTombstoneId = null;
|
|
2953
4202
|
|
|
2954
4203
|
switch (operation.type) {
|
|
2955
4204
|
case OPERATION_TYPES.RECORD_CREATE: {
|
|
@@ -3012,25 +4261,28 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3012
4261
|
});
|
|
3013
4262
|
const recordDocument = applied.documents.records[recordId];
|
|
3014
4263
|
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
4264
|
+
const candidateRoot = clone(applied.documents.root);
|
|
4265
|
+
const candidateFolders = clone(nextDocuments.folders);
|
|
3015
4266
|
const targetFolder = rtlEnsureTargetFolder({
|
|
3016
|
-
root:
|
|
3017
|
-
folders:
|
|
4267
|
+
root: candidateRoot,
|
|
4268
|
+
folders: candidateFolders,
|
|
3018
4269
|
localState: normalizedLocalState,
|
|
3019
4270
|
folderId: targetFolderId,
|
|
3020
4271
|
now: operationNow
|
|
3021
4272
|
});
|
|
3022
4273
|
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
3027
|
-
nextDocuments.folders[sourceFolderId].recordOrder
|
|
4274
|
+
if (candidateFolders[sourceFolderId]) {
|
|
4275
|
+
candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
4276
|
+
candidateFolders[sourceFolderId].recordOrder
|
|
3028
4277
|
).filter((id) => id !== recordId);
|
|
3029
4278
|
}
|
|
3030
4279
|
targetFolder.recordOrder = [
|
|
3031
4280
|
...rtlMergeOrder(targetFolder.recordOrder).filter((id) => id !== recordId),
|
|
3032
4281
|
recordId
|
|
3033
4282
|
];
|
|
4283
|
+
rtlSetRoot(nextDocuments.root, candidateRoot);
|
|
4284
|
+
nextDocuments.folders = candidateFolders;
|
|
4285
|
+
nextDocuments.records[recordId] = recordDocument;
|
|
3034
4286
|
changed = true;
|
|
3035
4287
|
break;
|
|
3036
4288
|
}
|
|
@@ -3050,6 +4302,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3050
4302
|
}
|
|
3051
4303
|
case OPERATION_TYPES.RECORD_DELETE: {
|
|
3052
4304
|
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
4305
|
+
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
|
|
3053
4306
|
const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
|
|
3054
4307
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3055
4308
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
@@ -3078,6 +4331,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3078
4331
|
reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
|
|
3079
4332
|
break;
|
|
3080
4333
|
}
|
|
4334
|
+
lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
|
|
3081
4335
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3082
4336
|
const restoredRecord = applied.documents.records[restoredRecordId];
|
|
3083
4337
|
if (!restoredRecord) { reason = 'restore_conflict'; break; }
|
|
@@ -3088,10 +4342,12 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3088
4342
|
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
3089
4343
|
nextDocuments.records[restoredRecordId] = restoredRecord;
|
|
3090
4344
|
delete nextDocuments.trash[trashEntryId];
|
|
4345
|
+
const previousRecordOrder = rtlMergeOrder(targetFolder.recordOrder);
|
|
4346
|
+
const restoreIndex = Number(trashEntry.originalRecordIndex || 0);
|
|
3091
4347
|
targetFolder.recordOrder = rtlMergeOrder(
|
|
3092
|
-
|
|
4348
|
+
previousRecordOrder.slice(0, restoreIndex),
|
|
3093
4349
|
[restoredRecordId],
|
|
3094
|
-
|
|
4350
|
+
previousRecordOrder.slice(restoreIndex)
|
|
3095
4351
|
);
|
|
3096
4352
|
changed = true;
|
|
3097
4353
|
break;
|
|
@@ -3137,7 +4393,6 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3137
4393
|
break;
|
|
3138
4394
|
case OPERATION_TYPES.FOLDER_REORDER:
|
|
3139
4395
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
3140
|
-
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
3141
4396
|
case OPERATION_TYPES.SETTINGS_UPDATE: {
|
|
3142
4397
|
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
3143
4398
|
const appliedRoot = applied.documents.root;
|
|
@@ -3153,12 +4408,6 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3153
4408
|
nextDocuments.root.groupOrder,
|
|
3154
4409
|
normalizedLocalState.groupOrder
|
|
3155
4410
|
));
|
|
3156
|
-
} else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
3157
|
-
appliedRoot.expandedGroups = rtlMergeOrder(
|
|
3158
|
-
rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids']),
|
|
3159
|
-
nextDocuments.root.expandedGroups,
|
|
3160
|
-
normalizedLocalState.expandedGroups
|
|
3161
|
-
);
|
|
3162
4411
|
}
|
|
3163
4412
|
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
3164
4413
|
changed = true;
|
|
@@ -3167,6 +4416,14 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3167
4416
|
default:
|
|
3168
4417
|
reason = 'unsupported_operation';
|
|
3169
4418
|
}
|
|
4419
|
+
if (applied && !reason && lifecycleTombstoneId) {
|
|
4420
|
+
const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
|
|
4421
|
+
if (nextTombstone) {
|
|
4422
|
+
nextDocuments.lifecycleTombstones[lifecycleTombstoneId] = clone(nextTombstone);
|
|
4423
|
+
} else {
|
|
4424
|
+
delete nextDocuments.lifecycleTombstones[lifecycleTombstoneId];
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
3170
4427
|
operationResults.push({
|
|
3171
4428
|
id: operation.id || null,
|
|
3172
4429
|
type: operation.type || null,
|
|
@@ -3183,6 +4440,9 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3183
4440
|
now,
|
|
3184
4441
|
syncMetaPatch: {lastBackgroundSyncAt: now}
|
|
3185
4442
|
}).root;
|
|
4443
|
+
if (preservedExpandedGroups !== undefined) {
|
|
4444
|
+
nextDocuments.root.expandedGroups = preservedExpandedGroups;
|
|
4445
|
+
}
|
|
3186
4446
|
}
|
|
3187
4447
|
const changes = buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, {
|
|
3188
4448
|
allowDeletes: true
|
|
@@ -3200,7 +4460,7 @@ export const planFirestoreV2OperationChanges = ({
|
|
|
3200
4460
|
* Counts all billable writes before a transaction reserves quota.
|
|
3201
4461
|
*/
|
|
3202
4462
|
export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
|
|
3203
|
-
const collectionWrites = ['records', 'folders', 'trash', 'ops'].reduce((count, key) => (
|
|
4463
|
+
const collectionWrites = ['records', 'folders', 'trash', 'lifecycleTombstones', 'ops'].reduce((count, key) => (
|
|
3204
4464
|
count + Object.keys(changes?.[key]?.upserts || {}).length +
|
|
3205
4465
|
toArray(changes?.[key]?.deleteIds).length
|
|
3206
4466
|
), 0);
|
|
@@ -3222,6 +4482,15 @@ const rtlStateRecordMap = (state) => {
|
|
|
3222
4482
|
return records;
|
|
3223
4483
|
};
|
|
3224
4484
|
|
|
4485
|
+
const rtlStateRecordOrders = (state) => {
|
|
4486
|
+
const orders = new Map();
|
|
4487
|
+
Object.entries(normalizeState(state || {}).records).forEach(([folderId, entries]) => {
|
|
4488
|
+
if (VIRTUAL_FOLDER_IDS.has(folderId)) return;
|
|
4489
|
+
orders.set(folderId, toArray(entries).map(getRecordId).filter(Boolean));
|
|
4490
|
+
});
|
|
4491
|
+
return orders;
|
|
4492
|
+
};
|
|
4493
|
+
|
|
3225
4494
|
const rtlComparableJson = (value) => JSON.stringify(value || {});
|
|
3226
4495
|
|
|
3227
4496
|
/**
|
|
@@ -3240,6 +4509,11 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3240
4509
|
const nextRecords = rtlStateRecordMap(next);
|
|
3241
4510
|
const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
|
|
3242
4511
|
const nextFolders = new Map(next.folders.map((folder) => [folder.id, folder]));
|
|
4512
|
+
const deletedFolderIds = new Set(
|
|
4513
|
+
[...previousFolders.keys()].filter((folderId) => (
|
|
4514
|
+
!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)
|
|
4515
|
+
))
|
|
4516
|
+
);
|
|
3243
4517
|
const drafts = [];
|
|
3244
4518
|
const bulkDrafts = [];
|
|
3245
4519
|
|
|
@@ -3271,18 +4545,29 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3271
4545
|
});
|
|
3272
4546
|
|
|
3273
4547
|
previousRecords.forEach((entry, recordId) => {
|
|
3274
|
-
if (!nextRecords.has(recordId)) {
|
|
4548
|
+
if (!nextRecords.has(recordId) && !deletedFolderIds.has(entry.folderId)) {
|
|
3275
4549
|
drafts.push({type: OPERATION_TYPES.RECORD_DELETE, payload: {recordId, deletedAt: now}});
|
|
3276
4550
|
}
|
|
3277
4551
|
});
|
|
3278
4552
|
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
4553
|
+
// Create prepends and move appends records, so neither operation can reproduce an arbitrary
|
|
4554
|
+
// snapshot position. Emit the complete target order whenever a folder's order differs; preceding
|
|
4555
|
+
// create/move/delete operations make the planner's fallback order contain only live target ids.
|
|
4556
|
+
const previousRecordOrders = rtlStateRecordOrders(previous);
|
|
4557
|
+
rtlStateRecordOrders(next).forEach((nextOrder, folderId) => {
|
|
4558
|
+
const previousOrder = toArray(previousRecordOrders.get(folderId));
|
|
4559
|
+
if (nextOrder.length === 0 || rtlComparableJson(previousOrder) === rtlComparableJson(nextOrder)) return;
|
|
4560
|
+
drafts.push({
|
|
4561
|
+
type: OPERATION_TYPES.RECORD_REORDER,
|
|
4562
|
+
payload: {folderId, recordIds: nextOrder}
|
|
4563
|
+
});
|
|
4564
|
+
});
|
|
4565
|
+
|
|
4566
|
+
deletedFolderIds.forEach((folderId) => {
|
|
4567
|
+
bulkDrafts.push({
|
|
4568
|
+
type: OPERATION_TYPES.FOLDER_DELETE,
|
|
4569
|
+
payload: {folderId, deletedAt: now}
|
|
4570
|
+
});
|
|
3286
4571
|
});
|
|
3287
4572
|
|
|
3288
4573
|
if (rtlComparableJson(previous.folderOrder) !== rtlComparableJson(next.folderOrder)) {
|
|
@@ -3291,12 +4576,6 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3291
4576
|
if (rtlComparableJson(previous.groupOrder) !== rtlComparableJson(next.groupOrder)) {
|
|
3292
4577
|
drafts.push({type: OPERATION_TYPES.GROUP_REORDER, payload: {groupOrder: next.groupOrder}});
|
|
3293
4578
|
}
|
|
3294
|
-
if (rtlComparableJson(previous.expandedGroups) !== rtlComparableJson(next.expandedGroups)) {
|
|
3295
|
-
drafts.push({
|
|
3296
|
-
type: OPERATION_TYPES.EXPANDED_GROUPS_UPDATE,
|
|
3297
|
-
payload: {expandedGroups: next.expandedGroups}
|
|
3298
|
-
});
|
|
3299
|
-
}
|
|
3300
4579
|
if (rtlComparableJson(previous.settings) !== rtlComparableJson(next.settings)) {
|
|
3301
4580
|
drafts.push({type: OPERATION_TYPES.SETTINGS_UPDATE, payload: {patch: next.settings}});
|
|
3302
4581
|
}
|
|
@@ -3337,6 +4616,7 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
3337
4616
|
|
|
3338
4617
|
export default {
|
|
3339
4618
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
4619
|
+
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
3340
4620
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
3341
4621
|
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
3342
4622
|
RTL_MAX_REQUEST_BYTES,
|
|
@@ -3346,7 +4626,10 @@ export default {
|
|
|
3346
4626
|
RECORD_TIMELABEL_SYNC_MODES,
|
|
3347
4627
|
RECORD_TIMELABEL_CLOUD_SCHEMAS,
|
|
3348
4628
|
FIRESTORE_V2_SETTINGS_DOC_ID,
|
|
4629
|
+
RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS,
|
|
3349
4630
|
normalizeState,
|
|
4631
|
+
normalizeRecordTimeLabelDomainState,
|
|
4632
|
+
migrateRecordTimeLabelExpandedGroups,
|
|
3350
4633
|
getActiveTrashEntries,
|
|
3351
4634
|
hasMeaningfulRecordTimeLabelCloudState,
|
|
3352
4635
|
buildRecordTimeLabelContentFingerprint,
|
|
@@ -3373,12 +4656,15 @@ export default {
|
|
|
3373
4656
|
extendFirestoreV2OperationReadPlanWithTrash,
|
|
3374
4657
|
planFirestoreV2OperationChanges,
|
|
3375
4658
|
estimateFirestoreV2WriteUnits,
|
|
4659
|
+
normalizeRecordTimeLabelOperationResults,
|
|
4660
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
3376
4661
|
buildOperationsFromSnapshotDiff,
|
|
3377
4662
|
flushPendingOperations,
|
|
3378
4663
|
mergeRemoteStateIntoLocal,
|
|
3379
4664
|
applyOperation,
|
|
3380
4665
|
applyRecordTimeLabelOperation,
|
|
3381
4666
|
createOperation,
|
|
4667
|
+
createRecordTimeLabelSyncEngine,
|
|
3382
4668
|
createSyncEngine,
|
|
3383
4669
|
createRecordTimeLabelController
|
|
3384
4670
|
};
|