@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/README.md +72 -2
- package/package.json +5 -2
- package/src/compat.js +8 -0
- package/src/firestore-v2.js +27 -0
- package/src/index.js +1147 -21
- package/src/protocol.js +128 -0
package/src/index.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
3
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
4
|
+
normalizeRecordTimeLabelOperationResults
|
|
5
|
+
} from './protocol.js';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
9
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
10
|
+
normalizeRecordTimeLabelOperationResults
|
|
11
|
+
} from './protocol.js';
|
|
12
|
+
|
|
1
13
|
const DEFAULT_FOLDER_ID = 'uncategorized';
|
|
2
14
|
const VIRTUAL_FOLDER_IDS = new Set(['all']);
|
|
3
15
|
const REQUIRED_FOLDERS = [
|
|
@@ -5,7 +17,7 @@ const REQUIRED_FOLDERS = [
|
|
|
5
17
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
6
18
|
];
|
|
7
19
|
|
|
8
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.
|
|
20
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.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
|
-
|
|
1119
|
-
|
|
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
|
|
1764
|
-
|
|
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
|
};
|