@recordtimelabel/core 0.3.3 → 0.4.1

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.1';
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,1055 @@ 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 details = [
2932
+ error?.name,
2933
+ error?.code,
2934
+ error?.reason,
2935
+ error?.message,
2936
+ value?.code,
2937
+ value?.reason,
2938
+ value?.message
2939
+ ].filter(Boolean).join(' ').toLowerCase();
2940
+ return Boolean(
2941
+ error?.retryable === true ||
2942
+ value?.retryable === true ||
2943
+ status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 ||
2944
+ details.includes('bulk_job_in_progress') || details.includes('retryable') ||
2945
+ details.includes('temporarily_unavailable') || details.includes('unavailable') ||
2946
+ details.includes('deadline_exceeded') || details.includes('failed to fetch') ||
2947
+ details.includes('fetch failed') || details.includes('network-request-failed') ||
2948
+ details.includes('network request failed') || details.includes('network_error') ||
2949
+ details.includes('err_network') || details.includes('econnreset') ||
2950
+ details.includes('etimedout')
2951
+ );
2952
+ };
2953
+
2954
+ const rtlRetryAfterMs = (value) => {
2955
+ const error = value?.error && typeof value.error === 'object' ? value.error : value;
2956
+ const rawRetryAfter = error?.retryAfterMs ?? value?.retryAfterMs;
2957
+ if (rawRetryAfter === null || rawRetryAfter === undefined || rawRetryAfter === '') return null;
2958
+ const retryAfter = Number(rawRetryAfter);
2959
+ return Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
2960
+ };
2961
+
2962
+ const rtlResultOperationId = (result = {}) => normalizeId(
2963
+ result.id ?? result.operationId ?? result.opId ?? result.operation?.id
2964
+ );
2965
+
2966
+ const rtlIsStaleRevision = (revision, baselineRevision) => (
2967
+ Number.isFinite(Number(revision)) && Number(revision) < Number(baselineRevision)
2968
+ );
2969
+
2970
+ const rtlDeriveDurableVisibleState = (workspace) => {
2971
+ let state = normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {});
2972
+ toArray(workspace?.pendingOperations).forEach((operation) => {
2973
+ state = operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
2974
+ ? state
2975
+ : normalizeRecordTimeLabelDomainState(applyRecordTimeLabelOperation(state, operation));
2976
+ });
2977
+ return state;
2978
+ };
2979
+
2980
+ const rtlDurableWorkspaceSnapshot = (workspace) => {
2981
+ const persisted = clone(workspace);
2982
+ const state = rtlDeriveDurableVisibleState(workspace);
2983
+ return {
2984
+ ...persisted,
2985
+ state: clone(state),
2986
+ visibleState: clone(state),
2987
+ // These aliases make migration from the v1 engine less surprising while
2988
+ // the persisted contract remains strictly pendingOperations-based.
2989
+ pendingOps: clone(workspace.pendingOperations),
2990
+ syncMeta: clone(workspace.syncMeta)
2991
+ };
2992
+ };
2993
+
2994
+ export const createRecordTimeLabelSyncEngine = ({
2995
+ storage,
2996
+ cloud,
2997
+ session,
2998
+ client = 'recordtimelabel-client',
2999
+ clock = () => Date.now(),
3000
+ logger = console
3001
+ } = {}) => {
3002
+ if (!storage?.load || !storage?.save) {
3003
+ throw new Error('createRecordTimeLabelSyncEngine requires storage.load() and storage.save()');
3004
+ }
3005
+
3006
+ let workspace = rtlEmptyDurableWorkspace();
3007
+ let initialized = false;
3008
+ let destroyed = false;
3009
+ let unsubscribeCloud = null;
3010
+ let unsubscribeSession = null;
3011
+ let subscriptionContext = null;
3012
+ const listeners = new Set();
3013
+ let queue = Promise.resolve();
3014
+
3015
+ const now = () => {
3016
+ try {
3017
+ const value = typeof clock === 'function' ? clock() : clock;
3018
+ return Number.isFinite(Number(value)) ? Number(value) : Date.now();
3019
+ } catch {
3020
+ return Date.now();
3021
+ }
3022
+ };
3023
+
3024
+ const getSnapshot = () => rtlDurableWorkspaceSnapshot(workspace);
3025
+
3026
+ const notify = (event = {}) => {
3027
+ if (destroyed) return;
3028
+ const snapshot = getSnapshot();
3029
+ listeners.forEach((listener) => {
3030
+ try {
3031
+ listener({...event, snapshot: clone(snapshot)});
3032
+ } catch (error) {
3033
+ logger?.error?.('[RecordTimeLabelCore] durable listener failed', error);
3034
+ }
3035
+ });
3036
+ };
3037
+
3038
+ const enqueue = (task) => {
3039
+ const run = queue.then(async () => {
3040
+ if (destroyed) return {stale: true, reason: 'destroyed'};
3041
+ return task();
3042
+ });
3043
+ queue = run.catch(() => {});
3044
+ return run;
3045
+ };
3046
+
3047
+ const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
3048
+
3049
+ const isCurrent = async (captured) => {
3050
+ if (destroyed || !captured) return false;
3051
+ if (typeof session?.isCurrent === 'function') {
3052
+ try {
3053
+ return Boolean(await session.isCurrent(
3054
+ captured.sessionToken,
3055
+ captured.uid,
3056
+ captured.workspaceEpoch
3057
+ ));
3058
+ } catch {
3059
+ return false;
3060
+ }
3061
+ }
3062
+ if (typeof session?.current !== 'function') return true;
3063
+ const current = rtlExtractSession(session, captured.workspaceEpoch);
3064
+ if (captured.token !== undefined && captured.token !== null && current.token !== captured.token) return false;
3065
+ if (captured.uid !== current.uid) return false;
3066
+ return !captured.hasEpoch || current.workspaceEpoch === captured.workspaceEpoch;
3067
+ };
3068
+
3069
+ const persist = async (candidate, captured) => {
3070
+ if (!(await isCurrent(captured))) return false;
3071
+ // The second argument is an optional adapter-side fence. The public
3072
+ // StoragePort remains compatible with save(workspace); adapters that can
3073
+ // enforce an atomic session/epoch check should consume this context before
3074
+ // mutating durable storage.
3075
+ await storage.save(clone(candidate), {
3076
+ sessionToken: captured?.sessionToken ?? null,
3077
+ uid: captured?.uid ?? null,
3078
+ ownerUid: captured?.uid ?? null,
3079
+ workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
3080
+ });
3081
+ if (!(await isCurrent(captured))) return false;
3082
+ return true;
3083
+ };
3084
+
3085
+ const applyRemoteBaseline = (candidate, remoteValue) => {
3086
+ const remote = rtlEnvelopePayload(remoteValue);
3087
+ if (!remote) return {changed: false, stale: false};
3088
+ const revision = Number(remote.revision);
3089
+ if (rtlIsStaleRevision(revision, candidate.remoteBaseline.revision)) {
3090
+ return {changed: false, stale: true};
3091
+ }
3092
+ const nextBaseline = rtlNormalizeRemoteBaseline({
3093
+ state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
3094
+ revision: Number.isFinite(revision) ? revision : candidate.remoteBaseline.revision,
3095
+ changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
3096
+ ? remote.changeCursor
3097
+ : candidate.remoteBaseline.changeCursor
3098
+ });
3099
+ const changed = JSON.stringify(nextBaseline) !== JSON.stringify(candidate.remoteBaseline);
3100
+ candidate.remoteBaseline = nextBaseline;
3101
+ return {changed, stale: false};
3102
+ };
3103
+
3104
+ const normalizeLoadedWorkspace = (loaded, captured) => {
3105
+ const source = loaded && typeof loaded === 'object' ? loaded : {};
3106
+ const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
3107
+ const sourceOwnerUid = rtlNormalizeUid(source.ownerUid);
3108
+ const sourcePending = toArray(source.pendingOperations ?? source.pendingOps);
3109
+ const sourceHasBoundOperation = sourcePending.some((operation) => rtlNormalizeUid(operation?.ownerUid));
3110
+ const loadedWorkspace = rtlNormalizeDurableWorkspace(
3111
+ hasDurableShape ? source : {
3112
+ ...source,
3113
+ state: source.state || source,
3114
+ pendingOps: source.pendingOps || source.pendingOperations || [],
3115
+ syncMeta: source.syncMeta || {}
3116
+ },
3117
+ {
3118
+ ownerUid: captured?.uid,
3119
+ workspaceEpoch: captured?.workspaceEpoch,
3120
+ client,
3121
+ now
3122
+ }
3123
+ );
3124
+ const currentUid = captured?.uid;
3125
+ const currentEpoch = captured?.workspaceEpoch ?? loadedWorkspace.workspaceEpoch;
3126
+ const anonymousMigration = Boolean(
3127
+ currentUid &&
3128
+ !sourceOwnerUid &&
3129
+ !sourceHasBoundOperation &&
3130
+ !loadedWorkspace.syncMeta?.anonymousMigrationAt
3131
+ );
3132
+ if (anonymousMigration) {
3133
+ const anonymousState = loadedWorkspace.remoteBaseline.state;
3134
+ const migrationTime = Math.max(1, rtlToFiniteNumber(anonymousState.lastModified, now()));
3135
+ const migratedOperations = [];
3136
+ const seenIds = new Set();
3137
+ const addMigrationOperation = (type, payload, id) => {
3138
+ const normalizedId = normalizeId(id);
3139
+ if (!normalizedId || seenIds.has(normalizedId)) return;
3140
+ seenIds.add(normalizedId);
3141
+ migratedOperations.push(rtlNormalizePendingOperation({
3142
+ id: normalizedId,
3143
+ type,
3144
+ payload,
3145
+ clientId: 'anonymous-migration',
3146
+ createdAt: migrationTime
3147
+ }, {
3148
+ client,
3149
+ now,
3150
+ ownerUid: currentUid,
3151
+ workspaceEpoch: currentEpoch
3152
+ }));
3153
+ };
3154
+ toArray(anonymousState.folders).forEach((folder) => {
3155
+ const id = normalizeId(folder?.id);
3156
+ if (!id || id === 'all' || id === 'uncategorized') return;
3157
+ addMigrationOperation(
3158
+ OPERATION_TYPES.FOLDER_CREATE,
3159
+ {folder: clone(folder)},
3160
+ `anonymous:folder.create:${id}`
3161
+ );
3162
+ });
3163
+ Object.entries(anonymousState.records || {}).forEach(([folderId, records]) => {
3164
+ if (folderId === 'all') return;
3165
+ toArray(records).forEach((record) => {
3166
+ const id = normalizeId(record?.id);
3167
+ if (!id) return;
3168
+ const migratedRecord = clone(record);
3169
+ if (migratedRecord && typeof migratedRecord === 'object') {
3170
+ delete migratedRecord.pendingSync;
3171
+ delete migratedRecord.syncAttempts;
3172
+ }
3173
+ addMigrationOperation(
3174
+ OPERATION_TYPES.RECORD_CREATE,
3175
+ {folderId, record: migratedRecord},
3176
+ `anonymous:record.create:${id}`
3177
+ );
3178
+ });
3179
+ });
3180
+ loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
3181
+ loadedWorkspace.pendingOperations = [
3182
+ ...migratedOperations,
3183
+ ...loadedWorkspace.pendingOperations
3184
+ .filter((operation) => (
3185
+ operation?.type === OPERATION_TYPES.FOLDER_CREATE ||
3186
+ operation?.type === OPERATION_TYPES.RECORD_CREATE
3187
+ ))
3188
+ .map((operation) => rtlNormalizePendingOperation(operation, {
3189
+ client,
3190
+ now,
3191
+ ownerUid: currentUid,
3192
+ workspaceEpoch: currentEpoch
3193
+ }))
3194
+ ];
3195
+ loadedWorkspace.ownerUid = currentUid;
3196
+ loadedWorkspace.workspaceEpoch = currentEpoch;
3197
+ loadedWorkspace.syncMeta = {
3198
+ ...loadedWorkspace.syncMeta,
3199
+ anonymousMigrationAt: now(),
3200
+ anonymousMigrationMode: 'create_only'
3201
+ };
3202
+ }
3203
+ const ownerMismatch = captured?.hasSession && loadedWorkspace.ownerUid !== currentUid && !anonymousMigration;
3204
+ const epochMismatch = Boolean(
3205
+ captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
3206
+ );
3207
+ if (ownerMismatch || epochMismatch) {
3208
+ return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
3209
+ }
3210
+ const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
3211
+ ? loadedWorkspace.syncMeta.migratedExpandedGroups
3212
+ : loadedWorkspace.syncMeta?.legacyExpandedGroups || [];
3213
+ const migratedView = migrateRecordTimeLabelExpandedGroups({
3214
+ pendingOperations: loadedWorkspace.pendingOperations,
3215
+ currentView
3216
+ });
3217
+ const hadLegacyView = migratedView.pendingOperations.length !==
3218
+ loadedWorkspace.pendingOperations.length ||
3219
+ Object.prototype.hasOwnProperty.call(loadedWorkspace.syncMeta || {}, 'legacyExpandedGroups');
3220
+ loadedWorkspace.pendingOperations = migratedView.pendingOperations;
3221
+ loadedWorkspace.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
3222
+ loadedWorkspace.remoteBaseline.state
3223
+ );
3224
+ if (hadLegacyView) {
3225
+ const syncMeta = {...loadedWorkspace.syncMeta};
3226
+ delete syncMeta.legacyExpandedGroups;
3227
+ loadedWorkspace.syncMeta = {
3228
+ ...syncMeta,
3229
+ migratedExpandedGroups: migratedView.expandedGroups,
3230
+ expandedGroupsMigrationAt: now()
3231
+ };
3232
+ }
3233
+ loadedWorkspace.ownerUid = currentUid ?? loadedWorkspace.ownerUid;
3234
+ if (captured?.hasEpoch) loadedWorkspace.workspaceEpoch = currentEpoch;
3235
+ return loadedWorkspace;
3236
+ };
3237
+
3238
+ const normalizeOperationResults = (response, sentOperations) => {
3239
+ let results;
3240
+ try {
3241
+ // Keep the Plan 017 engine's private input aliases compatible while the
3242
+ // public shared contract remains strict about operationResults arrays.
3243
+ const legacyOperationResults = response?.operationResults;
3244
+ const compatibleResponse = rtlDurableIsObject(legacyOperationResults)
3245
+ ? {
3246
+ ...response,
3247
+ operationResults: Object.entries(legacyOperationResults).map(([id, result]) => ({
3248
+ ...(result || {}),
3249
+ id: result?.id || id
3250
+ }))
3251
+ }
3252
+ : (
3253
+ !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults') &&
3254
+ Array.isArray(response?.results)
3255
+ ? {...response, operationResults: response.results}
3256
+ : response
3257
+ );
3258
+ results = normalizeRecordTimeLabelEnvelopeResponse(sentOperations, compatibleResponse);
3259
+ } catch (error) {
3260
+ return {error, results: null};
3261
+ }
3262
+ const byId = new Map(sentOperations.map((operation) => [operation.id, operation]));
3263
+ const normalized = [];
3264
+ const seen = new Set();
3265
+ for (let index = 0; index < results.length; index += 1) {
3266
+ const result = results[index] || {};
3267
+ const resultId = rtlResultOperationId(result) || sentOperations[index]?.id;
3268
+ if (!resultId || !byId.has(resultId) || seen.has(resultId)) {
3269
+ return {error: new Error('sync_protocol_invalid_operation_result'), results: null};
3270
+ }
3271
+ seen.add(resultId);
3272
+ normalized.push({...clone(result), id: resultId});
3273
+ }
3274
+ if (seen.size !== sentOperations.length) {
3275
+ return {error: new Error('sync_protocol_operation_result_count_mismatch'), results: null};
3276
+ }
3277
+ return {results: normalized};
3278
+ };
3279
+
3280
+ const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
3281
+ const direct = Number(result?.nextRetryAt);
3282
+ if (Number.isFinite(direct)) return direct;
3283
+ const rawAfter = result?.retryAfterMs ?? retryAfterMs;
3284
+ if (rawAfter !== null && rawAfter !== undefined && rawAfter !== '') {
3285
+ const after = Number(rawAfter);
3286
+ if (Number.isFinite(after) && after >= 0) return timestamp + after;
3287
+ }
3288
+ const attempts = Math.max(0, Number(operation?.retryCount || operation?.retryAttempts || 0));
3289
+ return timestamp + Math.min(RTL_RETRY_MAX_MS, RTL_RETRY_BASE_MS * (2 ** attempts));
3290
+ };
3291
+
3292
+ const makeRetryOperation = (operation, result, retryAfterMs, timestamp) => ({
3293
+ ...rtlCloneOperation(operation),
3294
+ retryCount: Math.max(0, Number(operation?.retryCount || operation?.retryAttempts || 0)) + 1,
3295
+ nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
3296
+ });
3297
+
3298
+ const processRemote = async (remoteValue, captured) => {
3299
+ if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3300
+ const candidate = clone(workspace);
3301
+ const applied = applyRemoteBaseline(candidate, remoteValue);
3302
+ if (applied.stale || !applied.changed) return {success: true, ignored: true};
3303
+ if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
3304
+ workspace = candidate;
3305
+ notify({type: 'remote_merged'});
3306
+ return getSnapshot();
3307
+ };
3308
+
3309
+ const initialize = async () => {
3310
+ const captured = capture();
3311
+ const loaded = await storage.load();
3312
+ if (!(await isCurrent(captured))) return getSnapshot();
3313
+ let candidate = normalizeLoadedWorkspace(loaded, captured);
3314
+ const identityChecked = rtlQuarantineOperations(
3315
+ candidate,
3316
+ candidate.pendingOperations,
3317
+ now()
3318
+ );
3319
+ candidate.pendingOperations = identityChecked.accepted;
3320
+ candidate.rejectedOperations = identityChecked.rejected;
3321
+ const context = rtlSessionContext(captured, candidate, client);
3322
+
3323
+ if (captured?.uid && typeof cloud?.bootstrap === 'function') {
3324
+ let bootstrap;
3325
+ try {
3326
+ bootstrap = await cloud.bootstrap(context);
3327
+ } catch (error) {
3328
+ if (!(await isCurrent(captured))) return getSnapshot();
3329
+ throw error;
3330
+ }
3331
+ if (!(await isCurrent(captured))) return getSnapshot();
3332
+ applyRemoteBaseline(candidate, bootstrap);
3333
+ }
3334
+
3335
+ // Always persist the normalized durable shape before subscribing. This
3336
+ // also makes legacy migration atomic from the engine's point of view.
3337
+ if (!(await persist(candidate, captured))) return getSnapshot();
3338
+ workspace = candidate;
3339
+ initialized = true;
3340
+
3341
+ if (typeof cloud?.subscribe === 'function') {
3342
+ subscriptionContext = context;
3343
+ unsubscribeCloud = cloud.subscribe((remoteValue) => {
3344
+ if (destroyed) return;
3345
+ return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3346
+ logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3347
+ return {success: false, error};
3348
+ });
3349
+ }, context);
3350
+ }
3351
+ if (typeof session?.subscribe === 'function' && !unsubscribeSession) {
3352
+ unsubscribeSession = session.subscribe(() => {});
3353
+ }
3354
+ notify({type: 'initialized'});
3355
+ return getSnapshot();
3356
+ };
3357
+
3358
+ const dispatchInternal = async (operations) => {
3359
+ const captured = capture();
3360
+ if (!(await isCurrent(captured))) return getSnapshot();
3361
+ const input = Array.isArray(operations)
3362
+ ? operations
3363
+ : (operations && typeof operations === 'object' ? [operations] : []);
3364
+ if (input.length === 0) return getSnapshot();
3365
+ const candidate = clone(workspace);
3366
+ if (captured?.uid !== undefined && captured?.uid !== null) {
3367
+ candidate.ownerUid = captured.uid;
3368
+ }
3369
+ if (captured?.hasEpoch) candidate.workspaceEpoch = captured.workspaceEpoch;
3370
+ const normalized = input.map((operation) => rtlNormalizePendingOperation(operation, {
3371
+ client,
3372
+ clientId: typeof client === 'string' ? client : client?.id,
3373
+ now,
3374
+ ownerUid: captured?.uid ?? candidate.ownerUid,
3375
+ workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
3376
+ }));
3377
+ const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
3378
+ const viewOperations = identityChecked.accepted.filter((operation) => (
3379
+ operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
3380
+ ));
3381
+ const durableOperations = identityChecked.accepted.filter((operation) => (
3382
+ operation.type !== OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
3383
+ ));
3384
+ const viewMigration = migrateRecordTimeLabelExpandedGroups({
3385
+ pendingOperations: viewOperations,
3386
+ currentView: candidate.syncMeta?.migratedExpandedGroups || []
3387
+ });
3388
+ candidate.pendingOperations = [
3389
+ ...candidate.pendingOperations,
3390
+ ...durableOperations
3391
+ ];
3392
+ candidate.rejectedOperations = identityChecked.rejected;
3393
+ if (viewOperations.length > 0) {
3394
+ candidate.syncMeta = {
3395
+ ...candidate.syncMeta,
3396
+ migratedExpandedGroups: viewMigration.expandedGroups,
3397
+ expandedGroupsMigrationAt: now(),
3398
+ lastLocalViewOperationAt: viewOperations[viewOperations.length - 1].createdAt,
3399
+ lastLocalViewOperationId: viewOperations[viewOperations.length - 1].id
3400
+ };
3401
+ }
3402
+ if (durableOperations.length > 0) {
3403
+ const lastOperation = durableOperations[durableOperations.length - 1];
3404
+ candidate.syncMeta = {
3405
+ ...candidate.syncMeta,
3406
+ lastLocalOperationAt: lastOperation.createdAt,
3407
+ lastLocalOperationId: lastOperation.id,
3408
+ lastLocalOperationType: lastOperation.type
3409
+ };
3410
+ }
3411
+ if (!(await persist(candidate, captured))) return getSnapshot();
3412
+ workspace = candidate;
3413
+ if (durableOperations.length > 0 || viewOperations.length > 0) {
3414
+ notify({
3415
+ type: 'local_applied',
3416
+ operations: clone([...durableOperations, ...viewOperations]),
3417
+ operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
3418
+ rejectedCount: identityChecked.rejectedCount
3419
+ });
3420
+ } else {
3421
+ notify({type: 'operations_rejected', rejectedCount: identityChecked.rejectedCount});
3422
+ }
3423
+ return getSnapshot();
3424
+ };
3425
+
3426
+ const syncInternal = async (reason) => {
3427
+ const captured = capture();
3428
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3429
+ const timestamp = now();
3430
+ const identityChecked = rtlQuarantineOperations(
3431
+ workspace,
3432
+ workspace.pendingOperations,
3433
+ timestamp
3434
+ );
3435
+ let identityRejectedCount = identityChecked.rejectedCount;
3436
+ if (identityRejectedCount > 0) {
3437
+ const candidate = clone(workspace);
3438
+ candidate.pendingOperations = identityChecked.accepted;
3439
+ candidate.rejectedOperations = identityChecked.rejected;
3440
+ candidate.syncMeta = {
3441
+ ...candidate.syncMeta,
3442
+ lastIdentityRejectionAt: timestamp,
3443
+ lastIdentityRejectionCount: identityRejectedCount
3444
+ };
3445
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
3446
+ workspace = candidate;
3447
+ notify({type: 'operations_rejected', rejectedCount: identityRejectedCount});
3448
+ }
3449
+ const ready = workspace.pendingOperations.filter((operation) => (
3450
+ !Number.isFinite(Number(operation.nextRetryAt)) || Number(operation.nextRetryAt) <= timestamp
3451
+ ));
3452
+ if (ready.length === 0) {
3453
+ return {
3454
+ success: true,
3455
+ skipped: workspace.pendingOperations.length > 0 ? 'retry_deadline' : 'empty',
3456
+ pendingCount: workspace.pendingOperations.length,
3457
+ rejectedCount: identityRejectedCount,
3458
+ identityRejectedCount
3459
+ };
3460
+ }
3461
+ if (!captured?.uid) {
3462
+ return {
3463
+ success: true,
3464
+ skipped: 'anonymous',
3465
+ pendingCount: workspace.pendingOperations.length,
3466
+ rejectedCount: identityRejectedCount,
3467
+ identityRejectedCount
3468
+ };
3469
+ }
3470
+ if (typeof cloud?.applyOperations !== 'function') {
3471
+ return {success: false, reason: 'missing_cloud_apply_operations'};
3472
+ }
3473
+ const context = rtlSessionContext(captured, workspace, client);
3474
+ let response;
3475
+ try {
3476
+ response = await cloud.applyOperations({
3477
+ schemaVersion: RTL_DURABLE_SCHEMA_VERSION,
3478
+ ownerUid: workspace.ownerUid,
3479
+ workspaceEpoch: workspace.workspaceEpoch,
3480
+ client: clone(client),
3481
+ reason: reason ?? null,
3482
+ operations: clone(ready)
3483
+ }, context);
3484
+ } catch (error) {
3485
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3486
+ if (!rtlRetryableEnvelopeError(error)) {
3487
+ logger?.error?.('[RecordTimeLabelCore] durable sync failed', error);
3488
+ return {success: false, error};
3489
+ }
3490
+ response = {
3491
+ success: false,
3492
+ error,
3493
+ retryAfterMs: rtlRetryAfterMs(error)
3494
+ };
3495
+ }
3496
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3497
+
3498
+ if (!rtlEnvelopeSuccess(response)) {
3499
+ if (!rtlRetryableEnvelopeError(response)) {
3500
+ const error = response?.error instanceof Error
3501
+ ? response.error
3502
+ : new Error(response?.error?.message || response?.message || response?.code || 'sync_envelope_failed');
3503
+ logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
3504
+ return {success: false, error};
3505
+ }
3506
+ const retryAfterMs = rtlRetryAfterMs(response);
3507
+ const candidate = clone(workspace);
3508
+ const byId = new Set(ready.map((operation) => operation.id));
3509
+ candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
3510
+ byId.has(operation.id)
3511
+ ? makeRetryOperation(operation, response, retryAfterMs, timestamp)
3512
+ : operation
3513
+ ));
3514
+ candidate.syncMeta = {
3515
+ ...candidate.syncMeta,
3516
+ lastSyncAttemptAt: timestamp,
3517
+ lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
3518
+ };
3519
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
3520
+ workspace = candidate;
3521
+ notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
3522
+ const retryAt = Math.min(...candidate.pendingOperations
3523
+ .filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
3524
+ .map((operation) => Number(operation.nextRetryAt)));
3525
+ return {
3526
+ success: false,
3527
+ retryable: true,
3528
+ retryAfterMs,
3529
+ retryAt: Number.isFinite(retryAt) ? retryAt : null,
3530
+ pendingCount: workspace.pendingOperations.length,
3531
+ identityRejectedCount
3532
+ };
3533
+ }
3534
+
3535
+ const parsed = normalizeOperationResults(response, ready);
3536
+ if (parsed.error) {
3537
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
3538
+ return {success: false, error: parsed.error, protocolError: true};
3539
+ }
3540
+ const candidate = clone(workspace);
3541
+ const resultById = new Map(parsed.results.map((result) => [result.id, result]));
3542
+ const readyIds = new Set(ready.map((operation) => operation.id));
3543
+ const appliedOperations = [];
3544
+ const nextPending = [];
3545
+ const nextRejected = {...candidate.rejectedOperations};
3546
+ for (const operation of candidate.pendingOperations) {
3547
+ if (!readyIds.has(operation.id)) {
3548
+ nextPending.push(operation);
3549
+ continue;
3550
+ }
3551
+ const result = resultById.get(operation.id);
3552
+ if (result.status === 'applied') {
3553
+ appliedOperations.push(operation);
3554
+ } else if (result.status === 'noop') {
3555
+ // A noop is acknowledged but intentionally not promoted onto the
3556
+ // baseline: the server says the operation had no effect.
3557
+ } else if (result.status === 'retryable') {
3558
+ nextPending.push(makeRetryOperation(operation, result, null, timestamp));
3559
+ } else if (result.status === 'rejected') {
3560
+ nextRejected[operation.id] = {
3561
+ id: operation.id,
3562
+ operation: clone(operation),
3563
+ status: 'rejected',
3564
+ reason: result.reason ?? result.error ?? result.code ?? 'rejected',
3565
+ rejectedAt: timestamp,
3566
+ response: rtlStripSessionTokens(result)
3567
+ };
3568
+ }
3569
+ }
3570
+ appliedOperations.forEach((operation) => {
3571
+ candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
3572
+ applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
3573
+ );
3574
+ });
3575
+ const responseRevision = Number(response.revision ?? response.remoteRevision);
3576
+ if (Number.isFinite(responseRevision) && responseRevision >= candidate.remoteBaseline.revision) {
3577
+ candidate.remoteBaseline.revision = responseRevision;
3578
+ }
3579
+ if (Object.prototype.hasOwnProperty.call(response || {}, 'changeCursor')) {
3580
+ candidate.remoteBaseline.changeCursor = response.changeCursor === null || response.changeCursor === undefined
3581
+ ? null
3582
+ : String(response.changeCursor);
3583
+ }
3584
+ candidate.pendingOperations = nextPending;
3585
+ candidate.rejectedOperations = nextRejected;
3586
+ candidate.syncMeta = {
3587
+ ...candidate.syncMeta,
3588
+ lastSyncedAt: timestamp,
3589
+ lastSyncAttemptAt: timestamp,
3590
+ lastSyncReason: reason ?? null,
3591
+ lastSyncError: null
3592
+ };
3593
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
3594
+ workspace = candidate;
3595
+ notify({type: 'synced', operations: clone(parsed.results)});
3596
+ const retryAt = Math.min(...candidate.pendingOperations
3597
+ .filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
3598
+ .map((operation) => Number(operation.nextRetryAt)));
3599
+ return {
3600
+ success: true,
3601
+ appliedCount: appliedOperations.length,
3602
+ syncedCount: appliedOperations.length + parsed.results.filter((result) => result.status === 'noop').length,
3603
+ rejectedCount: parsed.results.filter((result) => result.status === 'rejected').length,
3604
+ retryCount: parsed.results.filter((result) => result.status === 'retryable').length,
3605
+ retryAt: Number.isFinite(retryAt) ? retryAt : null,
3606
+ pendingCount: workspace.pendingOperations.length,
3607
+ identityRejectedCount
3608
+ };
3609
+ };
3610
+
3611
+ const engine = {
3612
+ init() {
3613
+ return enqueue(async () => {
3614
+ if (initialized) return getSnapshot();
3615
+ return initialize();
3616
+ });
3617
+ },
3618
+
3619
+ dispatch(operations) {
3620
+ return enqueue(() => dispatchInternal(operations));
3621
+ },
3622
+
3623
+ sync(reason) {
3624
+ return enqueue(() => syncInternal(reason));
3625
+ },
3626
+
3627
+ getSnapshot,
3628
+
3629
+ subscribe(listener) {
3630
+ if (typeof listener !== 'function') return () => {};
3631
+ listeners.add(listener);
3632
+ return () => listeners.delete(listener);
3633
+ },
3634
+
3635
+ destroy() {
3636
+ destroyed = true;
3637
+ initialized = false;
3638
+ if (typeof unsubscribeCloud === 'function') {
3639
+ try { unsubscribeCloud(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error); }
3640
+ }
3641
+ if (typeof unsubscribeSession === 'function') {
3642
+ try { unsubscribeSession(); } catch (error) { logger?.error?.('[RecordTimeLabelCore] durable session unsubscribe failed', error); }
3643
+ }
3644
+ unsubscribeCloud = null;
3645
+ unsubscribeSession = null;
3646
+ subscriptionContext = null;
3647
+ listeners.clear();
3648
+ }
3649
+ };
3650
+
3651
+ return engine;
3652
+ };
3653
+
2541
3654
  export const createSyncEngine = ({
2542
3655
  storageAdapter,
2543
3656
  cloudAdapter,
@@ -3075,6 +4188,10 @@ export const planFirestoreV2OperationChanges = ({
3075
4188
  } = {}) => {
3076
4189
  const previousDocuments = rtlCloneDocuments(documents);
3077
4190
  const nextDocuments = rtlCloneDocuments(documents);
4191
+ const preservedExpandedGroups = previousDocuments.root &&
4192
+ Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
4193
+ ? clone(previousDocuments.root.expandedGroups)
4194
+ : undefined;
3078
4195
  const normalizedLocalState = normalizeState(localState || {});
3079
4196
  const operationResults = [];
3080
4197
  let changed = false;
@@ -3083,6 +4200,16 @@ export const planFirestoreV2OperationChanges = ({
3083
4200
  }
3084
4201
 
3085
4202
  for (const operation of toArray(operations).filter(Boolean)) {
4203
+ if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
4204
+ operationResults.push({
4205
+ id: operation.id || null,
4206
+ type: operation.type,
4207
+ applied: false,
4208
+ status: 'noop',
4209
+ reason: 'local_view_state'
4210
+ });
4211
+ continue;
4212
+ }
3086
4213
  const payload = operation.payload || {};
3087
4214
  const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
3088
4215
  const recordId = rtlOperationRecordId(operation);
@@ -3284,7 +4411,6 @@ export const planFirestoreV2OperationChanges = ({
3284
4411
  break;
3285
4412
  case OPERATION_TYPES.FOLDER_REORDER:
3286
4413
  case OPERATION_TYPES.GROUP_REORDER:
3287
- case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
3288
4414
  case OPERATION_TYPES.SETTINGS_UPDATE: {
3289
4415
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
3290
4416
  const appliedRoot = applied.documents.root;
@@ -3300,10 +4426,6 @@ export const planFirestoreV2OperationChanges = ({
3300
4426
  nextDocuments.root.groupOrder,
3301
4427
  normalizedLocalState.groupOrder
3302
4428
  ));
3303
- } else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
3304
- appliedRoot.expandedGroups = rtlMergeOrder(
3305
- rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids'])
3306
- );
3307
4429
  }
3308
4430
  rtlSetRoot(nextDocuments.root, appliedRoot);
3309
4431
  changed = true;
@@ -3336,6 +4458,9 @@ export const planFirestoreV2OperationChanges = ({
3336
4458
  now,
3337
4459
  syncMetaPatch: {lastBackgroundSyncAt: now}
3338
4460
  }).root;
4461
+ if (preservedExpandedGroups !== undefined) {
4462
+ nextDocuments.root.expandedGroups = preservedExpandedGroups;
4463
+ }
3339
4464
  }
3340
4465
  const changes = buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, {
3341
4466
  allowDeletes: true
@@ -3469,12 +4594,6 @@ export const buildOperationsFromSnapshotDiff = ({
3469
4594
  if (rtlComparableJson(previous.groupOrder) !== rtlComparableJson(next.groupOrder)) {
3470
4595
  drafts.push({type: OPERATION_TYPES.GROUP_REORDER, payload: {groupOrder: next.groupOrder}});
3471
4596
  }
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
4597
  if (rtlComparableJson(previous.settings) !== rtlComparableJson(next.settings)) {
3479
4598
  drafts.push({type: OPERATION_TYPES.SETTINGS_UPDATE, payload: {patch: next.settings}});
3480
4599
  }
@@ -3515,6 +4634,7 @@ export const buildOperationsFromSnapshotDiff = ({
3515
4634
 
3516
4635
  export default {
3517
4636
  RECORD_TIMELABEL_CORE_VERSION,
4637
+ RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
3518
4638
  RTL_SYNC_PROTOCOL_VERSION,
3519
4639
  RTL_MAX_OPERATIONS_PER_REQUEST,
3520
4640
  RTL_MAX_REQUEST_BYTES,
@@ -3524,7 +4644,10 @@ export default {
3524
4644
  RECORD_TIMELABEL_SYNC_MODES,
3525
4645
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
3526
4646
  FIRESTORE_V2_SETTINGS_DOC_ID,
4647
+ RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS,
3527
4648
  normalizeState,
4649
+ normalizeRecordTimeLabelDomainState,
4650
+ migrateRecordTimeLabelExpandedGroups,
3528
4651
  getActiveTrashEntries,
3529
4652
  hasMeaningfulRecordTimeLabelCloudState,
3530
4653
  buildRecordTimeLabelContentFingerprint,
@@ -3551,12 +4674,15 @@ export default {
3551
4674
  extendFirestoreV2OperationReadPlanWithTrash,
3552
4675
  planFirestoreV2OperationChanges,
3553
4676
  estimateFirestoreV2WriteUnits,
4677
+ normalizeRecordTimeLabelOperationResults,
4678
+ normalizeRecordTimeLabelEnvelopeResponse,
3554
4679
  buildOperationsFromSnapshotDiff,
3555
4680
  flushPendingOperations,
3556
4681
  mergeRemoteStateIntoLocal,
3557
4682
  applyOperation,
3558
4683
  applyRecordTimeLabelOperation,
3559
4684
  createOperation,
4685
+ createRecordTimeLabelSyncEngine,
3560
4686
  createSyncEngine,
3561
4687
  createRecordTimeLabelController
3562
4688
  };