@recordtimelabel/core 0.3.3 → 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.3';
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;
@@ -562,6 +579,49 @@ export const normalizeState = (input = {}) => {
562
579
  };
563
580
  };
564
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
+
565
625
  export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {}) => {
566
626
  const state = normalizeState(input || {});
567
627
  const defaultFolderIds = new Set(options.defaultFolderIds || ['all', 'uncategorized']);
@@ -582,8 +642,7 @@ export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {})
582
642
  Object.keys(state.deletedFolderTombstones || {}).length > 0 ||
583
643
  Object.keys(state.trashEntries || {}).length > 0 ||
584
644
  hasNonDefaultGroupOrder ||
585
- state.folderOrder.length > 0 ||
586
- state.expandedGroups.length > 0;
645
+ state.folderOrder.length > 0;
587
646
  };
588
647
 
589
648
  export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
@@ -594,7 +653,6 @@ export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
594
653
  settings: state.settings,
595
654
  groupOrder: state.groupOrder,
596
655
  folderOrder: state.folderOrder,
597
- expandedGroups: state.expandedGroups,
598
656
  deletedRecordTombstones: state.deletedRecordTombstones,
599
657
  deletedFolderTombstones: state.deletedFolderTombstones,
600
658
  trashEntries: state.trashEntries
@@ -1115,8 +1173,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1115
1173
  break;
1116
1174
 
1117
1175
  case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
1118
- nextState.expandedGroups = normalizeOrder(payload.expandedGroups || payload.groupIds || payload.order || payload.ids);
1119
- break;
1176
+ // Legacy view operations are migrated by the app adapter and are never
1177
+ // part of the cloud/domain reducer.
1178
+ return normalized;
1120
1179
 
1121
1180
  case OPERATION_TYPES.SETTINGS_UPDATE:
1122
1181
  nextState.settings = {
@@ -1669,7 +1728,6 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1669
1728
  settings: normalized.settings || {},
1670
1729
  groupOrder: normalizeGroupOrderForOption(normalized.groupOrder, options.groupOrderNormalizer),
1671
1730
  folderOrder: normalized.folderOrder,
1672
- expandedGroups: normalized.expandedGroups,
1673
1731
  rtlSyncMeta: {
1674
1732
  ...(normalized.rtlSyncMeta || {}),
1675
1733
  schemaVersion: 2,
@@ -1760,8 +1818,15 @@ export const buildFirestoreV2DocumentChangeSet = (
1760
1818
  const allowDeletes = options.allowDeletes === true;
1761
1819
  const previousRoot = previousDocuments?.root;
1762
1820
  const nextRoot = nextDocuments?.root;
1763
- const rootUpsert = nextRoot && !areFirestoreV2DocumentValuesEqual(previousRoot, nextRoot)
1764
- ? 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
1765
1830
  : null;
1766
1831
  const records = buildFirestoreV2CollectionChangeSet(
1767
1832
  previousDocuments?.records,
@@ -1911,7 +1976,6 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1911
1976
  settings: rootDoc.settings || {},
1912
1977
  groupOrder: normalizeGroupOrderForOption(rootDoc.groupOrder, options.groupOrderNormalizer),
1913
1978
  folderOrder: rootDoc.folderOrder || [],
1914
- expandedGroups: rootDoc.expandedGroups || [],
1915
1979
  rtlSyncMeta: rootDoc.rtlSyncMeta || {},
1916
1980
  deletedRecordTombstones: mergeLifecycleTombstoneSources(
1917
1981
  rootDoc.deletedRecordTombstones,
@@ -2538,6 +2602,1037 @@ export const createRecordTimeLabelController = ({
2538
2602
  return controller;
2539
2603
  };
2540
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
+
2541
3636
  export const createSyncEngine = ({
2542
3637
  storageAdapter,
2543
3638
  cloudAdapter,
@@ -3075,6 +4170,10 @@ export const planFirestoreV2OperationChanges = ({
3075
4170
  } = {}) => {
3076
4171
  const previousDocuments = rtlCloneDocuments(documents);
3077
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;
3078
4177
  const normalizedLocalState = normalizeState(localState || {});
3079
4178
  const operationResults = [];
3080
4179
  let changed = false;
@@ -3083,6 +4182,16 @@ export const planFirestoreV2OperationChanges = ({
3083
4182
  }
3084
4183
 
3085
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
+ }
3086
4195
  const payload = operation.payload || {};
3087
4196
  const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
3088
4197
  const recordId = rtlOperationRecordId(operation);
@@ -3284,7 +4393,6 @@ export const planFirestoreV2OperationChanges = ({
3284
4393
  break;
3285
4394
  case OPERATION_TYPES.FOLDER_REORDER:
3286
4395
  case OPERATION_TYPES.GROUP_REORDER:
3287
- case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
3288
4396
  case OPERATION_TYPES.SETTINGS_UPDATE: {
3289
4397
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3290
4398
  const appliedRoot = applied.documents.root;
@@ -3300,10 +4408,6 @@ export const planFirestoreV2OperationChanges = ({
3300
4408
  nextDocuments.root.groupOrder,
3301
4409
  normalizedLocalState.groupOrder
3302
4410
  ));
3303
- } else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
3304
- appliedRoot.expandedGroups = rtlMergeOrder(
3305
- rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
3306
- );
3307
4411
  }
3308
4412
  rtlSetRoot(nextDocuments.root, appliedRoot);
3309
4413
  changed = true;
@@ -3336,6 +4440,9 @@ export const planFirestoreV2OperationChanges = ({
3336
4440
  now,
3337
4441
  syncMetaPatch: {lastBackgroundSyncAt: now}
3338
4442
  }).root;
4443
+ if (preservedExpandedGroups !== undefined) {
4444
+ nextDocuments.root.expandedGroups = preservedExpandedGroups;
4445
+ }
3339
4446
  }
3340
4447
  const changes = buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, {
3341
4448
  allowDeletes: true
@@ -3469,12 +4576,6 @@ export const buildOperationsFromSnapshotDiff = ({
3469
4576
  if (rtlComparableJson(previous.groupOrder) !== rtlComparableJson(next.groupOrder)) {
3470
4577
  drafts.push({type: OPERATION_TYPES.GROUP_REORDER, payload: {groupOrder: next.groupOrder}});
3471
4578
  }
3472
- if (rtlComparableJson(previous.expandedGroups) !== rtlComparableJson(next.expandedGroups)) {
3473
- drafts.push({
3474
- type: OPERATION_TYPES.EXPANDED_GROUPS_UPDATE,
3475
- payload: {expandedGroups: next.expandedGroups}
3476
- });
3477
- }
3478
4579
  if (rtlComparableJson(previous.settings) !== rtlComparableJson(next.settings)) {
3479
4580
  drafts.push({type: OPERATION_TYPES.SETTINGS_UPDATE, payload: {patch: next.settings}});
3480
4581
  }
@@ -3515,6 +4616,7 @@ export const buildOperationsFromSnapshotDiff = ({
3515
4616
 
3516
4617
  export default {
3517
4618
  RECORD_TIMELABEL_CORE_VERSION,
4619
+ RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
3518
4620
  RTL_SYNC_PROTOCOL_VERSION,
3519
4621
  RTL_MAX_OPERATIONS_PER_REQUEST,
3520
4622
  RTL_MAX_REQUEST_BYTES,
@@ -3524,7 +4626,10 @@ export default {
3524
4626
  RECORD_TIMELABEL_SYNC_MODES,
3525
4627
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
3526
4628
  FIRESTORE_V2_SETTINGS_DOC_ID,
4629
+ RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS,
3527
4630
  normalizeState,
4631
+ normalizeRecordTimeLabelDomainState,
4632
+ migrateRecordTimeLabelExpandedGroups,
3528
4633
  getActiveTrashEntries,
3529
4634
  hasMeaningfulRecordTimeLabelCloudState,
3530
4635
  buildRecordTimeLabelContentFingerprint,
@@ -3551,12 +4656,15 @@ export default {
3551
4656
  extendFirestoreV2OperationReadPlanWithTrash,
3552
4657
  planFirestoreV2OperationChanges,
3553
4658
  estimateFirestoreV2WriteUnits,
4659
+ normalizeRecordTimeLabelOperationResults,
4660
+ normalizeRecordTimeLabelEnvelopeResponse,
3554
4661
  buildOperationsFromSnapshotDiff,
3555
4662
  flushPendingOperations,
3556
4663
  mergeRemoteStateIntoLocal,
3557
4664
  applyOperation,
3558
4665
  applyRecordTimeLabelOperation,
3559
4666
  createOperation,
4667
+ createRecordTimeLabelSyncEngine,
3560
4668
  createSyncEngine,
3561
4669
  createRecordTimeLabelController
3562
4670
  };