@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.
Files changed (3) hide show
  1. package/README.md +75 -43
  2. package/package.json +20 -20
  3. 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.0';
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 normalizeTombstones = (value) => {
100
- if (!value) return {};
101
- const entries = Array.isArray(value)
102
- ? value.map((entry) => [entry?.id || entry?.recordId || entry?.folderId, entry])
103
- : Object.entries(value);
104
-
105
- return entries.reduce((result, [rawId, rawTombstone]) => {
106
- const id = normalizeId(rawId);
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
- const existing = placements.get(id);
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
- const merged = mergeRecords(existing.record, normalizedRecord);
225
- const preferred = pickPreferredRecord(existing.record, normalizedRecord);
226
- const shouldMove = preferred === normalizedRecord && targetFolderId !== existing.folderId;
227
- const samePriorityNewer = getRecordTime(normalizedRecord) > getRecordTime(existing.record);
228
-
229
- if (shouldMove || samePriorityNewer) {
230
- normalized[existing.folderId][existing.index] = null;
231
- normalized[targetFolderId].push(merged);
232
- placements.set(id, {
233
- folderId: targetFolderId,
234
- index: normalized[targetFolderId].length - 1,
235
- record: merged
236
- });
237
- } else {
238
- normalized[existing.folderId][existing.index] = merged;
239
- placements.set(id, {
240
- ...existing,
241
- record: merged
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
- Object.keys(normalized).forEach((folderId) => {
248
- normalized[folderId] = toArray(normalized[folderId]).filter(Boolean);
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
- return normalized;
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 ensureFolders = (folders = []) => {
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 nextFolders = [];
257
- toArray(folders).forEach((folder) => {
258
- if (!folder || typeof folder !== 'object') return;
259
- const id = normalizeId(folder.id);
260
- if (!id || seen.has(id)) return;
261
- seen.add(id);
262
- nextFolders.push({ ...folder, id });
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
- REQUIRED_FOLDERS.forEach((folder) => {
266
- if (!seen.has(folder.id)) {
267
- seen.add(folder.id);
268
- nextFolders.unshift({ ...folder });
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
- return nextFolders.sort((left, right) => {
273
- const leftRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === left.id);
274
- const rightRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === right.id);
275
- if (leftRequired !== -1 || rightRequired !== -1) {
276
- if (leftRequired === -1) return 1;
277
- if (rightRequired === -1) return -1;
278
- return leftRequired - rightRequired;
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
- return 0;
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 ensureRecordFolders = (records, folders) => {
285
- const nextRecords = { ...records };
286
- folders.forEach((folder) => {
287
- if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
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
- if (!nextRecords.all) nextRecords.all = [];
290
- if (!nextRecords[DEFAULT_FOLDER_ID]) nextRecords[DEFAULT_FOLDER_ID] = [];
291
- nextRecords.all = [];
292
- return nextRecords;
906
+
907
+ return settings;
293
908
  };
294
909
 
295
- const getFolderTime = (folder = {}) => Math.max(
296
- toFiniteTimestamp(folder.updatedAt),
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 mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombstones = {}) => {
302
- const byId = new Map();
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
- const visit = (folder, source) => {
305
- if (!folder || typeof folder !== 'object') return;
306
- const id = normalizeId(folder.id);
307
- if (!id) return;
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
- const tombstone = deletedFolderTombstones[id];
310
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
311
- return;
945
+ toArray(options.settingKeys).forEach((key) => {
946
+ if (settings[key] !== undefined) {
947
+ storagePatch[key] = settings[key];
312
948
  }
949
+ });
313
950
 
314
- const current = byId.get(id);
315
- if (!current) {
316
- byId.set(id, { ...folder, id });
317
- return;
318
- }
951
+ return storagePatch;
952
+ };
319
953
 
320
- const currentTime = getFolderTime(current);
321
- const nextTime = getFolderTime(folder);
322
- if (nextTime > currentTime || (nextTime === currentTime && source === 'local')) {
323
- byId.set(id, { ...current, ...folder, id });
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
- toArray(remoteFolders).forEach((folder) => visit(folder, 'remote'));
328
- toArray(localFolders).forEach((folder) => visit(folder, 'local'));
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
- return Array.from(byId.values());
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 mergeOrderWithLocalIds = (remoteOrder = [], localOrder = [], allowedIds = new Set()) => {
334
- const merged = normalizeOrder(remoteOrder).filter((id) => allowedIds.has(id));
335
- normalizeOrder(localOrder).forEach((id) => {
336
- if (allowedIds.has(id) && !merged.includes(id)) {
337
- merged.push(id);
338
- }
339
- });
340
- return merged;
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
- export const normalizeState = (input = {}) => {
344
- const deletedRecordTombstones = normalizeTombstones(input.deletedRecordTombstones);
345
- const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
346
- const folders = ensureFolders(input.folders).filter((folder) => {
347
- const tombstone = deletedFolderTombstones[folder.id];
348
- if (!tombstone) return true;
349
- const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
350
- return folderTime > toFiniteTimestamp(tombstone.deletedAt);
351
- });
352
- const records = ensureRecordFolders(
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
- folders,
359
- records,
360
- groupOrder: normalizeOrder(input.groupOrder),
361
- folderOrder: normalizeOrder(input.folderOrder),
362
- expandedGroups: normalizeOrder(input.expandedGroups),
363
- settings: input.settings && typeof input.settings === 'object' ? { ...input.settings } : {},
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
- schemaVersion: 1,
366
- ...(input.rtlSyncMeta && typeof input.rtlSyncMeta === 'object' ? input.rtlSyncMeta : {})
1058
+ ...(normalized.rtlSyncMeta || {}),
1059
+ schemaVersion: 2,
1060
+ ...(options.syncMetaPatch || {})
367
1061
  },
368
- deletedRecordTombstones,
369
- deletedFolderTombstones,
370
- lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
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 removeRecordById = (records, recordId) => {
375
- const nextRecords = {};
376
- Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
377
- nextRecords[folderId] = toArray(folderRecords).filter((record) => record?.id !== recordId);
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
- return nextRecords;
1127
+
1128
+ return { state, pendingOps };
380
1129
  };
381
1130
 
382
- const findRecordEntry = (records, recordId) => {
383
- for (const [folderId, folderRecords] of Object.entries(records || {})) {
384
- const index = toArray(folderRecords).findIndex((record) => record?.id === recordId);
385
- if (index !== -1) {
386
- return { folderId, index, record: folderRecords[index] };
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
- export const createOperation = (type, payload = {}, options = {}) => ({
393
- id: options.id || `${type}:${options.clientId || 'client'}:${options.now || Date.now()}:${Math.random().toString(36).slice(2, 10)}`,
394
- type,
395
- payload,
396
- clientId: options.clientId || null,
397
- createdAt: options.now || Date.now()
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
- export const applyOperation = (state = {}, operation = {}) => {
401
- const normalized = normalizeState(state);
402
- const type = operation.type;
403
- const payload = operation.payload || {};
404
- const operationTime = toFiniteTimestamp(operation.createdAt || payload.updatedAt || Date.now()) || Date.now();
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
- const folderId = safeFolderId(payload.folderId || record.folderId);
421
- const cleanedRecord = {
422
- ...record,
423
- id: recordId,
424
- updatedAt: record.updatedAt || operationTime
425
- };
426
- delete cleanedRecord.folderId;
1177
+ const getLastLocalOperationType = (data = {}) => String(
1178
+ data?.rtlSyncMeta?.lastOperationType ||
1179
+ data?.syncMeta?.lastLocalOperationType ||
1180
+ ''
1181
+ );
427
1182
 
428
- nextState.records = removeRecordById(nextState.records, recordId);
429
- nextState.records[folderId] = [
430
- cleanedRecord,
431
- ...toArray(nextState.records[folderId])
432
- ];
433
- break;
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
- case OPERATION_TYPES.RECORD_UPDATE: {
437
- const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
438
- if (!recordId) return normalized;
439
- const entry = findRecordEntry(nextState.records, recordId);
440
- if (!entry) return normalized;
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
- case OPERATION_TYPES.RECORD_MOVE: {
453
- const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
454
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
455
- if (!recordId) return normalized;
456
- const entry = findRecordEntry(nextState.records, recordId);
457
- if (!entry) return normalized;
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
- case OPERATION_TYPES.RECORD_DELETE: {
473
- const recordId = normalizeId(payload.recordId || payload.id);
474
- if (!recordId) return normalized;
475
- nextState.records = removeRecordById(nextState.records, recordId);
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
- case OPERATION_TYPES.FOLDER_CREATE: {
486
- const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
487
- const folderId = normalizeId(folder?.id || payload.folderId);
488
- if (!folderId) return normalized;
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
- case OPERATION_TYPES.FOLDER_UPDATE: {
505
- const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
506
- if (!folderId) return normalized;
507
- const patch = payload.patch || payload.folder || {};
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
- case OPERATION_TYPES.FOLDER_DELETE: {
517
- const folderId = normalizeId(payload.folderId || payload.id);
518
- if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
519
- nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
520
- delete nextState.records[folderId];
521
- nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
522
- nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
523
- nextState.deletedFolderTombstones[folderId] = {
524
- id: folderId,
525
- deletedAt: payload.deletedAt || operationTime,
526
- clientId: operation.clientId || payload.clientId || null,
527
- operationId: operation.id || null
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
- case OPERATION_TYPES.FOLDER_REORDER:
533
- nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
534
- break;
1232
+ if (shouldPreferRecentLocalOrder(localData, remoteData, operationType)) {
1233
+ return mergeOrderArrays(normalizer, localData?.[stateKey], remoteData?.[stateKey]);
1234
+ }
535
1235
 
536
- case OPERATION_TYPES.GROUP_REORDER:
537
- nextState.groupOrder = normalizeOrder(payload.groupOrder || payload.order);
538
- break;
1236
+ return mergeOrderArrays(normalizer, remoteData?.[stateKey], localData?.[stateKey]);
1237
+ };
539
1238
 
540
- case OPERATION_TYPES.SETTINGS_UPDATE:
541
- nextState.settings = {
542
- ...nextState.settings,
543
- ...(payload.patch || payload.settings || {})
544
- };
545
- break;
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
- default:
548
- return normalized;
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
- nextState.lastModified = Math.max(nextState.lastModified || 0, operationTime);
552
- nextState.rtlSyncMeta = {
553
- ...nextState.rtlSyncMeta,
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
- return normalizeState(nextState);
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 mergeLocalRemote = ({
563
- localState = {},
564
- remoteState = {},
565
- pendingOps = [],
566
- clientId = null,
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 local = normalizeState(localState);
570
- const remote = normalizeState(remoteState);
571
- const deletedRecordTombstones = mergeTombstones(
572
- remote.deletedRecordTombstones,
573
- local.deletedRecordTombstones
574
- );
575
- const deletedFolderTombstones = mergeTombstones(
576
- remote.deletedFolderTombstones,
577
- local.deletedFolderTombstones
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
- let mergedState = normalizeState({
583
- ...remote,
584
- folders,
585
- records: { ...remote.records },
586
- groupOrder: mergeOrderWithLocalIds(remote.groupOrder, local.groupOrder, folderIds),
587
- folderOrder: mergeOrderWithLocalIds(remote.folderOrder, local.folderOrder, folderIds),
588
- expandedGroups: mergeOrderWithLocalIds(remote.expandedGroups, local.expandedGroups, folderIds),
589
- settings: { ...remote.settings, ...local.settings },
590
- deletedRecordTombstones,
591
- deletedFolderTombstones,
592
- rtlSyncMeta: {
593
- ...remote.rtlSyncMeta,
594
- ...local.rtlSyncMeta,
595
- lastMergedAt: now,
596
- lastMergedClientId: clientId
597
- },
598
- lastModified: Math.max(remote.lastModified || 0, local.lastModified || 0)
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
- Object.entries(local.records || {}).forEach(([folderId, folderRecords]) => {
602
- toArray(folderRecords).forEach((localRecord) => {
603
- if (!localRecord?.id) return;
604
- const tombstone = deletedRecordTombstones[localRecord.id];
605
- const recordTime = getRecordTime(localRecord);
606
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) return;
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
- toArray(pendingOps).forEach((operation) => {
626
- mergedState = applyOperation(mergedState, operation);
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
- state: normalizeState(mergedState),
631
- pendingOps: toArray(pendingOps).map((operation) => ({ ...operation })),
632
- hasPendingOps: toArray(pendingOps).length > 0,
633
- mergedAt: now
1425
+ success: true,
1426
+ syncedCount: pendingOpsForPayload.length,
1427
+ state: stateToPersist,
1428
+ pendingOps: remainingPendingOps
634
1429
  };
635
1430
  };
636
1431
 
637
- export const createSyncEngine = ({
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('createSyncEngine requires a storageAdapter with load() and save()');
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 = engine.getSnapshot();
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 engine = {
1549
+ const controller = {
668
1550
  async init() {
669
- const loaded = await storageAdapter.load();
670
- state = normalizeState(loaded?.state || loaded || {});
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 = mergeLocalRemote({
677
- localState: state,
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 engine.handleRemoteUpdate(remoteState);
1573
+ await controller.handleRemoteUpdate(remoteState);
690
1574
  });
691
1575
  }
692
1576
 
693
1577
  notify({ type: 'initialized' });
694
- return engine.getSnapshot();
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 = applyOperation(state, nextOperation);
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 engine.getSnapshot();
1607
+ return getSnapshot();
729
1608
  },
730
1609
 
731
1610
  async handleRemoteUpdate(remoteState) {
732
- const merged = mergeLocalRemote({
733
- localState: state,
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 engine.getSnapshot();
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 cloudAdapter.save(state, {
751
- pendingOps: clone(pendingOps) || [],
1631
+ const result = await flushPendingOperations({
1632
+ operations,
1633
+ storageAdapter,
1634
+ cloudAdapter,
1635
+ storageKeys,
1636
+ settingKeys,
1637
+ groupOrderNormalizer,
752
1638
  clientId,
753
- syncMeta
1639
+ now: clock()
754
1640
  });
755
- pendingOps = [];
1641
+ if (result.state) state = result.state;
1642
+ pendingOps = result.pendingOps || [];
756
1643
  syncMeta = {
757
1644
  ...syncMeta,
758
1645
  lastSyncedAt: clock()
759
1646
  };
760
- state = normalizeState({
761
- ...state,
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 engine;
1670
+ return controller;
797
1671
  };
798
-
799
- export default {
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
  };