@recordtimelabel/core 0.1.0 → 0.1.2
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 +75 -43
- package/package.json +20 -20
- package/src/index.js +1627 -569
package/src/index.js
CHANGED
|
@@ -1,648 +1,1520 @@
|
|
|
1
|
-
const DEFAULT_FOLDER_ID = 'uncategorized';
|
|
2
|
-
const VIRTUAL_FOLDER_IDS = new Set(['all']);
|
|
3
|
-
const REQUIRED_FOLDERS = [
|
|
4
|
-
{ id: 'all', name: 'All' },
|
|
5
|
-
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.1.
|
|
9
|
-
|
|
1
|
+
const DEFAULT_FOLDER_ID = 'uncategorized';
|
|
2
|
+
const VIRTUAL_FOLDER_IDS = new Set(['all']);
|
|
3
|
+
const REQUIRED_FOLDERS = [
|
|
4
|
+
{ id: 'all', name: 'All' },
|
|
5
|
+
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.1.2';
|
|
9
|
+
|
|
10
10
|
export const OPERATION_TYPES = Object.freeze({
|
|
11
11
|
RECORD_CREATE: 'record.create',
|
|
12
12
|
RECORD_UPDATE: 'record.update',
|
|
13
13
|
RECORD_MOVE: 'record.move',
|
|
14
14
|
RECORD_DELETE: 'record.delete',
|
|
15
|
+
RECORD_REORDER: 'record.reorder',
|
|
15
16
|
FOLDER_CREATE: 'folder.create',
|
|
16
17
|
FOLDER_UPDATE: 'folder.update',
|
|
17
18
|
FOLDER_DELETE: 'folder.delete',
|
|
18
19
|
FOLDER_REORDER: 'folder.reorder',
|
|
19
20
|
GROUP_REORDER: 'group.reorder',
|
|
21
|
+
EXPANDED_GROUPS_UPDATE: 'expandedGroups.update',
|
|
20
22
|
SETTINGS_UPDATE: 'settings.update'
|
|
21
23
|
});
|
|
24
|
+
|
|
25
|
+
export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
|
|
26
|
+
FULL: 'full',
|
|
27
|
+
MERGE: 'merge'
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const RECORD_TIMELABEL_CLOUD_SCHEMAS = Object.freeze({
|
|
31
|
+
V1: 'v1',
|
|
32
|
+
V2: 'v2'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const FIRESTORE_V2_SETTINGS_DOC_ID = 'main';
|
|
22
36
|
|
|
23
37
|
const toArray = (value) => (Array.isArray(value) ? value : []);
|
|
24
38
|
const clone = (value) => {
|
|
25
39
|
if (value === undefined || value === null) return value;
|
|
26
40
|
return JSON.parse(JSON.stringify(value));
|
|
27
41
|
};
|
|
28
|
-
|
|
29
|
-
const toFiniteTimestamp = (value) => {
|
|
30
|
-
if (typeof value === 'number') {
|
|
31
|
-
return Number.isFinite(value) ? value : 0;
|
|
32
|
-
}
|
|
33
|
-
const parsed = new Date(value || 0).getTime();
|
|
34
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const getRecordTime = (record = {}) => Math.max(
|
|
38
|
-
toFiniteTimestamp(record.updatedAt),
|
|
39
|
-
toFiniteTimestamp(record.createdAt),
|
|
40
|
-
toFiniteTimestamp(record.syncedAt)
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
const hasValue = (value) => {
|
|
44
|
-
if (value === undefined || value === null) return false;
|
|
45
|
-
if (typeof value === 'string') return value.trim() !== '';
|
|
46
|
-
return true;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const richFields = [
|
|
50
|
-
'note',
|
|
51
|
-
'title',
|
|
52
|
-
'streamerName',
|
|
53
|
-
'channelName',
|
|
54
|
-
'channelHandle',
|
|
55
|
-
'channelUrl',
|
|
56
|
-
'url',
|
|
57
|
-
'videoUrl',
|
|
58
|
-
'downloadUrl',
|
|
59
|
-
'liveTime',
|
|
60
|
-
'originalLiveTime',
|
|
61
|
-
'platform',
|
|
62
|
-
'hasVod',
|
|
63
|
-
'vodId',
|
|
64
|
-
'vodTitle',
|
|
65
|
-
'avatarUrl',
|
|
66
|
-
'm3u8Type'
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
const getRecordRichness = (record = {}) => richFields.reduce(
|
|
70
|
-
(score, field) => score + (hasValue(record[field]) ? 1 : 0),
|
|
71
|
-
0
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
const getRecordPriority = (record = {}) => {
|
|
75
|
-
let score = 0;
|
|
76
|
-
if (record.pendingSync === true) score += 64;
|
|
77
|
-
if (record.hasVod === true) score += 16;
|
|
78
|
-
if (record.videoUrl) score += 8;
|
|
79
|
-
if (record.downloadUrl) score += 4;
|
|
80
|
-
if (record.note) score += 2;
|
|
81
|
-
return score + getRecordRichness(record);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const normalizeId = (value) => {
|
|
85
|
-
if (typeof value !== 'string' && typeof value !== 'number') return '';
|
|
86
|
-
return String(value).trim();
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const safeFolderId = (folderId) => {
|
|
90
|
-
const normalized = normalizeId(folderId);
|
|
91
|
-
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized)) return DEFAULT_FOLDER_ID;
|
|
92
|
-
return normalized;
|
|
93
|
-
};
|
|
94
|
-
|
|
42
|
+
|
|
43
|
+
const toFiniteTimestamp = (value) => {
|
|
44
|
+
if (typeof value === 'number') {
|
|
45
|
+
return Number.isFinite(value) ? value : 0;
|
|
46
|
+
}
|
|
47
|
+
const parsed = new Date(value || 0).getTime();
|
|
48
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getRecordTime = (record = {}) => Math.max(
|
|
52
|
+
toFiniteTimestamp(record.updatedAt),
|
|
53
|
+
toFiniteTimestamp(record.createdAt),
|
|
54
|
+
toFiniteTimestamp(record.syncedAt)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const hasValue = (value) => {
|
|
58
|
+
if (value === undefined || value === null) return false;
|
|
59
|
+
if (typeof value === 'string') return value.trim() !== '';
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const richFields = [
|
|
64
|
+
'note',
|
|
65
|
+
'title',
|
|
66
|
+
'streamerName',
|
|
67
|
+
'channelName',
|
|
68
|
+
'channelHandle',
|
|
69
|
+
'channelUrl',
|
|
70
|
+
'url',
|
|
71
|
+
'videoUrl',
|
|
72
|
+
'downloadUrl',
|
|
73
|
+
'liveTime',
|
|
74
|
+
'originalLiveTime',
|
|
75
|
+
'platform',
|
|
76
|
+
'hasVod',
|
|
77
|
+
'vodId',
|
|
78
|
+
'vodTitle',
|
|
79
|
+
'avatarUrl',
|
|
80
|
+
'm3u8Type'
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const getRecordRichness = (record = {}) => richFields.reduce(
|
|
84
|
+
(score, field) => score + (hasValue(record[field]) ? 1 : 0),
|
|
85
|
+
0
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const getRecordPriority = (record = {}) => {
|
|
89
|
+
let score = 0;
|
|
90
|
+
if (record.pendingSync === true) score += 64;
|
|
91
|
+
if (record.hasVod === true) score += 16;
|
|
92
|
+
if (record.videoUrl) score += 8;
|
|
93
|
+
if (record.downloadUrl) score += 4;
|
|
94
|
+
if (record.note) score += 2;
|
|
95
|
+
return score + getRecordRichness(record);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const normalizeId = (value) => {
|
|
99
|
+
if (typeof value !== 'string' && typeof value !== 'number') return '';
|
|
100
|
+
return String(value).trim();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const safeFolderId = (folderId) => {
|
|
104
|
+
const normalized = normalizeId(folderId);
|
|
105
|
+
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized)) return DEFAULT_FOLDER_ID;
|
|
106
|
+
return normalized;
|
|
107
|
+
};
|
|
108
|
+
|
|
95
109
|
const normalizeOrder = (value) => Array.from(new Set(
|
|
96
110
|
toArray(value).map(normalizeId).filter(Boolean)
|
|
97
111
|
));
|
|
98
112
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!id) return result;
|
|
108
|
-
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
109
|
-
? { ...rawTombstone }
|
|
110
|
-
: {};
|
|
111
|
-
const deletedAt = toFiniteTimestamp(tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt);
|
|
112
|
-
result[id] = {
|
|
113
|
-
...tombstone,
|
|
114
|
-
id,
|
|
115
|
-
deletedAt: deletedAt || Date.now()
|
|
116
|
-
};
|
|
117
|
-
return result;
|
|
118
|
-
}, {});
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const mergeTombstones = (left = {}, right = {}) => {
|
|
122
|
-
const merged = { ...left };
|
|
123
|
-
Object.entries(right).forEach(([id, tombstone]) => {
|
|
124
|
-
const current = merged[id];
|
|
125
|
-
if (!current || toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt)) {
|
|
126
|
-
merged[id] = { ...tombstone };
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
return merged;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const resolveRecordFolderId = (folderId, record = {}) => {
|
|
133
|
-
const candidates = [
|
|
134
|
-
record.folderId,
|
|
135
|
-
record.originalFolderId,
|
|
136
|
-
record.sourceFolderId,
|
|
137
|
-
record.targetFolderId,
|
|
138
|
-
folderId
|
|
139
|
-
];
|
|
140
|
-
const match = candidates.map(normalizeId).find((id) => id && !VIRTUAL_FOLDER_IDS.has(id));
|
|
141
|
-
return match || DEFAULT_FOLDER_ID;
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const pickPreferredRecord = (left, right) => {
|
|
145
|
-
if (!left) return right ? { ...right } : null;
|
|
146
|
-
if (!right) return { ...left };
|
|
147
|
-
|
|
148
|
-
const leftTime = getRecordTime(left);
|
|
149
|
-
const rightTime = getRecordTime(right);
|
|
150
|
-
if (leftTime !== rightTime) {
|
|
151
|
-
return rightTime > leftTime ? right : left;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const leftPriority = getRecordPriority(left);
|
|
155
|
-
const rightPriority = getRecordPriority(right);
|
|
156
|
-
if (leftPriority !== rightPriority) {
|
|
157
|
-
return rightPriority > leftPriority ? right : left;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return left;
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
export const mergeRecords = (left, right) => {
|
|
164
|
-
if (!left) return right ? { ...right } : null;
|
|
165
|
-
if (!right) return { ...left };
|
|
166
|
-
|
|
167
|
-
const preferred = pickPreferredRecord(left, right);
|
|
168
|
-
const secondary = preferred === left ? right : left;
|
|
169
|
-
const merged = { ...secondary, ...preferred };
|
|
170
|
-
|
|
171
|
-
richFields.forEach((field) => {
|
|
172
|
-
if (!hasValue(merged[field])) {
|
|
173
|
-
if (hasValue(preferred?.[field])) {
|
|
174
|
-
merged[field] = preferred[field];
|
|
175
|
-
} else if (hasValue(secondary?.[field])) {
|
|
176
|
-
merged[field] = secondary[field];
|
|
177
|
-
}
|
|
178
|
-
}
|
|
113
|
+
const normalizeIdList = (ids) => {
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
const result = [];
|
|
116
|
+
toArray(ids).forEach((id) => {
|
|
117
|
+
const normalized = normalizeId(id);
|
|
118
|
+
if (!normalized || seen.has(normalized)) return;
|
|
119
|
+
seen.add(normalized);
|
|
120
|
+
result.push(normalized);
|
|
179
121
|
});
|
|
180
|
-
|
|
181
|
-
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
182
|
-
return merged;
|
|
122
|
+
return result;
|
|
183
123
|
};
|
|
184
124
|
|
|
125
|
+
const getRecordId = (record) => normalizeId(record?.id || record?.recordId);
|
|
126
|
+
|
|
127
|
+
const normalizeTombstones = (value) => {
|
|
128
|
+
if (!value) return {};
|
|
129
|
+
const entries = Array.isArray(value)
|
|
130
|
+
? value.map((entry) => [entry?.id || entry?.recordId || entry?.folderId, entry])
|
|
131
|
+
: Object.entries(value);
|
|
132
|
+
|
|
133
|
+
return entries.reduce((result, [rawId, rawTombstone]) => {
|
|
134
|
+
const id = normalizeId(rawId);
|
|
135
|
+
if (!id) return result;
|
|
136
|
+
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
137
|
+
? { ...rawTombstone }
|
|
138
|
+
: {};
|
|
139
|
+
const deletedAt = toFiniteTimestamp(tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt);
|
|
140
|
+
result[id] = {
|
|
141
|
+
...tombstone,
|
|
142
|
+
id,
|
|
143
|
+
deletedAt: deletedAt || Date.now()
|
|
144
|
+
};
|
|
145
|
+
return result;
|
|
146
|
+
}, {});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const mergeTombstones = (left = {}, right = {}) => {
|
|
150
|
+
const merged = { ...left };
|
|
151
|
+
Object.entries(right).forEach(([id, tombstone]) => {
|
|
152
|
+
const current = merged[id];
|
|
153
|
+
if (!current || toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt)) {
|
|
154
|
+
merged[id] = { ...tombstone };
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return merged;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const resolveRecordFolderId = (folderId, record = {}) => {
|
|
161
|
+
const candidates = [
|
|
162
|
+
record.folderId,
|
|
163
|
+
record.originalFolderId,
|
|
164
|
+
record.sourceFolderId,
|
|
165
|
+
record.targetFolderId,
|
|
166
|
+
folderId
|
|
167
|
+
];
|
|
168
|
+
const match = candidates.map(normalizeId).find((id) => id && !VIRTUAL_FOLDER_IDS.has(id));
|
|
169
|
+
return match || DEFAULT_FOLDER_ID;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const pickPreferredRecord = (left, right) => {
|
|
173
|
+
if (!left) return right ? { ...right } : null;
|
|
174
|
+
if (!right) return { ...left };
|
|
175
|
+
|
|
176
|
+
const leftTime = getRecordTime(left);
|
|
177
|
+
const rightTime = getRecordTime(right);
|
|
178
|
+
if (leftTime !== rightTime) {
|
|
179
|
+
return rightTime > leftTime ? right : left;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const leftPriority = getRecordPriority(left);
|
|
183
|
+
const rightPriority = getRecordPriority(right);
|
|
184
|
+
if (leftPriority !== rightPriority) {
|
|
185
|
+
return rightPriority > leftPriority ? right : left;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return left;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const mergeRecords = (left, right) => {
|
|
192
|
+
if (!left) return right ? { ...right } : null;
|
|
193
|
+
if (!right) return { ...left };
|
|
194
|
+
|
|
195
|
+
const preferred = pickPreferredRecord(left, right);
|
|
196
|
+
const secondary = preferred === left ? right : left;
|
|
197
|
+
const merged = { ...secondary, ...preferred };
|
|
198
|
+
|
|
199
|
+
richFields.forEach((field) => {
|
|
200
|
+
if (!hasValue(merged[field])) {
|
|
201
|
+
if (hasValue(preferred?.[field])) {
|
|
202
|
+
merged[field] = preferred[field];
|
|
203
|
+
} else if (hasValue(secondary?.[field])) {
|
|
204
|
+
merged[field] = secondary[field];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
210
|
+
return merged;
|
|
211
|
+
};
|
|
212
|
+
|
|
185
213
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
186
214
|
const source = records && typeof records === 'object' ? records : {};
|
|
187
215
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
216
|
+
const folderTombstones = normalizeTombstones(options.deletedFolderTombstones);
|
|
188
217
|
const normalized = { all: [], [DEFAULT_FOLDER_ID]: [] };
|
|
189
218
|
const placements = new Map();
|
|
190
|
-
|
|
191
|
-
Object.entries(source).forEach(([folderId, folderRecords]) => {
|
|
192
|
-
const safeList = toArray(folderRecords);
|
|
193
|
-
safeList.forEach((record, index) => {
|
|
194
|
-
if (!record || typeof record !== 'object') return;
|
|
195
|
-
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
196
|
-
if (!id) return;
|
|
219
|
+
|
|
220
|
+
Object.entries(source).forEach(([folderId, folderRecords]) => {
|
|
221
|
+
const safeList = toArray(folderRecords);
|
|
222
|
+
safeList.forEach((record, index) => {
|
|
223
|
+
if (!record || typeof record !== 'object') return;
|
|
224
|
+
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
225
|
+
if (!id) return;
|
|
197
226
|
|
|
198
227
|
const targetFolderId = resolveRecordFolderId(folderId, record);
|
|
199
|
-
if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
|
|
200
228
|
|
|
201
229
|
const normalizedRecord = {
|
|
202
230
|
...record,
|
|
203
|
-
id
|
|
204
|
-
};
|
|
205
|
-
delete normalizedRecord.folderId;
|
|
206
|
-
delete normalizedRecord.sortIndex;
|
|
231
|
+
id
|
|
232
|
+
};
|
|
233
|
+
delete normalizedRecord.folderId;
|
|
234
|
+
delete normalizedRecord.sortIndex;
|
|
207
235
|
|
|
208
236
|
const tombstone = tombstones[id];
|
|
209
237
|
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
|
|
210
238
|
return;
|
|
211
239
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!existing) {
|
|
215
|
-
normalized[targetFolderId].push(normalizedRecord);
|
|
216
|
-
placements.set(id, {
|
|
217
|
-
folderId: targetFolderId,
|
|
218
|
-
index: normalized[targetFolderId].length - 1,
|
|
219
|
-
record: normalizedRecord
|
|
220
|
-
});
|
|
240
|
+
const folderTombstone = folderTombstones[targetFolderId];
|
|
241
|
+
if (folderTombstone && toFiniteTimestamp(folderTombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
|
|
221
242
|
return;
|
|
222
243
|
}
|
|
223
244
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
|
|
246
|
+
|
|
247
|
+
const existing = placements.get(id);
|
|
248
|
+
if (!existing) {
|
|
249
|
+
normalized[targetFolderId].push(normalizedRecord);
|
|
250
|
+
placements.set(id, {
|
|
251
|
+
folderId: targetFolderId,
|
|
252
|
+
index: normalized[targetFolderId].length - 1,
|
|
253
|
+
record: normalizedRecord
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const merged = mergeRecords(existing.record, normalizedRecord);
|
|
259
|
+
const preferred = pickPreferredRecord(existing.record, normalizedRecord);
|
|
260
|
+
const shouldMove = preferred === normalizedRecord && targetFolderId !== existing.folderId;
|
|
261
|
+
const samePriorityNewer = getRecordTime(normalizedRecord) > getRecordTime(existing.record);
|
|
262
|
+
|
|
263
|
+
if (shouldMove || samePriorityNewer) {
|
|
264
|
+
normalized[existing.folderId][existing.index] = null;
|
|
265
|
+
normalized[targetFolderId].push(merged);
|
|
266
|
+
placements.set(id, {
|
|
267
|
+
folderId: targetFolderId,
|
|
268
|
+
index: normalized[targetFolderId].length - 1,
|
|
269
|
+
record: merged
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
normalized[existing.folderId][existing.index] = merged;
|
|
273
|
+
placements.set(id, {
|
|
274
|
+
...existing,
|
|
275
|
+
record: merged
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
Object.keys(normalized).forEach((folderId) => {
|
|
282
|
+
normalized[folderId] = toArray(normalized[folderId]).filter(Boolean);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return normalized;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const ensureFolders = (folders = []) => {
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
const nextFolders = [];
|
|
291
|
+
toArray(folders).forEach((folder) => {
|
|
292
|
+
if (!folder || typeof folder !== 'object') return;
|
|
293
|
+
const id = normalizeId(folder.id);
|
|
294
|
+
if (!id || seen.has(id)) return;
|
|
295
|
+
seen.add(id);
|
|
296
|
+
nextFolders.push({ ...folder, id });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
REQUIRED_FOLDERS.forEach((folder) => {
|
|
300
|
+
if (!seen.has(folder.id)) {
|
|
301
|
+
seen.add(folder.id);
|
|
302
|
+
nextFolders.unshift({ ...folder });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return nextFolders.sort((left, right) => {
|
|
307
|
+
const leftRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === left.id);
|
|
308
|
+
const rightRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === right.id);
|
|
309
|
+
if (leftRequired !== -1 || rightRequired !== -1) {
|
|
310
|
+
if (leftRequired === -1) return 1;
|
|
311
|
+
if (rightRequired === -1) return -1;
|
|
312
|
+
return leftRequired - rightRequired;
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const ensureRecordFolders = (records, folders) => {
|
|
319
|
+
const nextRecords = { ...records };
|
|
320
|
+
folders.forEach((folder) => {
|
|
321
|
+
if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
|
|
322
|
+
});
|
|
323
|
+
if (!nextRecords.all) nextRecords.all = [];
|
|
324
|
+
if (!nextRecords[DEFAULT_FOLDER_ID]) nextRecords[DEFAULT_FOLDER_ID] = [];
|
|
325
|
+
nextRecords.all = [];
|
|
326
|
+
return nextRecords;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const getFolderTime = (folder = {}) => Math.max(
|
|
330
|
+
toFiniteTimestamp(folder.updatedAt),
|
|
331
|
+
toFiniteTimestamp(folder.createdAt),
|
|
332
|
+
toFiniteTimestamp(folder.lastModified)
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombstones = {}) => {
|
|
336
|
+
const byId = new Map();
|
|
337
|
+
|
|
338
|
+
const visit = (folder, source) => {
|
|
339
|
+
if (!folder || typeof folder !== 'object') return;
|
|
340
|
+
const id = normalizeId(folder.id);
|
|
341
|
+
if (!id) return;
|
|
342
|
+
|
|
343
|
+
const tombstone = deletedFolderTombstones[id];
|
|
344
|
+
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const current = byId.get(id);
|
|
349
|
+
if (!current) {
|
|
350
|
+
byId.set(id, { ...folder, id });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const currentTime = getFolderTime(current);
|
|
355
|
+
const nextTime = getFolderTime(folder);
|
|
356
|
+
if (nextTime > currentTime || (nextTime === currentTime && source === 'local')) {
|
|
357
|
+
byId.set(id, { ...current, ...folder, id });
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
toArray(remoteFolders).forEach((folder) => visit(folder, 'remote'));
|
|
362
|
+
toArray(localFolders).forEach((folder) => visit(folder, 'local'));
|
|
363
|
+
|
|
364
|
+
return Array.from(byId.values());
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const mergeOrderWithLocalIds = (remoteOrder = [], localOrder = [], allowedIds = new Set()) => {
|
|
368
|
+
const merged = normalizeOrder(remoteOrder).filter((id) => allowedIds.has(id));
|
|
369
|
+
normalizeOrder(localOrder).forEach((id) => {
|
|
370
|
+
if (allowedIds.has(id) && !merged.includes(id)) {
|
|
371
|
+
merged.push(id);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
return merged;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export const normalizeState = (input = {}) => {
|
|
378
|
+
const deletedRecordTombstones = normalizeTombstones(input.deletedRecordTombstones);
|
|
379
|
+
const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
|
|
380
|
+
const folders = ensureFolders(input.folders).filter((folder) => {
|
|
381
|
+
const tombstone = deletedFolderTombstones[folder.id];
|
|
382
|
+
if (!tombstone) return true;
|
|
383
|
+
const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
|
|
384
|
+
return folderTime > toFiniteTimestamp(tombstone.deletedAt);
|
|
385
|
+
});
|
|
386
|
+
const records = ensureRecordFolders(
|
|
387
|
+
normalizeRecords(input.records, { deletedRecordTombstones, deletedFolderTombstones }),
|
|
388
|
+
folders
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
folders,
|
|
393
|
+
records,
|
|
394
|
+
groupOrder: normalizeOrder(input.groupOrder),
|
|
395
|
+
folderOrder: normalizeOrder(input.folderOrder),
|
|
396
|
+
expandedGroups: normalizeOrder(input.expandedGroups),
|
|
397
|
+
settings: input.settings && typeof input.settings === 'object' ? { ...input.settings } : {},
|
|
398
|
+
rtlSyncMeta: {
|
|
399
|
+
schemaVersion: 1,
|
|
400
|
+
...(input.rtlSyncMeta && typeof input.rtlSyncMeta === 'object' ? input.rtlSyncMeta : {})
|
|
401
|
+
},
|
|
402
|
+
deletedRecordTombstones,
|
|
403
|
+
deletedFolderTombstones,
|
|
404
|
+
lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export const hasMeaningfulRecordTimeLabelCloudState = (input = {}, options = {}) => {
|
|
409
|
+
const state = normalizeState(input || {});
|
|
410
|
+
const defaultFolderIds = new Set(options.defaultFolderIds || ['all', 'uncategorized']);
|
|
411
|
+
const hasCustomFolder = state.folders.some((folder) => (
|
|
412
|
+
folder?.id && !defaultFolderIds.has(folder.id)
|
|
413
|
+
));
|
|
414
|
+
const hasRecords = Object.values(state.records || {}).some((records) => (
|
|
415
|
+
Array.isArray(records) && records.length > 0
|
|
416
|
+
));
|
|
417
|
+
const hasNonDefaultGroupOrder = state.groupOrder.some((id) => (
|
|
418
|
+
typeof id === 'string' && id.trim() && !defaultFolderIds.has(id.trim())
|
|
419
|
+
));
|
|
420
|
+
|
|
421
|
+
return hasCustomFolder ||
|
|
422
|
+
hasRecords ||
|
|
423
|
+
Object.keys(state.settings || {}).length > 0 ||
|
|
424
|
+
Object.keys(state.deletedRecordTombstones || {}).length > 0 ||
|
|
425
|
+
Object.keys(state.deletedFolderTombstones || {}).length > 0 ||
|
|
426
|
+
hasNonDefaultGroupOrder ||
|
|
427
|
+
state.folderOrder.length > 0 ||
|
|
428
|
+
state.expandedGroups.length > 0;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
export const buildRecordTimeLabelContentFingerprint = (input = {}) => {
|
|
432
|
+
const state = normalizeState(input || {});
|
|
433
|
+
return JSON.stringify({
|
|
434
|
+
folders: state.folders,
|
|
435
|
+
records: state.records,
|
|
436
|
+
settings: state.settings,
|
|
437
|
+
groupOrder: state.groupOrder,
|
|
438
|
+
folderOrder: state.folderOrder,
|
|
439
|
+
expandedGroups: state.expandedGroups,
|
|
440
|
+
deletedRecordTombstones: state.deletedRecordTombstones,
|
|
441
|
+
deletedFolderTombstones: state.deletedFolderTombstones
|
|
245
442
|
});
|
|
443
|
+
};
|
|
246
444
|
|
|
247
|
-
|
|
248
|
-
|
|
445
|
+
export const buildMigratedRecordTimeLabelV2State = (legacyState = {}, options = {}) => {
|
|
446
|
+
const migratedAt = toFiniteTimestamp(options.migratedAt || options.now || Date.now()) || Date.now();
|
|
447
|
+
return normalizeState({
|
|
448
|
+
...(legacyState || {}),
|
|
449
|
+
rtlSyncMeta: {
|
|
450
|
+
...(legacyState?.rtlSyncMeta && typeof legacyState.rtlSyncMeta === 'object' ? legacyState.rtlSyncMeta : {}),
|
|
451
|
+
schemaVersion: 2,
|
|
452
|
+
lastMigratedFromV1At: migratedAt
|
|
453
|
+
},
|
|
454
|
+
lastModified: legacyState?.lastModified || migratedAt
|
|
249
455
|
});
|
|
456
|
+
};
|
|
250
457
|
|
|
251
|
-
|
|
458
|
+
const removeRecordById = (records, recordId) => {
|
|
459
|
+
const nextRecords = {};
|
|
460
|
+
Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
|
|
461
|
+
nextRecords[folderId] = toArray(folderRecords).filter((record) => record?.id !== recordId);
|
|
462
|
+
});
|
|
463
|
+
return nextRecords;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const findRecordEntry = (records, recordId) => {
|
|
467
|
+
for (const [folderId, folderRecords] of Object.entries(records || {})) {
|
|
468
|
+
const index = toArray(folderRecords).findIndex((record) => record?.id === recordId);
|
|
469
|
+
if (index !== -1) {
|
|
470
|
+
return { folderId, index, record: folderRecords[index] };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
252
474
|
};
|
|
253
475
|
|
|
254
|
-
const
|
|
476
|
+
const orderRecordsByReference = (records, referenceRecords) => {
|
|
477
|
+
const targetRecords = toArray(records);
|
|
478
|
+
const referenceOrder = normalizeIdList(toArray(referenceRecords).map((record) => getRecordId(record)));
|
|
479
|
+
if (targetRecords.length < 2 || referenceOrder.length < 2) {
|
|
480
|
+
return targetRecords;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const recordsById = new Map();
|
|
484
|
+
targetRecords.forEach((record) => {
|
|
485
|
+
const recordId = getRecordId(record);
|
|
486
|
+
if (recordId && !recordsById.has(recordId)) {
|
|
487
|
+
recordsById.set(recordId, record);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
255
491
|
const seen = new Set();
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
492
|
+
const orderedRecords = [];
|
|
493
|
+
referenceOrder.forEach((recordId) => {
|
|
494
|
+
const record = recordsById.get(recordId);
|
|
495
|
+
if (!record || seen.has(recordId)) return;
|
|
496
|
+
seen.add(recordId);
|
|
497
|
+
orderedRecords.push(record);
|
|
498
|
+
});
|
|
499
|
+
targetRecords.forEach((record) => {
|
|
500
|
+
const recordId = getRecordId(record);
|
|
501
|
+
if (!recordId || seen.has(recordId)) return;
|
|
502
|
+
seen.add(recordId);
|
|
503
|
+
orderedRecords.push(record);
|
|
263
504
|
});
|
|
264
505
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
506
|
+
return orderedRecords;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export const preserveRecordOrderFromLocal = (records = {}, localRecords = {}) => {
|
|
510
|
+
const result = {};
|
|
511
|
+
Object.keys(records || {}).forEach((folderId) => {
|
|
512
|
+
result[folderId] = orderRecordsByReference(records?.[folderId], localRecords?.[folderId]);
|
|
270
513
|
});
|
|
514
|
+
return result;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
export const createOperation = (type, payload = {}, options = {}) => ({
|
|
518
|
+
id: options.id || `${type}:${options.clientId || 'client'}:${options.now || Date.now()}:${Math.random().toString(36).slice(2, 10)}`,
|
|
519
|
+
type,
|
|
520
|
+
payload,
|
|
521
|
+
clientId: options.clientId || null,
|
|
522
|
+
createdAt: options.now || Date.now()
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
export const applyOperation = (state = {}, operation = {}) => {
|
|
526
|
+
const normalized = normalizeState(state);
|
|
527
|
+
const type = operation.type;
|
|
528
|
+
const payload = operation.payload || {};
|
|
529
|
+
const operationTime = toFiniteTimestamp(operation.createdAt || payload.updatedAt || Date.now()) || Date.now();
|
|
530
|
+
|
|
531
|
+
const nextState = clone(normalized);
|
|
532
|
+
|
|
533
|
+
switch (type) {
|
|
534
|
+
case OPERATION_TYPES.RECORD_CREATE: {
|
|
535
|
+
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
536
|
+
if (!record) return normalized;
|
|
537
|
+
const recordId = normalizeId(record.id || payload.recordId);
|
|
538
|
+
if (!recordId) return normalized;
|
|
539
|
+
const tombstone = nextState.deletedRecordTombstones[recordId];
|
|
540
|
+
const recordTime = getRecordTime(record) || operationTime;
|
|
541
|
+
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
|
|
542
|
+
return normalized;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const folderId = safeFolderId(payload.folderId || record.folderId);
|
|
546
|
+
const cleanedRecord = {
|
|
547
|
+
...record,
|
|
548
|
+
id: recordId,
|
|
549
|
+
updatedAt: record.updatedAt || operationTime
|
|
550
|
+
};
|
|
551
|
+
delete cleanedRecord.folderId;
|
|
552
|
+
|
|
553
|
+
nextState.records = removeRecordById(nextState.records, recordId);
|
|
554
|
+
nextState.records[folderId] = [
|
|
555
|
+
cleanedRecord,
|
|
556
|
+
...toArray(nextState.records[folderId])
|
|
557
|
+
];
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
562
|
+
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
563
|
+
if (!recordId) return normalized;
|
|
564
|
+
const entry = findRecordEntry(nextState.records, recordId);
|
|
565
|
+
if (!entry) return normalized;
|
|
566
|
+
const patch = payload.patch || payload.record || {};
|
|
567
|
+
const updatedRecord = mergeRecords(entry.record, {
|
|
568
|
+
...entry.record,
|
|
569
|
+
...patch,
|
|
570
|
+
id: recordId,
|
|
571
|
+
updatedAt: patch.updatedAt || operationTime
|
|
572
|
+
});
|
|
573
|
+
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
case OPERATION_TYPES.RECORD_MOVE: {
|
|
578
|
+
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
579
|
+
const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
|
|
580
|
+
if (!recordId) return normalized;
|
|
581
|
+
const entry = findRecordEntry(nextState.records, recordId);
|
|
582
|
+
if (!entry) return normalized;
|
|
583
|
+
const movedRecord = {
|
|
584
|
+
...entry.record,
|
|
585
|
+
...(payload.record || {}),
|
|
586
|
+
id: recordId,
|
|
587
|
+
updatedAt: payload.updatedAt || operationTime
|
|
588
|
+
};
|
|
589
|
+
nextState.records = removeRecordById(nextState.records, recordId);
|
|
590
|
+
nextState.records[targetFolderId] = [
|
|
591
|
+
...toArray(nextState.records[targetFolderId]),
|
|
592
|
+
movedRecord
|
|
593
|
+
];
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
case OPERATION_TYPES.RECORD_REORDER: {
|
|
598
|
+
const folderId = safeFolderId(payload.folderId);
|
|
599
|
+
const existingFolderRecords = toArray(nextState.records?.[folderId]);
|
|
600
|
+
const payloadRecords = toArray(payload.records);
|
|
601
|
+
const recordsById = new Map();
|
|
602
|
+
|
|
603
|
+
existingFolderRecords.forEach((record) => {
|
|
604
|
+
const recordId = getRecordId(record);
|
|
605
|
+
if (recordId && !recordsById.has(recordId)) {
|
|
606
|
+
recordsById.set(recordId, { ...record, id: recordId });
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
payloadRecords.forEach((record) => {
|
|
610
|
+
const recordId = getRecordId(record);
|
|
611
|
+
if (!recordId) return;
|
|
612
|
+
recordsById.set(recordId, {
|
|
613
|
+
...(recordsById.get(recordId) || {}),
|
|
614
|
+
...record,
|
|
615
|
+
id: recordId
|
|
616
|
+
});
|
|
617
|
+
});
|
|
271
618
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
619
|
+
const orderedIds = normalizeIdList(
|
|
620
|
+
Array.isArray(payload.recordIds)
|
|
621
|
+
? payload.recordIds
|
|
622
|
+
: payloadRecords.map((record) => getRecordId(record))
|
|
623
|
+
);
|
|
624
|
+
if (orderedIds.length === 0) return normalized;
|
|
625
|
+
|
|
626
|
+
const seen = new Set();
|
|
627
|
+
const reorderedRecords = [];
|
|
628
|
+
orderedIds.forEach((recordId) => {
|
|
629
|
+
const record = recordsById.get(recordId);
|
|
630
|
+
if (!record || seen.has(recordId)) return;
|
|
631
|
+
seen.add(recordId);
|
|
632
|
+
reorderedRecords.push(record);
|
|
633
|
+
});
|
|
634
|
+
existingFolderRecords.forEach((record) => {
|
|
635
|
+
const recordId = getRecordId(record);
|
|
636
|
+
if (!recordId || seen.has(recordId)) return;
|
|
637
|
+
seen.add(recordId);
|
|
638
|
+
reorderedRecords.push(record);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
nextState.records[folderId] = reorderedRecords;
|
|
642
|
+
break;
|
|
279
643
|
}
|
|
280
|
-
|
|
281
|
-
|
|
644
|
+
|
|
645
|
+
case OPERATION_TYPES.RECORD_DELETE: {
|
|
646
|
+
const recordId = normalizeId(payload.recordId || payload.id);
|
|
647
|
+
if (!recordId) return normalized;
|
|
648
|
+
nextState.records = removeRecordById(nextState.records, recordId);
|
|
649
|
+
nextState.deletedRecordTombstones[recordId] = {
|
|
650
|
+
id: recordId,
|
|
651
|
+
deletedAt: payload.deletedAt || operationTime,
|
|
652
|
+
clientId: operation.clientId || payload.clientId || null,
|
|
653
|
+
operationId: operation.id || null
|
|
654
|
+
};
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
case OPERATION_TYPES.FOLDER_CREATE: {
|
|
659
|
+
const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
|
|
660
|
+
const folderId = normalizeId(folder?.id || payload.folderId);
|
|
661
|
+
if (!folderId) return normalized;
|
|
662
|
+
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
663
|
+
const nextFolder = {
|
|
664
|
+
...(folder || {}),
|
|
665
|
+
id: folderId,
|
|
666
|
+
updatedAt: folder?.updatedAt || operationTime
|
|
667
|
+
};
|
|
668
|
+
if (existingIndex === -1) {
|
|
669
|
+
nextState.folders.push(nextFolder);
|
|
670
|
+
} else {
|
|
671
|
+
nextState.folders[existingIndex] = { ...nextState.folders[existingIndex], ...nextFolder };
|
|
672
|
+
}
|
|
673
|
+
if (!nextState.records[folderId]) nextState.records[folderId] = [];
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
678
|
+
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
679
|
+
if (!folderId) return normalized;
|
|
680
|
+
const patch = payload.patch || payload.folder || {};
|
|
681
|
+
nextState.folders = nextState.folders.map((folder) => (
|
|
682
|
+
folder.id === folderId
|
|
683
|
+
? { ...folder, ...patch, id: folderId, updatedAt: patch.updatedAt || operationTime }
|
|
684
|
+
: folder
|
|
685
|
+
));
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
690
|
+
const folderId = normalizeId(payload.folderId || payload.id);
|
|
691
|
+
if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
|
|
692
|
+
nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
|
|
693
|
+
delete nextState.records[folderId];
|
|
694
|
+
nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
|
|
695
|
+
nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
|
|
696
|
+
nextState.deletedFolderTombstones[folderId] = {
|
|
697
|
+
id: folderId,
|
|
698
|
+
deletedAt: payload.deletedAt || operationTime,
|
|
699
|
+
clientId: operation.clientId || payload.clientId || null,
|
|
700
|
+
operationId: operation.id || null
|
|
701
|
+
};
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
case OPERATION_TYPES.FOLDER_REORDER:
|
|
706
|
+
nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
|
|
707
|
+
break;
|
|
708
|
+
|
|
709
|
+
case OPERATION_TYPES.GROUP_REORDER:
|
|
710
|
+
nextState.groupOrder = normalizeOrder(payload.groupOrder || payload.order);
|
|
711
|
+
break;
|
|
712
|
+
|
|
713
|
+
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
714
|
+
nextState.expandedGroups = normalizeOrder(payload.expandedGroups || payload.groupIds || payload.order || payload.ids);
|
|
715
|
+
break;
|
|
716
|
+
|
|
717
|
+
case OPERATION_TYPES.SETTINGS_UPDATE:
|
|
718
|
+
nextState.settings = {
|
|
719
|
+
...nextState.settings,
|
|
720
|
+
...(payload.patch || payload.settings || {})
|
|
721
|
+
};
|
|
722
|
+
break;
|
|
723
|
+
|
|
724
|
+
default:
|
|
725
|
+
return normalized;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
nextState.lastModified = Math.max(nextState.lastModified || 0, operationTime);
|
|
729
|
+
nextState.rtlSyncMeta = {
|
|
730
|
+
...nextState.rtlSyncMeta,
|
|
731
|
+
lastOperationId: operation.id || nextState.rtlSyncMeta.lastOperationId || null,
|
|
732
|
+
lastOperationType: operation.type || nextState.rtlSyncMeta.lastOperationType || null,
|
|
733
|
+
lastClientId: operation.clientId || nextState.rtlSyncMeta.lastClientId || null,
|
|
734
|
+
lastOperationAt: operationTime
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
return normalizeState(nextState);
|
|
282
738
|
};
|
|
283
739
|
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
740
|
+
export const applyRecordTimeLabelOperation = (state = {}, operation = {}) => applyOperation(state, operation);
|
|
741
|
+
|
|
742
|
+
export const mergeLocalRemote = ({
|
|
743
|
+
localState = {},
|
|
744
|
+
remoteState = {},
|
|
745
|
+
pendingOps = [],
|
|
746
|
+
clientId = null,
|
|
747
|
+
now = Date.now()
|
|
748
|
+
} = {}) => {
|
|
749
|
+
const local = normalizeState(localState);
|
|
750
|
+
const remote = normalizeState(remoteState);
|
|
751
|
+
const deletedRecordTombstones = mergeTombstones(
|
|
752
|
+
remote.deletedRecordTombstones,
|
|
753
|
+
local.deletedRecordTombstones
|
|
754
|
+
);
|
|
755
|
+
const deletedFolderTombstones = mergeTombstones(
|
|
756
|
+
remote.deletedFolderTombstones,
|
|
757
|
+
local.deletedFolderTombstones
|
|
758
|
+
);
|
|
759
|
+
const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
|
|
760
|
+
const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
|
|
761
|
+
|
|
762
|
+
let mergedState = normalizeState({
|
|
763
|
+
...remote,
|
|
764
|
+
folders,
|
|
765
|
+
records: { ...remote.records },
|
|
766
|
+
groupOrder: mergeOrderArrays(null, remote.groupOrder, local.groupOrder),
|
|
767
|
+
folderOrder: mergeOrderWithLocalIds(remote.folderOrder, local.folderOrder, folderIds),
|
|
768
|
+
expandedGroups: mergeOrderArrays(null, remote.expandedGroups, local.expandedGroups),
|
|
769
|
+
settings: { ...remote.settings, ...local.settings },
|
|
770
|
+
deletedRecordTombstones,
|
|
771
|
+
deletedFolderTombstones,
|
|
772
|
+
rtlSyncMeta: {
|
|
773
|
+
...remote.rtlSyncMeta,
|
|
774
|
+
...local.rtlSyncMeta,
|
|
775
|
+
lastMergedAt: now,
|
|
776
|
+
lastMergedClientId: clientId
|
|
777
|
+
},
|
|
778
|
+
lastModified: Math.max(remote.lastModified || 0, local.lastModified || 0)
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
Object.entries(local.records || {}).forEach(([folderId, folderRecords]) => {
|
|
782
|
+
toArray(folderRecords).forEach((localRecord) => {
|
|
783
|
+
if (!localRecord?.id) return;
|
|
784
|
+
const tombstone = deletedRecordTombstones[localRecord.id];
|
|
785
|
+
const recordTime = getRecordTime(localRecord);
|
|
786
|
+
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) return;
|
|
787
|
+
|
|
788
|
+
const remoteEntry = findRecordEntry(mergedState.records, localRecord.id);
|
|
789
|
+
const shouldKeepLocal = localRecord.pendingSync === true ||
|
|
790
|
+
recordTime > getRecordTime(remoteEntry?.record || {});
|
|
791
|
+
if (!shouldKeepLocal) return;
|
|
792
|
+
|
|
793
|
+
const targetFolderId = safeFolderId(folderId);
|
|
794
|
+
const mergedRecord = remoteEntry
|
|
795
|
+
? mergeRecords(remoteEntry.record, localRecord)
|
|
796
|
+
: { ...localRecord };
|
|
797
|
+
mergedState.records = removeRecordById(mergedState.records, localRecord.id);
|
|
798
|
+
mergedState.records[targetFolderId] = [
|
|
799
|
+
mergedRecord,
|
|
800
|
+
...toArray(mergedState.records[targetFolderId])
|
|
801
|
+
];
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
toArray(pendingOps).forEach((operation) => {
|
|
806
|
+
mergedState = applyOperation(mergedState, operation);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
return {
|
|
810
|
+
state: normalizeState(mergedState),
|
|
811
|
+
pendingOps: toArray(pendingOps).map((operation) => ({ ...operation })),
|
|
812
|
+
hasPendingOps: toArray(pendingOps).length > 0,
|
|
813
|
+
mergedAt: now
|
|
814
|
+
};
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const flattenRecordsById = (records = {}) => {
|
|
818
|
+
const result = new Map();
|
|
819
|
+
Object.entries(normalizeRecords(records || {})).forEach(([folderId, recordList]) => {
|
|
820
|
+
toArray(recordList).forEach((record) => {
|
|
821
|
+
if (!record?.id) return;
|
|
822
|
+
result.set(record.id, { folderId, record });
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
return result;
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
export const buildDeletionTombstones = ({
|
|
829
|
+
previousState,
|
|
830
|
+
nextRecords,
|
|
831
|
+
nextFolders,
|
|
832
|
+
existingRecordTombstones,
|
|
833
|
+
existingFolderTombstones,
|
|
834
|
+
deletedAt,
|
|
835
|
+
clientId = null
|
|
836
|
+
} = {}) => {
|
|
837
|
+
const deletionTime = toFiniteTimestamp(deletedAt) || Date.now();
|
|
838
|
+
const previous = normalizeState(previousState || {});
|
|
839
|
+
const previousRecords = flattenRecordsById(previous.records || {});
|
|
840
|
+
const nextRecordsById = flattenRecordsById(nextRecords || {});
|
|
841
|
+
const deletedRecordTombstones = mergeTombstones(
|
|
842
|
+
previous.deletedRecordTombstones || {},
|
|
843
|
+
normalizeTombstones(existingRecordTombstones || {})
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
previousRecords.forEach(({ folderId }, recordId) => {
|
|
847
|
+
if (nextRecordsById.has(recordId)) return;
|
|
848
|
+
const current = deletedRecordTombstones[recordId];
|
|
849
|
+
if (current && toFiniteTimestamp(current.deletedAt) >= deletionTime) return;
|
|
850
|
+
deletedRecordTombstones[recordId] = {
|
|
851
|
+
id: recordId,
|
|
852
|
+
folderId,
|
|
853
|
+
deletedAt: deletionTime,
|
|
854
|
+
clientId
|
|
855
|
+
};
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
const nextFolderIds = new Set(toArray(nextFolders)
|
|
859
|
+
.map((folder) => folder?.id)
|
|
860
|
+
.filter(Boolean));
|
|
861
|
+
const deletedFolderTombstones = mergeTombstones(
|
|
862
|
+
previous.deletedFolderTombstones || {},
|
|
863
|
+
normalizeTombstones(existingFolderTombstones || {})
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
toArray(previous.folders).forEach((folder) => {
|
|
867
|
+
if (!folder?.id || folder.id === 'all' || folder.id === DEFAULT_FOLDER_ID || nextFolderIds.has(folder.id)) return;
|
|
868
|
+
const current = deletedFolderTombstones[folder.id];
|
|
869
|
+
if (current && toFiniteTimestamp(current.deletedAt) >= deletionTime) return;
|
|
870
|
+
deletedFolderTombstones[folder.id] = {
|
|
871
|
+
id: folder.id,
|
|
872
|
+
deletedAt: deletionTime,
|
|
873
|
+
clientId
|
|
874
|
+
};
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
return {
|
|
878
|
+
deletedRecordTombstones,
|
|
879
|
+
deletedFolderTombstones
|
|
880
|
+
};
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
const removeRecordSyncFlags = (records = {}) => Object.fromEntries(
|
|
884
|
+
Object.entries(normalizeRecords(records || {})).map(([folderId, recordList]) => [
|
|
885
|
+
folderId,
|
|
886
|
+
toArray(recordList).map((record) => {
|
|
887
|
+
const cleanRecord = { ...record };
|
|
888
|
+
delete cleanRecord.pendingSync;
|
|
889
|
+
delete cleanRecord.syncAttempts;
|
|
890
|
+
return cleanRecord;
|
|
891
|
+
})
|
|
892
|
+
])
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
export const buildSettingsFromStorage = (data = {}, options = {}) => {
|
|
896
|
+
const settingKeys = toArray(options.settingKeys);
|
|
897
|
+
const settings = data.settings && typeof data.settings === 'object'
|
|
898
|
+
? { ...data.settings }
|
|
899
|
+
: {};
|
|
900
|
+
|
|
901
|
+
settingKeys.forEach((key) => {
|
|
902
|
+
if (data[key] !== undefined) {
|
|
903
|
+
settings[key] = data[key];
|
|
904
|
+
}
|
|
288
905
|
});
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
nextRecords.all = [];
|
|
292
|
-
return nextRecords;
|
|
906
|
+
|
|
907
|
+
return settings;
|
|
293
908
|
};
|
|
294
909
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
toFiniteTimestamp(folder.createdAt),
|
|
298
|
-
toFiniteTimestamp(folder.lastModified)
|
|
910
|
+
const normalizeGroupOrderForOption = (order, normalizer) => (
|
|
911
|
+
typeof normalizer === 'function' ? normalizer(order) : normalizeOrder(order)
|
|
299
912
|
);
|
|
300
913
|
|
|
301
|
-
const
|
|
302
|
-
|
|
914
|
+
export const buildLocalStateFromStorage = (data = {}, options = {}) => normalizeState({
|
|
915
|
+
folders: data.folders || [],
|
|
916
|
+
records: data.records || {},
|
|
917
|
+
groupOrder: normalizeGroupOrderForOption(data.groupOrder, options.groupOrderNormalizer),
|
|
918
|
+
folderOrder: data.folderOrder || [],
|
|
919
|
+
expandedGroups: data.expandedGroups || [],
|
|
920
|
+
settings: buildSettingsFromStorage(data, options),
|
|
921
|
+
rtlSyncMeta: data.rtlSyncMeta || {},
|
|
922
|
+
deletedRecordTombstones: data.deletedRecordTombstones || {},
|
|
923
|
+
deletedFolderTombstones: data.deletedFolderTombstones || {},
|
|
924
|
+
lastModified: data.lastModified || data.updatedAt || 0
|
|
925
|
+
});
|
|
303
926
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
927
|
+
const resolveNow = (now) => (typeof now === 'function' ? now() : (now || Date.now()));
|
|
928
|
+
|
|
929
|
+
export const buildStoragePatchFromState = (state = {}, options = {}) => {
|
|
930
|
+
const normalized = normalizeState(state || {});
|
|
931
|
+
const settings = normalized.settings || {};
|
|
932
|
+
const storagePatch = {
|
|
933
|
+
folders: normalized.folders,
|
|
934
|
+
records: normalized.records,
|
|
935
|
+
groupOrder: normalizeGroupOrderForOption(normalized.groupOrder, options.groupOrderNormalizer),
|
|
936
|
+
folderOrder: normalized.folderOrder,
|
|
937
|
+
expandedGroups: normalized.expandedGroups,
|
|
938
|
+
settings,
|
|
939
|
+
rtlSyncMeta: normalized.rtlSyncMeta,
|
|
940
|
+
deletedRecordTombstones: normalized.deletedRecordTombstones,
|
|
941
|
+
deletedFolderTombstones: normalized.deletedFolderTombstones,
|
|
942
|
+
lastModified: normalized.lastModified || resolveNow(options.now)
|
|
943
|
+
};
|
|
308
944
|
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
|
|
945
|
+
toArray(options.settingKeys).forEach((key) => {
|
|
946
|
+
if (settings[key] !== undefined) {
|
|
947
|
+
storagePatch[key] = settings[key];
|
|
312
948
|
}
|
|
949
|
+
});
|
|
313
950
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
byId.set(id, { ...folder, id });
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
951
|
+
return storagePatch;
|
|
952
|
+
};
|
|
319
953
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
954
|
+
export const buildFirestoreV1UserPatch = (state = {}, options = {}) => {
|
|
955
|
+
const normalized = normalizeState(state || {});
|
|
956
|
+
return {
|
|
957
|
+
folders: normalized.folders,
|
|
958
|
+
records: removeRecordSyncFlags(normalized.records || {}),
|
|
959
|
+
groupOrder: normalizeGroupOrderForOption(normalized.groupOrder, options.groupOrderNormalizer),
|
|
960
|
+
folderOrder: normalized.folderOrder,
|
|
961
|
+
expandedGroups: normalized.expandedGroups,
|
|
962
|
+
settings: normalized.settings || {},
|
|
963
|
+
rtlSyncMeta: {
|
|
964
|
+
...(normalized.rtlSyncMeta || {}),
|
|
965
|
+
schemaVersion: 1,
|
|
966
|
+
...(options.syncMetaPatch || {})
|
|
967
|
+
},
|
|
968
|
+
deletedRecordTombstones: normalized.deletedRecordTombstones || {},
|
|
969
|
+
deletedFolderTombstones: normalized.deletedFolderTombstones || {},
|
|
970
|
+
lastModified: normalized.lastModified || resolveNow(options.now)
|
|
325
971
|
};
|
|
972
|
+
};
|
|
326
973
|
|
|
327
|
-
|
|
328
|
-
|
|
974
|
+
export const buildFirestoreV2LogicalPaths = (userId = '{uid}') => {
|
|
975
|
+
const uid = String(userId || '{uid}');
|
|
976
|
+
const root = `users/${uid}/recordTimeLabel/${FIRESTORE_V2_SETTINGS_DOC_ID}`;
|
|
977
|
+
return {
|
|
978
|
+
root,
|
|
979
|
+
records: `${root}/records`,
|
|
980
|
+
folders: `${root}/folders`,
|
|
981
|
+
settings: root,
|
|
982
|
+
ops: `${root}/ops`
|
|
983
|
+
};
|
|
984
|
+
};
|
|
329
985
|
|
|
330
|
-
|
|
986
|
+
const mapValuesArray = (value = {}) => {
|
|
987
|
+
if (Array.isArray(value)) return value;
|
|
988
|
+
if (!value || typeof value !== 'object') return [];
|
|
989
|
+
return Object.values(value);
|
|
331
990
|
};
|
|
332
991
|
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
992
|
+
const cleanV2RecordDocument = (record = {}, folderId, now) => {
|
|
993
|
+
const recordId = normalizeId(record.id);
|
|
994
|
+
if (!recordId) return null;
|
|
995
|
+
const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
|
|
996
|
+
delete cleanRecord.pendingSync;
|
|
997
|
+
delete cleanRecord.syncAttempts;
|
|
998
|
+
return {
|
|
999
|
+
...cleanRecord,
|
|
1000
|
+
updatedAt: cleanRecord.updatedAt || cleanRecord.createdAt || now,
|
|
1001
|
+
schemaVersion: 2
|
|
1002
|
+
};
|
|
341
1003
|
};
|
|
342
1004
|
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
normalizeRecords(input.records, { deletedRecordTombstones }),
|
|
354
|
-
folders
|
|
355
|
-
);
|
|
1005
|
+
const cleanV2FolderDocument = (folder = {}, now) => {
|
|
1006
|
+
const folderId = normalizeId(folder.id);
|
|
1007
|
+
if (!folderId) return null;
|
|
1008
|
+
return {
|
|
1009
|
+
...folder,
|
|
1010
|
+
id: folderId,
|
|
1011
|
+
updatedAt: folder.updatedAt || folder.createdAt || now,
|
|
1012
|
+
schemaVersion: 2
|
|
1013
|
+
};
|
|
1014
|
+
};
|
|
356
1015
|
|
|
1016
|
+
const cleanV2OperationDocument = (operation = {}, now) => {
|
|
1017
|
+
const operationId = normalizeId(operation.id);
|
|
1018
|
+
if (!operationId || !operation.type) return null;
|
|
357
1019
|
return {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1020
|
+
...operation,
|
|
1021
|
+
id: operationId,
|
|
1022
|
+
createdAt: operation.createdAt || now,
|
|
1023
|
+
schemaVersion: 2
|
|
1024
|
+
};
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) => {
|
|
1028
|
+
const normalized = normalizeState(state || {});
|
|
1029
|
+
const now = resolveNow(options.now);
|
|
1030
|
+
const records = {};
|
|
1031
|
+
Object.entries(removeRecordSyncFlags(normalized.records || {})).forEach(([folderId, recordList]) => {
|
|
1032
|
+
toArray(recordList).forEach((record) => {
|
|
1033
|
+
const recordDoc = cleanV2RecordDocument(record, folderId, now);
|
|
1034
|
+
if (recordDoc) records[recordDoc.id] = recordDoc;
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
const folders = {};
|
|
1039
|
+
toArray(normalized.folders).forEach((folder) => {
|
|
1040
|
+
const folderDoc = cleanV2FolderDocument(folder, now);
|
|
1041
|
+
if (folderDoc) folders[folderDoc.id] = folderDoc;
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const ops = {};
|
|
1045
|
+
toArray(options.pendingOps || state.pendingOps).forEach((operation) => {
|
|
1046
|
+
const operationDoc = cleanV2OperationDocument(operation, now);
|
|
1047
|
+
if (operationDoc) ops[operationDoc.id] = operationDoc;
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const root = {
|
|
1051
|
+
id: FIRESTORE_V2_SETTINGS_DOC_ID,
|
|
1052
|
+
schemaVersion: 2,
|
|
1053
|
+
settings: normalized.settings || {},
|
|
1054
|
+
groupOrder: normalizeGroupOrderForOption(normalized.groupOrder, options.groupOrderNormalizer),
|
|
1055
|
+
folderOrder: normalized.folderOrder,
|
|
1056
|
+
expandedGroups: normalized.expandedGroups,
|
|
364
1057
|
rtlSyncMeta: {
|
|
365
|
-
|
|
366
|
-
|
|
1058
|
+
...(normalized.rtlSyncMeta || {}),
|
|
1059
|
+
schemaVersion: 2,
|
|
1060
|
+
...(options.syncMetaPatch || {})
|
|
367
1061
|
},
|
|
368
|
-
deletedRecordTombstones,
|
|
369
|
-
deletedFolderTombstones,
|
|
370
|
-
lastModified:
|
|
1062
|
+
deletedRecordTombstones: normalized.deletedRecordTombstones || {},
|
|
1063
|
+
deletedFolderTombstones: normalized.deletedFolderTombstones || {},
|
|
1064
|
+
lastModified: normalized.lastModified || now
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
return {
|
|
1068
|
+
schemaVersion: 2,
|
|
1069
|
+
root,
|
|
1070
|
+
records,
|
|
1071
|
+
folders,
|
|
1072
|
+
ops
|
|
371
1073
|
};
|
|
372
1074
|
};
|
|
373
1075
|
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
1076
|
+
const removeV2Metadata = (data = {}) => {
|
|
1077
|
+
const { schemaVersion, folderId, ...rest } = data || {};
|
|
1078
|
+
return rest;
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
export const buildStateFromFirestoreV2Documents = (documents = {}, options = {}) => {
|
|
1082
|
+
const rootDoc = documents?.root ||
|
|
1083
|
+
documents?.settings?.[FIRESTORE_V2_SETTINGS_DOC_ID] ||
|
|
1084
|
+
documents?.settings ||
|
|
1085
|
+
{};
|
|
1086
|
+
const records = {};
|
|
1087
|
+
mapValuesArray(documents?.records).forEach((recordDoc) => {
|
|
1088
|
+
const recordId = normalizeId(recordDoc?.id);
|
|
1089
|
+
if (!recordId) return;
|
|
1090
|
+
const folderId = safeFolderId(recordDoc.folderId);
|
|
1091
|
+
records[folderId] = [
|
|
1092
|
+
...toArray(records[folderId]),
|
|
1093
|
+
{ ...removeV2Metadata(recordDoc), id: recordId }
|
|
1094
|
+
];
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const folders = mapValuesArray(documents?.folders)
|
|
1098
|
+
.map((folderDoc) => {
|
|
1099
|
+
const folderId = normalizeId(folderDoc?.id);
|
|
1100
|
+
if (!folderId) return null;
|
|
1101
|
+
const { schemaVersion, ...folder } = folderDoc;
|
|
1102
|
+
return { ...folder, id: folderId };
|
|
1103
|
+
})
|
|
1104
|
+
.filter(Boolean);
|
|
1105
|
+
|
|
1106
|
+
const pendingOps = mapValuesArray(documents?.ops)
|
|
1107
|
+
.map((operationDoc) => {
|
|
1108
|
+
const operationId = normalizeId(operationDoc?.id);
|
|
1109
|
+
if (!operationId || !operationDoc?.type) return null;
|
|
1110
|
+
const { schemaVersion, ...operation } = operationDoc;
|
|
1111
|
+
return { ...operation, id: operationId };
|
|
1112
|
+
})
|
|
1113
|
+
.filter(Boolean);
|
|
1114
|
+
|
|
1115
|
+
const state = normalizeState({
|
|
1116
|
+
folders,
|
|
1117
|
+
records,
|
|
1118
|
+
settings: rootDoc.settings || {},
|
|
1119
|
+
groupOrder: normalizeGroupOrderForOption(rootDoc.groupOrder, options.groupOrderNormalizer),
|
|
1120
|
+
folderOrder: rootDoc.folderOrder || [],
|
|
1121
|
+
expandedGroups: rootDoc.expandedGroups || [],
|
|
1122
|
+
rtlSyncMeta: rootDoc.rtlSyncMeta || {},
|
|
1123
|
+
deletedRecordTombstones: rootDoc.deletedRecordTombstones || {},
|
|
1124
|
+
deletedFolderTombstones: rootDoc.deletedFolderTombstones || {},
|
|
1125
|
+
lastModified: rootDoc.lastModified || 0
|
|
378
1126
|
});
|
|
379
|
-
|
|
1127
|
+
|
|
1128
|
+
return { state, pendingOps };
|
|
380
1129
|
};
|
|
381
1130
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
1131
|
+
const mergeOrderArrays = (normalizer, ...orders) => {
|
|
1132
|
+
const seen = new Set();
|
|
1133
|
+
const result = [];
|
|
1134
|
+
orders.forEach((order) => {
|
|
1135
|
+
normalizeGroupOrderForOption(order, normalizer).forEach((id) => {
|
|
1136
|
+
if (seen.has(id)) return;
|
|
1137
|
+
seen.add(id);
|
|
1138
|
+
result.push(id);
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
return result;
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
const getLatestPendingOrder = (pendingOps = [], operationType, payloadKeys = []) => {
|
|
1145
|
+
const operations = toArray(pendingOps);
|
|
1146
|
+
for (let index = operations.length - 1; index >= 0; index -= 1) {
|
|
1147
|
+
const operation = operations[index];
|
|
1148
|
+
if (operation?.type !== operationType) continue;
|
|
1149
|
+
const payload = operation.payload || {};
|
|
1150
|
+
const order = payloadKeys.map((key) => payload[key]).find(Array.isArray);
|
|
1151
|
+
if (Array.isArray(order)) return order;
|
|
388
1152
|
}
|
|
389
1153
|
return null;
|
|
390
1154
|
};
|
|
391
1155
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1156
|
+
const resolvePayloadOrder = ({
|
|
1157
|
+
data,
|
|
1158
|
+
previousCloudData,
|
|
1159
|
+
payloadState,
|
|
1160
|
+
pendingOps,
|
|
1161
|
+
operationType,
|
|
1162
|
+
payloadKeys,
|
|
1163
|
+
stateKey,
|
|
1164
|
+
normalizer
|
|
1165
|
+
} = {}) => {
|
|
1166
|
+
const pendingOrder = getLatestPendingOrder(pendingOps, operationType, payloadKeys);
|
|
1167
|
+
if (pendingOrder) {
|
|
1168
|
+
return mergeOrderArrays(normalizer, pendingOrder, previousCloudData?.[stateKey]);
|
|
1169
|
+
}
|
|
399
1170
|
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const nextState = clone(normalized);
|
|
407
|
-
|
|
408
|
-
switch (type) {
|
|
409
|
-
case OPERATION_TYPES.RECORD_CREATE: {
|
|
410
|
-
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
411
|
-
if (!record) return normalized;
|
|
412
|
-
const recordId = normalizeId(record.id || payload.recordId);
|
|
413
|
-
if (!recordId) return normalized;
|
|
414
|
-
const tombstone = nextState.deletedRecordTombstones[recordId];
|
|
415
|
-
const recordTime = getRecordTime(record) || operationTime;
|
|
416
|
-
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
|
|
417
|
-
return normalized;
|
|
418
|
-
}
|
|
1171
|
+
const localOrder = normalizeGroupOrderForOption(data?.[stateKey], normalizer);
|
|
1172
|
+
const cloudOrder = normalizeGroupOrderForOption(previousCloudData?.[stateKey], normalizer);
|
|
1173
|
+
const payloadOrder = normalizeGroupOrderForOption(payloadState?.[stateKey], normalizer);
|
|
1174
|
+
return mergeOrderArrays(normalizer, localOrder, cloudOrder, payloadOrder);
|
|
1175
|
+
};
|
|
419
1176
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
};
|
|
426
|
-
delete cleanedRecord.folderId;
|
|
1177
|
+
const getLastLocalOperationType = (data = {}) => String(
|
|
1178
|
+
data?.rtlSyncMeta?.lastOperationType ||
|
|
1179
|
+
data?.syncMeta?.lastLocalOperationType ||
|
|
1180
|
+
''
|
|
1181
|
+
);
|
|
427
1182
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
1183
|
+
const getLastLocalOperationAt = (data = {}) => toFiniteTimestamp(
|
|
1184
|
+
data?.rtlSyncMeta?.lastOperationAt ||
|
|
1185
|
+
data?.rtlSyncMeta?.lastLocalOperationAt ||
|
|
1186
|
+
data?.syncMeta?.lastLocalOperationAt ||
|
|
1187
|
+
data?.lastModified
|
|
1188
|
+
);
|
|
435
1189
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const patch = payload.patch || payload.record || {};
|
|
442
|
-
const updatedRecord = mergeRecords(entry.record, {
|
|
443
|
-
...entry.record,
|
|
444
|
-
...patch,
|
|
445
|
-
id: recordId,
|
|
446
|
-
updatedAt: patch.updatedAt || operationTime
|
|
447
|
-
});
|
|
448
|
-
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
449
|
-
break;
|
|
450
|
-
}
|
|
1190
|
+
const getLastRemoteOperationAt = (data = {}) => toFiniteTimestamp(
|
|
1191
|
+
data?.rtlSyncMeta?.lastOperationAt ||
|
|
1192
|
+
data?.rtlSyncMeta?.lastBackgroundSyncAt ||
|
|
1193
|
+
data?.lastModified
|
|
1194
|
+
);
|
|
451
1195
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const movedRecord = {
|
|
459
|
-
...entry.record,
|
|
460
|
-
...(payload.record || {}),
|
|
461
|
-
id: recordId,
|
|
462
|
-
updatedAt: payload.updatedAt || operationTime
|
|
463
|
-
};
|
|
464
|
-
nextState.records = removeRecordById(nextState.records, recordId);
|
|
465
|
-
nextState.records[targetFolderId] = [
|
|
466
|
-
...toArray(nextState.records[targetFolderId]),
|
|
467
|
-
movedRecord
|
|
468
|
-
];
|
|
469
|
-
break;
|
|
470
|
-
}
|
|
1196
|
+
const hasRecentLocalOperation = (localData = {}, remoteData = {}, operationType) => {
|
|
1197
|
+
const lastOperationId = String(localData?.rtlSyncMeta?.lastOperationId || '');
|
|
1198
|
+
const lastOperationType = getLastLocalOperationType(localData);
|
|
1199
|
+
if (!lastOperationId.startsWith(`${operationType}:`) && lastOperationType !== operationType) {
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
471
1202
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
nextState.deletedRecordTombstones[recordId] = {
|
|
477
|
-
id: recordId,
|
|
478
|
-
deletedAt: payload.deletedAt || operationTime,
|
|
479
|
-
clientId: operation.clientId || payload.clientId || null,
|
|
480
|
-
operationId: operation.id || null
|
|
481
|
-
};
|
|
482
|
-
break;
|
|
483
|
-
}
|
|
1203
|
+
const localOperationAt = getLastLocalOperationAt(localData);
|
|
1204
|
+
const remoteOperationAt = getLastRemoteOperationAt(remoteData);
|
|
1205
|
+
return !localOperationAt || !remoteOperationAt || localOperationAt >= remoteOperationAt;
|
|
1206
|
+
};
|
|
484
1207
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
490
|
-
const nextFolder = {
|
|
491
|
-
...(folder || {}),
|
|
492
|
-
id: folderId,
|
|
493
|
-
updatedAt: folder?.updatedAt || operationTime
|
|
494
|
-
};
|
|
495
|
-
if (existingIndex === -1) {
|
|
496
|
-
nextState.folders.push(nextFolder);
|
|
497
|
-
} else {
|
|
498
|
-
nextState.folders[existingIndex] = { ...nextState.folders[existingIndex], ...nextFolder };
|
|
499
|
-
}
|
|
500
|
-
if (!nextState.records[folderId]) nextState.records[folderId] = [];
|
|
501
|
-
break;
|
|
502
|
-
}
|
|
1208
|
+
const shouldPreferRecentLocalOrder = (localData = {}, remoteData = {}, operationType) => {
|
|
1209
|
+
if (hasRecentLocalOperation(localData, remoteData, operationType)) {
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
503
1212
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
nextState.folders = nextState.folders.map((folder) => (
|
|
509
|
-
folder.id === folderId
|
|
510
|
-
? { ...folder, ...patch, id: folderId, updatedAt: patch.updatedAt || operationTime }
|
|
511
|
-
: folder
|
|
512
|
-
));
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
1213
|
+
const localOperationAt = getLastLocalOperationAt(localData);
|
|
1214
|
+
const remoteOperationAt = getLastRemoteOperationAt(remoteData);
|
|
1215
|
+
return Boolean(localOperationAt) && (!remoteOperationAt || localOperationAt >= remoteOperationAt);
|
|
1216
|
+
};
|
|
515
1217
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
break;
|
|
530
|
-
}
|
|
1218
|
+
const resolveMergedOrder = ({
|
|
1219
|
+
localData = {},
|
|
1220
|
+
remoteData = {},
|
|
1221
|
+
pendingOps = [],
|
|
1222
|
+
operationType,
|
|
1223
|
+
payloadKeys,
|
|
1224
|
+
stateKey,
|
|
1225
|
+
normalizer
|
|
1226
|
+
} = {}) => {
|
|
1227
|
+
const pendingOrder = getLatestPendingOrder(pendingOps, operationType, payloadKeys);
|
|
1228
|
+
if (pendingOrder) {
|
|
1229
|
+
return mergeOrderArrays(normalizer, pendingOrder, remoteData?.[stateKey], localData?.[stateKey]);
|
|
1230
|
+
}
|
|
531
1231
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1232
|
+
if (shouldPreferRecentLocalOrder(localData, remoteData, operationType)) {
|
|
1233
|
+
return mergeOrderArrays(normalizer, localData?.[stateKey], remoteData?.[stateKey]);
|
|
1234
|
+
}
|
|
535
1235
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
break;
|
|
1236
|
+
return mergeOrderArrays(normalizer, remoteData?.[stateKey], localData?.[stateKey]);
|
|
1237
|
+
};
|
|
539
1238
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1239
|
+
const buildSyncStateInput = (data = {}, operationTime) => ({
|
|
1240
|
+
folders: data?.folders || [],
|
|
1241
|
+
records: data?.records || {},
|
|
1242
|
+
settings: data?.settings || {},
|
|
1243
|
+
groupOrder: data?.groupOrder || [],
|
|
1244
|
+
folderOrder: data?.folderOrder || [],
|
|
1245
|
+
expandedGroups: data?.expandedGroups || [],
|
|
1246
|
+
rtlSyncMeta: data?.rtlSyncMeta || {},
|
|
1247
|
+
deletedRecordTombstones: data?.deletedRecordTombstones || {},
|
|
1248
|
+
deletedFolderTombstones: data?.deletedFolderTombstones || {},
|
|
1249
|
+
lastModified: operationTime
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
export const buildRecordTimeLabelSyncPayload = ({
|
|
1253
|
+
data,
|
|
1254
|
+
previousCloudData,
|
|
1255
|
+
pendingOps = [],
|
|
1256
|
+
syncMode = RECORD_TIMELABEL_SYNC_MODES.FULL,
|
|
1257
|
+
clientId = 'recordtimelabel-client',
|
|
1258
|
+
now = Date.now()
|
|
1259
|
+
} = {}) => {
|
|
1260
|
+
const operationTime = toFiniteTimestamp(data?.lastModified) || now;
|
|
1261
|
+
const payloadSource = syncMode === RECORD_TIMELABEL_SYNC_MODES.MERGE
|
|
1262
|
+
? mergeLocalRemote({
|
|
1263
|
+
localState: buildSyncStateInput(data || {}, operationTime),
|
|
1264
|
+
remoteState: previousCloudData || {},
|
|
1265
|
+
pendingOps,
|
|
1266
|
+
clientId,
|
|
1267
|
+
now
|
|
1268
|
+
}).state
|
|
1269
|
+
: buildSyncStateInput(data || {}, operationTime);
|
|
1270
|
+
const sanitizedRecords = removeRecordSyncFlags(payloadSource.records || {});
|
|
1271
|
+
const deletionTombstones = buildDeletionTombstones({
|
|
1272
|
+
previousState: previousCloudData,
|
|
1273
|
+
nextRecords: sanitizedRecords,
|
|
1274
|
+
nextFolders: payloadSource.folders || [],
|
|
1275
|
+
existingRecordTombstones: payloadSource.deletedRecordTombstones || {},
|
|
1276
|
+
existingFolderTombstones: payloadSource.deletedFolderTombstones || {},
|
|
1277
|
+
deletedAt: operationTime,
|
|
1278
|
+
clientId
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
return normalizeState({
|
|
1282
|
+
...payloadSource,
|
|
1283
|
+
records: sanitizedRecords,
|
|
1284
|
+
deletedRecordTombstones: deletionTombstones.deletedRecordTombstones,
|
|
1285
|
+
deletedFolderTombstones: deletionTombstones.deletedFolderTombstones,
|
|
1286
|
+
lastModified: operationTime
|
|
1287
|
+
});
|
|
1288
|
+
};
|
|
546
1289
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1290
|
+
export const buildSyncPayloadWithOperations = ({
|
|
1291
|
+
data,
|
|
1292
|
+
previousCloudData,
|
|
1293
|
+
pendingOps = [],
|
|
1294
|
+
syncMode = RECORD_TIMELABEL_SYNC_MODES.FULL,
|
|
1295
|
+
clientId = 'recordtimelabel-client',
|
|
1296
|
+
now = Date.now(),
|
|
1297
|
+
groupOrderNormalizer
|
|
1298
|
+
} = {}) => {
|
|
1299
|
+
const operations = toArray(pendingOps);
|
|
1300
|
+
let payloadState = buildRecordTimeLabelSyncPayload({
|
|
1301
|
+
data,
|
|
1302
|
+
previousCloudData,
|
|
1303
|
+
pendingOps: [],
|
|
1304
|
+
syncMode,
|
|
1305
|
+
clientId,
|
|
1306
|
+
now
|
|
1307
|
+
});
|
|
550
1308
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
lastOperationId: operation.id || nextState.rtlSyncMeta.lastOperationId || null,
|
|
555
|
-
lastClientId: operation.clientId || nextState.rtlSyncMeta.lastClientId || null,
|
|
556
|
-
lastOperationAt: operationTime
|
|
557
|
-
};
|
|
1309
|
+
operations.forEach((operation) => {
|
|
1310
|
+
payloadState = applyRecordTimeLabelOperation(payloadState, operation);
|
|
1311
|
+
});
|
|
558
1312
|
|
|
559
|
-
|
|
1313
|
+
const hasPendingRecordReorder = operations.some((operation) => (
|
|
1314
|
+
operation?.type === OPERATION_TYPES.RECORD_REORDER
|
|
1315
|
+
));
|
|
1316
|
+
const recordsForPayload = hasPendingRecordReorder
|
|
1317
|
+
? payloadState.records || {}
|
|
1318
|
+
: preserveRecordOrderFromLocal(payloadState.records || {}, data?.records || {});
|
|
1319
|
+
|
|
1320
|
+
return normalizeState({
|
|
1321
|
+
...payloadState,
|
|
1322
|
+
groupOrder: resolvePayloadOrder({
|
|
1323
|
+
data,
|
|
1324
|
+
previousCloudData,
|
|
1325
|
+
payloadState,
|
|
1326
|
+
pendingOps: operations,
|
|
1327
|
+
operationType: OPERATION_TYPES.GROUP_REORDER,
|
|
1328
|
+
payloadKeys: ['groupOrder', 'order'],
|
|
1329
|
+
stateKey: 'groupOrder',
|
|
1330
|
+
normalizer: groupOrderNormalizer
|
|
1331
|
+
}),
|
|
1332
|
+
folderOrder: resolvePayloadOrder({
|
|
1333
|
+
data,
|
|
1334
|
+
previousCloudData,
|
|
1335
|
+
payloadState,
|
|
1336
|
+
pendingOps: operations,
|
|
1337
|
+
operationType: OPERATION_TYPES.FOLDER_REORDER,
|
|
1338
|
+
payloadKeys: ['folderOrder', 'order'],
|
|
1339
|
+
stateKey: 'folderOrder'
|
|
1340
|
+
}),
|
|
1341
|
+
expandedGroups: resolvePayloadOrder({
|
|
1342
|
+
data,
|
|
1343
|
+
previousCloudData,
|
|
1344
|
+
payloadState,
|
|
1345
|
+
pendingOps: operations,
|
|
1346
|
+
operationType: OPERATION_TYPES.EXPANDED_GROUPS_UPDATE,
|
|
1347
|
+
payloadKeys: ['expandedGroups', 'groupIds', 'order', 'ids'],
|
|
1348
|
+
stateKey: 'expandedGroups'
|
|
1349
|
+
}),
|
|
1350
|
+
records: removeRecordSyncFlags(recordsForPayload)
|
|
1351
|
+
});
|
|
560
1352
|
};
|
|
561
1353
|
|
|
562
|
-
export const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1354
|
+
export const buildRecordTimeLabelSyncPayloadWithOperations = buildSyncPayloadWithOperations;
|
|
1355
|
+
|
|
1356
|
+
export const flushPendingOperations = async ({
|
|
1357
|
+
operations,
|
|
1358
|
+
storageAdapter,
|
|
1359
|
+
cloudAdapter,
|
|
1360
|
+
storageKeys,
|
|
1361
|
+
settingKeys = [],
|
|
1362
|
+
groupOrderNormalizer,
|
|
1363
|
+
clientId = 'recordtimelabel-client',
|
|
567
1364
|
now = Date.now()
|
|
568
1365
|
} = {}) => {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
|
|
580
|
-
const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
|
|
1366
|
+
const requestedOperations = toArray(operations);
|
|
1367
|
+
if (requestedOperations.length === 0) {
|
|
1368
|
+
return { success: true, syncedCount: 0, state: null, pendingOps: [] };
|
|
1369
|
+
}
|
|
1370
|
+
if (!storageAdapter?.load || !storageAdapter?.save) {
|
|
1371
|
+
throw new Error('missing_storage_adapter');
|
|
1372
|
+
}
|
|
1373
|
+
if (!cloudAdapter?.load || !cloudAdapter?.save) {
|
|
1374
|
+
throw new Error('missing_cloud_adapter');
|
|
1375
|
+
}
|
|
581
1376
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1377
|
+
const syncTime = resolveNow(now);
|
|
1378
|
+
const storageData = await storageAdapter.load(storageKeys);
|
|
1379
|
+
const latestPendingOps = toArray(storageData.pendingOps);
|
|
1380
|
+
const requestedIds = new Set(requestedOperations.map((operation) => operation?.id).filter(Boolean));
|
|
1381
|
+
const pendingOpsForPayload = latestPendingOps.filter((operation) => requestedIds.has(operation?.id));
|
|
1382
|
+
|
|
1383
|
+
if (pendingOpsForPayload.length === 0) {
|
|
1384
|
+
return {
|
|
1385
|
+
success: true,
|
|
1386
|
+
syncedCount: 0,
|
|
1387
|
+
state: buildLocalStateFromStorage(storageData, { settingKeys, groupOrderNormalizer }),
|
|
1388
|
+
pendingOps: latestPendingOps
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const remoteState = await cloudAdapter.load();
|
|
1393
|
+
const localState = buildLocalStateFromStorage(storageData, { settingKeys, groupOrderNormalizer });
|
|
1394
|
+
const payload = buildSyncPayloadWithOperations({
|
|
1395
|
+
data: localState,
|
|
1396
|
+
previousCloudData: remoteState || {},
|
|
1397
|
+
pendingOps: pendingOpsForPayload,
|
|
1398
|
+
syncMode: RECORD_TIMELABEL_SYNC_MODES.MERGE,
|
|
1399
|
+
clientId,
|
|
1400
|
+
now: syncTime,
|
|
1401
|
+
groupOrderNormalizer
|
|
599
1402
|
});
|
|
600
1403
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const remoteEntry = findRecordEntry(mergedState.records, localRecord.id);
|
|
609
|
-
const shouldKeepLocal = localRecord.pendingSync === true ||
|
|
610
|
-
recordTime > getRecordTime(remoteEntry?.record || {});
|
|
611
|
-
if (!shouldKeepLocal) return;
|
|
612
|
-
|
|
613
|
-
const targetFolderId = safeFolderId(folderId);
|
|
614
|
-
const mergedRecord = remoteEntry
|
|
615
|
-
? mergeRecords(remoteEntry.record, localRecord)
|
|
616
|
-
: { ...localRecord };
|
|
617
|
-
mergedState.records = removeRecordById(mergedState.records, localRecord.id);
|
|
618
|
-
mergedState.records[targetFolderId] = [
|
|
619
|
-
mergedRecord,
|
|
620
|
-
...toArray(mergedState.records[targetFolderId])
|
|
621
|
-
];
|
|
622
|
-
});
|
|
1404
|
+
await cloudAdapter.save(payload);
|
|
1405
|
+
|
|
1406
|
+
const syncedIds = new Set(pendingOpsForPayload.map((operation) => operation?.id).filter(Boolean));
|
|
1407
|
+
const remainingPendingOps = latestPendingOps.filter((operation) => !syncedIds.has(operation?.id));
|
|
1408
|
+
let stateToPersist = normalizeState(payload || {});
|
|
1409
|
+
remainingPendingOps.forEach((operation) => {
|
|
1410
|
+
stateToPersist = applyRecordTimeLabelOperation(stateToPersist, operation);
|
|
623
1411
|
});
|
|
624
1412
|
|
|
625
|
-
|
|
626
|
-
|
|
1413
|
+
await storageAdapter.save({
|
|
1414
|
+
...buildStoragePatchFromState(stateToPersist, { settingKeys, groupOrderNormalizer, now: syncTime }),
|
|
1415
|
+
pendingOps: remainingPendingOps,
|
|
1416
|
+
syncMeta: {
|
|
1417
|
+
...(storageData.syncMeta || {}),
|
|
1418
|
+
lastSyncedAt: syncTime,
|
|
1419
|
+
lastSyncedSchemaVersion: 1,
|
|
1420
|
+
lastSyncedOperationCount: pendingOpsForPayload.length
|
|
1421
|
+
}
|
|
627
1422
|
});
|
|
628
1423
|
|
|
629
1424
|
return {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1425
|
+
success: true,
|
|
1426
|
+
syncedCount: pendingOpsForPayload.length,
|
|
1427
|
+
state: stateToPersist,
|
|
1428
|
+
pendingOps: remainingPendingOps
|
|
634
1429
|
};
|
|
635
1430
|
};
|
|
636
1431
|
|
|
637
|
-
export const
|
|
1432
|
+
export const mergeRemoteStateIntoLocal = ({
|
|
1433
|
+
localData = {},
|
|
1434
|
+
remoteData = {},
|
|
1435
|
+
pendingOps = localData.pendingOps || [],
|
|
1436
|
+
settingKeys = [],
|
|
1437
|
+
groupOrderNormalizer,
|
|
1438
|
+
clientId = 'recordtimelabel-client',
|
|
1439
|
+
now = Date.now()
|
|
1440
|
+
} = {}) => {
|
|
1441
|
+
const localState = buildLocalStateFromStorage(localData, { settingKeys, groupOrderNormalizer });
|
|
1442
|
+
const merged = mergeLocalRemote({
|
|
1443
|
+
localState,
|
|
1444
|
+
remoteState: remoteData || {},
|
|
1445
|
+
pendingOps,
|
|
1446
|
+
clientId,
|
|
1447
|
+
now
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
let state = merged.state;
|
|
1451
|
+
const hasPendingRecordReorder = toArray(pendingOps).some((operation) => (
|
|
1452
|
+
operation?.type === OPERATION_TYPES.RECORD_REORDER
|
|
1453
|
+
));
|
|
1454
|
+
const hasRecentRecordReorder = hasRecentLocalOperation(
|
|
1455
|
+
localData,
|
|
1456
|
+
remoteData,
|
|
1457
|
+
OPERATION_TYPES.RECORD_REORDER
|
|
1458
|
+
);
|
|
1459
|
+
if (hasPendingRecordReorder || hasRecentRecordReorder) {
|
|
1460
|
+
state = normalizeState({
|
|
1461
|
+
...state,
|
|
1462
|
+
records: preserveRecordOrderFromLocal(state.records || {}, localState.records || {})
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
state = normalizeState({
|
|
1467
|
+
...state,
|
|
1468
|
+
groupOrder: resolveMergedOrder({
|
|
1469
|
+
localData,
|
|
1470
|
+
remoteData,
|
|
1471
|
+
pendingOps,
|
|
1472
|
+
operationType: OPERATION_TYPES.GROUP_REORDER,
|
|
1473
|
+
payloadKeys: ['groupOrder', 'order'],
|
|
1474
|
+
stateKey: 'groupOrder',
|
|
1475
|
+
normalizer: groupOrderNormalizer
|
|
1476
|
+
}),
|
|
1477
|
+
folderOrder: resolveMergedOrder({
|
|
1478
|
+
localData,
|
|
1479
|
+
remoteData,
|
|
1480
|
+
pendingOps,
|
|
1481
|
+
operationType: OPERATION_TYPES.FOLDER_REORDER,
|
|
1482
|
+
payloadKeys: ['folderOrder', 'order'],
|
|
1483
|
+
stateKey: 'folderOrder'
|
|
1484
|
+
}),
|
|
1485
|
+
expandedGroups: resolveMergedOrder({
|
|
1486
|
+
localData,
|
|
1487
|
+
remoteData,
|
|
1488
|
+
pendingOps,
|
|
1489
|
+
operationType: OPERATION_TYPES.EXPANDED_GROUPS_UPDATE,
|
|
1490
|
+
payloadKeys: ['expandedGroups', 'groupIds', 'order', 'ids'],
|
|
1491
|
+
stateKey: 'expandedGroups'
|
|
1492
|
+
})
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
return {
|
|
1496
|
+
...merged,
|
|
1497
|
+
state,
|
|
1498
|
+
storagePatch: buildStoragePatchFromState(state, {
|
|
1499
|
+
settingKeys,
|
|
1500
|
+
groupOrderNormalizer,
|
|
1501
|
+
now
|
|
1502
|
+
})
|
|
1503
|
+
};
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
export const createRecordTimeLabelController = ({
|
|
638
1507
|
storageAdapter,
|
|
639
1508
|
cloudAdapter,
|
|
1509
|
+
storageKeys,
|
|
1510
|
+
settingKeys = [],
|
|
1511
|
+
groupOrderNormalizer,
|
|
640
1512
|
clientId = 'recordtimelabel-client',
|
|
641
1513
|
clock = () => Date.now(),
|
|
642
1514
|
logger = console
|
|
643
1515
|
} = {}) => {
|
|
644
1516
|
if (!storageAdapter?.load || !storageAdapter?.save) {
|
|
645
|
-
throw new Error('
|
|
1517
|
+
throw new Error('createRecordTimeLabelController requires a storageAdapter with load() and save()');
|
|
646
1518
|
}
|
|
647
1519
|
|
|
648
1520
|
let state = normalizeState({});
|
|
@@ -651,32 +1523,44 @@ export const createSyncEngine = ({
|
|
|
651
1523
|
let unsubscribeCloud = null;
|
|
652
1524
|
const listeners = new Set();
|
|
653
1525
|
|
|
1526
|
+
const getSnapshot = () => ({
|
|
1527
|
+
state: clone(state),
|
|
1528
|
+
pendingOps: clone(pendingOps) || [],
|
|
1529
|
+
syncMeta: { ...syncMeta }
|
|
1530
|
+
});
|
|
1531
|
+
|
|
654
1532
|
const notify = (event) => {
|
|
655
|
-
const snapshot =
|
|
1533
|
+
const snapshot = controller.getSnapshot();
|
|
656
1534
|
listeners.forEach((listener) => listener({ ...event, snapshot }));
|
|
657
1535
|
};
|
|
658
1536
|
|
|
659
1537
|
const persist = async () => {
|
|
660
1538
|
await storageAdapter.save({
|
|
661
|
-
state,
|
|
1539
|
+
...buildStoragePatchFromState(state, {
|
|
1540
|
+
settingKeys,
|
|
1541
|
+
groupOrderNormalizer,
|
|
1542
|
+
now: clock()
|
|
1543
|
+
}),
|
|
662
1544
|
pendingOps,
|
|
663
1545
|
syncMeta
|
|
664
1546
|
});
|
|
665
1547
|
};
|
|
666
1548
|
|
|
667
|
-
const
|
|
1549
|
+
const controller = {
|
|
668
1550
|
async init() {
|
|
669
|
-
const loaded = await storageAdapter.load();
|
|
670
|
-
state =
|
|
1551
|
+
const loaded = await storageAdapter.load(storageKeys);
|
|
1552
|
+
state = buildLocalStateFromStorage(loaded || {}, { settingKeys, groupOrderNormalizer });
|
|
671
1553
|
pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
|
|
672
1554
|
syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
|
|
673
1555
|
|
|
674
1556
|
if (cloudAdapter?.load) {
|
|
675
1557
|
const remoteState = await cloudAdapter.load();
|
|
676
|
-
const merged =
|
|
677
|
-
|
|
678
|
-
remoteState,
|
|
1558
|
+
const merged = mergeRemoteStateIntoLocal({
|
|
1559
|
+
localData: { ...state, pendingOps, syncMeta },
|
|
1560
|
+
remoteData: remoteState,
|
|
679
1561
|
pendingOps,
|
|
1562
|
+
settingKeys,
|
|
1563
|
+
groupOrderNormalizer,
|
|
680
1564
|
clientId,
|
|
681
1565
|
now: clock()
|
|
682
1566
|
});
|
|
@@ -686,21 +1570,15 @@ export const createSyncEngine = ({
|
|
|
686
1570
|
|
|
687
1571
|
if (cloudAdapter?.subscribe) {
|
|
688
1572
|
unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
|
|
689
|
-
await
|
|
1573
|
+
await controller.handleRemoteUpdate(remoteState);
|
|
690
1574
|
});
|
|
691
1575
|
}
|
|
692
1576
|
|
|
693
1577
|
notify({ type: 'initialized' });
|
|
694
|
-
return
|
|
1578
|
+
return getSnapshot();
|
|
695
1579
|
},
|
|
696
1580
|
|
|
697
|
-
getSnapshot
|
|
698
|
-
return {
|
|
699
|
-
state: clone(state),
|
|
700
|
-
pendingOps: clone(pendingOps) || [],
|
|
701
|
-
syncMeta: { ...syncMeta }
|
|
702
|
-
};
|
|
703
|
-
},
|
|
1581
|
+
getSnapshot,
|
|
704
1582
|
|
|
705
1583
|
subscribe(listener) {
|
|
706
1584
|
listeners.add(listener);
|
|
@@ -717,63 +1595,59 @@ export const createSyncEngine = ({
|
|
|
717
1595
|
clientId: operation.clientId || clientId,
|
|
718
1596
|
createdAt: operation.createdAt || clock()
|
|
719
1597
|
};
|
|
720
|
-
state =
|
|
1598
|
+
state = applyRecordTimeLabelOperation(state, nextOperation);
|
|
721
1599
|
pendingOps = [...pendingOps, nextOperation];
|
|
722
1600
|
syncMeta = {
|
|
723
1601
|
...syncMeta,
|
|
724
|
-
lastLocalOperationAt: nextOperation.createdAt
|
|
1602
|
+
lastLocalOperationAt: nextOperation.createdAt,
|
|
1603
|
+
lastLocalOperationType: nextOperation.type
|
|
725
1604
|
};
|
|
726
1605
|
await persist();
|
|
727
1606
|
notify({ type: 'local_applied', operation: nextOperation });
|
|
728
|
-
return
|
|
1607
|
+
return getSnapshot();
|
|
729
1608
|
},
|
|
730
1609
|
|
|
731
1610
|
async handleRemoteUpdate(remoteState) {
|
|
732
|
-
const merged =
|
|
733
|
-
|
|
734
|
-
remoteState,
|
|
1611
|
+
const merged = mergeRemoteStateIntoLocal({
|
|
1612
|
+
localData: { ...state, pendingOps, syncMeta },
|
|
1613
|
+
remoteData: remoteState,
|
|
735
1614
|
pendingOps,
|
|
1615
|
+
settingKeys,
|
|
1616
|
+
groupOrderNormalizer,
|
|
736
1617
|
clientId,
|
|
737
1618
|
now: clock()
|
|
738
1619
|
});
|
|
739
1620
|
state = merged.state;
|
|
740
1621
|
await persist();
|
|
741
1622
|
notify({ type: 'remote_merged' });
|
|
742
|
-
return
|
|
1623
|
+
return getSnapshot();
|
|
743
1624
|
},
|
|
744
1625
|
|
|
745
|
-
async sync() {
|
|
1626
|
+
async sync(operations = pendingOps) {
|
|
746
1627
|
if (!cloudAdapter?.save) {
|
|
747
1628
|
return { success: false, reason: 'missing_cloud_save' };
|
|
748
1629
|
}
|
|
749
1630
|
try {
|
|
750
|
-
await
|
|
751
|
-
|
|
1631
|
+
const result = await flushPendingOperations({
|
|
1632
|
+
operations,
|
|
1633
|
+
storageAdapter,
|
|
1634
|
+
cloudAdapter,
|
|
1635
|
+
storageKeys,
|
|
1636
|
+
settingKeys,
|
|
1637
|
+
groupOrderNormalizer,
|
|
752
1638
|
clientId,
|
|
753
|
-
|
|
1639
|
+
now: clock()
|
|
754
1640
|
});
|
|
755
|
-
|
|
1641
|
+
if (result.state) state = result.state;
|
|
1642
|
+
pendingOps = result.pendingOps || [];
|
|
756
1643
|
syncMeta = {
|
|
757
1644
|
...syncMeta,
|
|
758
1645
|
lastSyncedAt: clock()
|
|
759
1646
|
};
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
records: Object.fromEntries(Object.entries(state.records).map(([folderId, records]) => [
|
|
763
|
-
folderId,
|
|
764
|
-
toArray(records).map((record) => {
|
|
765
|
-
const cleanRecord = { ...record };
|
|
766
|
-
delete cleanRecord.pendingSync;
|
|
767
|
-
delete cleanRecord.syncAttempts;
|
|
768
|
-
return cleanRecord;
|
|
769
|
-
})
|
|
770
|
-
]))
|
|
771
|
-
});
|
|
772
|
-
await persist();
|
|
773
|
-
notify({ type: 'synced' });
|
|
774
|
-
return { success: true };
|
|
1647
|
+
notify({ type: 'synced', result });
|
|
1648
|
+
return result;
|
|
775
1649
|
} catch (error) {
|
|
776
|
-
logger?.error?.('[RecordTimeLabelCore] sync failed', error);
|
|
1650
|
+
logger?.error?.('[RecordTimeLabelCore] controller sync failed', error);
|
|
777
1651
|
syncMeta = {
|
|
778
1652
|
...syncMeta,
|
|
779
1653
|
lastSyncError: error?.message || String(error)
|
|
@@ -793,17 +1667,201 @@ export const createSyncEngine = ({
|
|
|
793
1667
|
}
|
|
794
1668
|
};
|
|
795
1669
|
|
|
796
|
-
return
|
|
1670
|
+
return controller;
|
|
797
1671
|
};
|
|
798
|
-
|
|
799
|
-
export
|
|
1672
|
+
|
|
1673
|
+
export const createSyncEngine = ({
|
|
1674
|
+
storageAdapter,
|
|
1675
|
+
cloudAdapter,
|
|
1676
|
+
clientId = 'recordtimelabel-client',
|
|
1677
|
+
clock = () => Date.now(),
|
|
1678
|
+
logger = console
|
|
1679
|
+
} = {}) => {
|
|
1680
|
+
if (!storageAdapter?.load || !storageAdapter?.save) {
|
|
1681
|
+
throw new Error('createSyncEngine requires a storageAdapter with load() and save()');
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
let state = normalizeState({});
|
|
1685
|
+
let pendingOps = [];
|
|
1686
|
+
let syncMeta = {};
|
|
1687
|
+
let unsubscribeCloud = null;
|
|
1688
|
+
const listeners = new Set();
|
|
1689
|
+
|
|
1690
|
+
const notify = (event) => {
|
|
1691
|
+
const snapshot = engine.getSnapshot();
|
|
1692
|
+
listeners.forEach((listener) => listener({ ...event, snapshot }));
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
const persist = async () => {
|
|
1696
|
+
await storageAdapter.save({
|
|
1697
|
+
state,
|
|
1698
|
+
pendingOps,
|
|
1699
|
+
syncMeta
|
|
1700
|
+
});
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
const engine = {
|
|
1704
|
+
async init() {
|
|
1705
|
+
const loaded = await storageAdapter.load();
|
|
1706
|
+
state = normalizeState(loaded?.state || loaded || {});
|
|
1707
|
+
pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
|
|
1708
|
+
syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
|
|
1709
|
+
|
|
1710
|
+
if (cloudAdapter?.load) {
|
|
1711
|
+
const remoteState = await cloudAdapter.load();
|
|
1712
|
+
const merged = mergeLocalRemote({
|
|
1713
|
+
localState: state,
|
|
1714
|
+
remoteState,
|
|
1715
|
+
pendingOps,
|
|
1716
|
+
clientId,
|
|
1717
|
+
now: clock()
|
|
1718
|
+
});
|
|
1719
|
+
state = merged.state;
|
|
1720
|
+
await persist();
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
if (cloudAdapter?.subscribe) {
|
|
1724
|
+
unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
|
|
1725
|
+
await engine.handleRemoteUpdate(remoteState);
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
notify({ type: 'initialized' });
|
|
1730
|
+
return engine.getSnapshot();
|
|
1731
|
+
},
|
|
1732
|
+
|
|
1733
|
+
getSnapshot() {
|
|
1734
|
+
return {
|
|
1735
|
+
state: clone(state),
|
|
1736
|
+
pendingOps: clone(pendingOps) || [],
|
|
1737
|
+
syncMeta: { ...syncMeta }
|
|
1738
|
+
};
|
|
1739
|
+
},
|
|
1740
|
+
|
|
1741
|
+
subscribe(listener) {
|
|
1742
|
+
listeners.add(listener);
|
|
1743
|
+
return () => listeners.delete(listener);
|
|
1744
|
+
},
|
|
1745
|
+
|
|
1746
|
+
async apply(operation) {
|
|
1747
|
+
const nextOperation = {
|
|
1748
|
+
...operation,
|
|
1749
|
+
id: operation.id || createOperation(operation.type, operation.payload, {
|
|
1750
|
+
clientId,
|
|
1751
|
+
now: clock()
|
|
1752
|
+
}).id,
|
|
1753
|
+
clientId: operation.clientId || clientId,
|
|
1754
|
+
createdAt: operation.createdAt || clock()
|
|
1755
|
+
};
|
|
1756
|
+
state = applyOperation(state, nextOperation);
|
|
1757
|
+
pendingOps = [...pendingOps, nextOperation];
|
|
1758
|
+
syncMeta = {
|
|
1759
|
+
...syncMeta,
|
|
1760
|
+
lastLocalOperationAt: nextOperation.createdAt
|
|
1761
|
+
};
|
|
1762
|
+
await persist();
|
|
1763
|
+
notify({ type: 'local_applied', operation: nextOperation });
|
|
1764
|
+
return engine.getSnapshot();
|
|
1765
|
+
},
|
|
1766
|
+
|
|
1767
|
+
async handleRemoteUpdate(remoteState) {
|
|
1768
|
+
const merged = mergeLocalRemote({
|
|
1769
|
+
localState: state,
|
|
1770
|
+
remoteState,
|
|
1771
|
+
pendingOps,
|
|
1772
|
+
clientId,
|
|
1773
|
+
now: clock()
|
|
1774
|
+
});
|
|
1775
|
+
state = merged.state;
|
|
1776
|
+
await persist();
|
|
1777
|
+
notify({ type: 'remote_merged' });
|
|
1778
|
+
return engine.getSnapshot();
|
|
1779
|
+
},
|
|
1780
|
+
|
|
1781
|
+
async sync() {
|
|
1782
|
+
if (!cloudAdapter?.save) {
|
|
1783
|
+
return { success: false, reason: 'missing_cloud_save' };
|
|
1784
|
+
}
|
|
1785
|
+
try {
|
|
1786
|
+
await cloudAdapter.save(state, {
|
|
1787
|
+
pendingOps: clone(pendingOps) || [],
|
|
1788
|
+
clientId,
|
|
1789
|
+
syncMeta
|
|
1790
|
+
});
|
|
1791
|
+
pendingOps = [];
|
|
1792
|
+
syncMeta = {
|
|
1793
|
+
...syncMeta,
|
|
1794
|
+
lastSyncedAt: clock()
|
|
1795
|
+
};
|
|
1796
|
+
state = normalizeState({
|
|
1797
|
+
...state,
|
|
1798
|
+
records: Object.fromEntries(Object.entries(state.records).map(([folderId, records]) => [
|
|
1799
|
+
folderId,
|
|
1800
|
+
toArray(records).map((record) => {
|
|
1801
|
+
const cleanRecord = { ...record };
|
|
1802
|
+
delete cleanRecord.pendingSync;
|
|
1803
|
+
delete cleanRecord.syncAttempts;
|
|
1804
|
+
return cleanRecord;
|
|
1805
|
+
})
|
|
1806
|
+
]))
|
|
1807
|
+
});
|
|
1808
|
+
await persist();
|
|
1809
|
+
notify({ type: 'synced' });
|
|
1810
|
+
return { success: true };
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
logger?.error?.('[RecordTimeLabelCore] sync failed', error);
|
|
1813
|
+
syncMeta = {
|
|
1814
|
+
...syncMeta,
|
|
1815
|
+
lastSyncError: error?.message || String(error)
|
|
1816
|
+
};
|
|
1817
|
+
await persist();
|
|
1818
|
+
notify({ type: 'sync_error', error });
|
|
1819
|
+
return { success: false, error };
|
|
1820
|
+
}
|
|
1821
|
+
},
|
|
1822
|
+
|
|
1823
|
+
async destroy() {
|
|
1824
|
+
if (unsubscribeCloud) {
|
|
1825
|
+
unsubscribeCloud();
|
|
1826
|
+
unsubscribeCloud = null;
|
|
1827
|
+
}
|
|
1828
|
+
listeners.clear();
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
return engine;
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
export default {
|
|
800
1836
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
801
1837
|
OPERATION_TYPES,
|
|
1838
|
+
RECORD_TIMELABEL_SYNC_MODES,
|
|
1839
|
+
RECORD_TIMELABEL_CLOUD_SCHEMAS,
|
|
1840
|
+
FIRESTORE_V2_SETTINGS_DOC_ID,
|
|
802
1841
|
normalizeState,
|
|
1842
|
+
hasMeaningfulRecordTimeLabelCloudState,
|
|
1843
|
+
buildRecordTimeLabelContentFingerprint,
|
|
1844
|
+
buildMigratedRecordTimeLabelV2State,
|
|
803
1845
|
normalizeRecords,
|
|
804
1846
|
mergeRecords,
|
|
805
1847
|
mergeLocalRemote,
|
|
1848
|
+
preserveRecordOrderFromLocal,
|
|
1849
|
+
buildRecordTimeLabelSyncPayload,
|
|
1850
|
+
buildSyncPayloadWithOperations,
|
|
1851
|
+
buildRecordTimeLabelSyncPayloadWithOperations,
|
|
1852
|
+
buildDeletionTombstones,
|
|
1853
|
+
buildSettingsFromStorage,
|
|
1854
|
+
buildLocalStateFromStorage,
|
|
1855
|
+
buildStoragePatchFromState,
|
|
1856
|
+
buildFirestoreV1UserPatch,
|
|
1857
|
+
buildFirestoreV2LogicalPaths,
|
|
1858
|
+
buildFirestoreV2DocumentsFromState,
|
|
1859
|
+
buildStateFromFirestoreV2Documents,
|
|
1860
|
+
flushPendingOperations,
|
|
1861
|
+
mergeRemoteStateIntoLocal,
|
|
806
1862
|
applyOperation,
|
|
1863
|
+
applyRecordTimeLabelOperation,
|
|
807
1864
|
createOperation,
|
|
808
|
-
createSyncEngine
|
|
1865
|
+
createSyncEngine,
|
|
1866
|
+
createRecordTimeLabelController
|
|
809
1867
|
};
|