@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/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.3.2';
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
- if (!current || toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt)) {
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
- if (!recordSnapshot && nextState.trashEntries[`record:${recordId}`]) return normalized;
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
- nextState.expandedGroups = normalizeOrder(payload.expandedGroups || payload.groupIds || payload.order || payload.ids);
1104
- break;
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 rootUpsert = nextRoot && !areFirestoreV2DocumentValuesEqual(previousRoot, nextRoot)
1720
- ? nextRoot
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 hasCollectionChanges = [records, folders, trash, ops].some((changeSet) => (
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: rootDoc.deletedRecordTombstones || {},
1824
- deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
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
- const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order)
2634
- .filter((id) => id.startsWith('group-'));
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) recordIds.add(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) recordIds.add(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) folderIds.add(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
- if (entry?.kind === 'record') next.folderIds.add(safeFolderId(entry.originalFolderId));
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 = ({root, records, folders, trash, operation, now}) => {
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 || {}, records: records || {}, folders: folders || {}, trash: trash || {}, ops: {}
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: nextDocuments.root,
3017
- folders: nextDocuments.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
- rtlSetRoot(nextDocuments.root, applied.documents.root);
3024
- nextDocuments.records[recordId] = recordDocument;
3025
- if (nextDocuments.folders[sourceFolderId]) {
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
- targetFolder.recordOrder.slice(0, Number(trashEntry.originalRecordIndex || 0)),
4348
+ previousRecordOrder.slice(0, restoreIndex),
3093
4349
  [restoredRecordId],
3094
- targetFolder.recordOrder.slice(Number(trashEntry.originalRecordIndex || 0))
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
- previousFolders.forEach((folder, folderId) => {
3280
- if (!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)) {
3281
- bulkDrafts.push({
3282
- type: OPERATION_TYPES.FOLDER_DELETE,
3283
- payload: {folderId, deletedAt: now}
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
  };