@recordtimelabel/core 0.1.1 → 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 +40 -10
  2. package/package.json +1 -1
  3. package/src/index.js +1029 -108
package/README.md CHANGED
@@ -11,25 +11,44 @@ Current consumers:
11
11
  - `D:\Programming\RecordTimeLabel`
12
12
  - `D:\Programming\YouTwitchDownloader`
13
13
 
14
- During local development both apps consume it with:
14
+ During local development an app can consume a sibling checkout with:
15
15
 
16
16
  ```json
17
17
  "@recordtimelabel/core": "file:../recordtimelabel-core"
18
18
  ```
19
19
 
20
- For release builds, move this dependency to a git tag, npm package, or private registry version so builds do not depend on a sibling folder path.
20
+ For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The current release candidate is:
21
+
22
+ ```json
23
+ "@recordtimelabel/core": "0.1.2"
24
+ ```
21
25
 
22
26
  ## Public API
23
27
 
24
28
  - `normalizeState(input)`
25
29
  - `normalizeRecords(records, options)`
26
- - `mergeLocalRemote({ localState, remoteState, pendingOps, clientId, now })`
27
- - `applyOperation(state, operation)`
28
- - `createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })`
29
- - `buildRecordTimeLabelSyncPayload({ data, previousCloudData, pendingOps, syncMode, clientId, now })`
30
- - `RECORD_TIMELABEL_SYNC_MODES`
31
- - `createOperation(type, payload, options)`
32
- - `OPERATION_TYPES`
30
+ - `mergeLocalRemote({ localState, remoteState, pendingOps, clientId, now })`
31
+ - `applyOperation(state, operation)`
32
+ - `applyRecordTimeLabelOperation(state, operation)`
33
+ - `createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })`
34
+ - `createRecordTimeLabelController({ storageAdapter, cloudAdapter, clientId, settingKeys, clock, logger })`
35
+ - `buildRecordTimeLabelSyncPayload({ data, previousCloudData, pendingOps, syncMode, clientId, now })`
36
+ - `buildSyncPayloadWithOperations({ data, previousCloudData, pendingOps, syncMode, clientId, now })`
37
+ - `flushPendingOperations({ operations, storageAdapter, cloudAdapter, clientId, now })`
38
+ - `mergeRemoteStateIntoLocal({ localData, remoteData, pendingOps, settingKeys, clientId, now })`
39
+ - `buildLocalStateFromStorage(data, options)`
40
+ - `buildStoragePatchFromState(state, options)`
41
+ - `buildFirestoreV1UserPatch(state, options)`
42
+ - `buildFirestoreV2LogicalPaths(userId)`
43
+ - `buildFirestoreV2DocumentsFromState(state, options)`
44
+ - `buildStateFromFirestoreV2Documents(documents, options)`
45
+ - `hasMeaningfulRecordTimeLabelCloudState(data, options)`
46
+ - `buildRecordTimeLabelContentFingerprint(data)`
47
+ - `buildMigratedRecordTimeLabelV2State(legacyState, options)`
48
+ - `RECORD_TIMELABEL_CLOUD_SCHEMAS`
49
+ - `RECORD_TIMELABEL_SYNC_MODES`
50
+ - `createOperation(type, payload, options)`
51
+ - `OPERATION_TYPES`
33
52
 
34
53
  ## Firestore v1 Compatibility
35
54
 
@@ -48,7 +67,18 @@ The core also normalizes and preserves these compatibility metadata fields:
48
67
  - `deletedRecordTombstones`
49
68
  - `deletedFolderTombstones`
50
69
 
51
- Deletion must be represented by tombstones or operations. Do not reintroduce the old heuristic that treats "local exists but cloud missing for more than five minutes" as deletion.
70
+ Deletion must be represented by tombstones or operations. Do not reintroduce the old heuristic that treats "local exists but cloud missing for more than five minutes" as deletion.
71
+
72
+ ## Firestore v2 Preparation
73
+
74
+ The package exposes platform-neutral v2 document helpers so app adapters can share the same record/folder/settings/ops shape before wiring Firebase SDK or REST calls:
75
+
76
+ - root document `users/{uid}/recordTimeLabel/main`: settings, order arrays, tombstones, and sync metadata
77
+ - `users/{uid}/recordTimeLabel/main/records/{recordId}`: flattened per-record documents with `folderId`
78
+ - `users/{uid}/recordTimeLabel/main/folders/{folderId}`: per-folder documents
79
+ - `users/{uid}/recordTimeLabel/main/ops/{opId}`: pending operation documents
80
+
81
+ These helpers do not import Firebase and do not perform network writes.
52
82
 
53
83
  ## Test
54
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -5,31 +5,40 @@ const REQUIRED_FOLDERS = [
5
5
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
6
6
  ];
7
7
 
8
- export const RECORD_TIMELABEL_CORE_VERSION = '0.1.1';
9
-
10
- export const OPERATION_TYPES = Object.freeze({
11
- RECORD_CREATE: 'record.create',
12
- RECORD_UPDATE: 'record.update',
13
- RECORD_MOVE: 'record.move',
14
- RECORD_DELETE: 'record.delete',
15
- FOLDER_CREATE: 'folder.create',
16
- FOLDER_UPDATE: 'folder.update',
17
- FOLDER_DELETE: 'folder.delete',
18
- FOLDER_REORDER: 'folder.reorder',
19
- GROUP_REORDER: 'group.reorder',
20
- SETTINGS_UPDATE: 'settings.update'
21
- });
22
-
23
- export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
24
- FULL: 'full',
25
- MERGE: 'merge'
26
- });
27
-
28
- const toArray = (value) => (Array.isArray(value) ? value : []);
29
- const clone = (value) => {
30
- if (value === undefined || value === null) return value;
31
- return JSON.parse(JSON.stringify(value));
32
- };
8
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.1.2';
9
+
10
+ export const OPERATION_TYPES = Object.freeze({
11
+ RECORD_CREATE: 'record.create',
12
+ RECORD_UPDATE: 'record.update',
13
+ RECORD_MOVE: 'record.move',
14
+ RECORD_DELETE: 'record.delete',
15
+ RECORD_REORDER: 'record.reorder',
16
+ FOLDER_CREATE: 'folder.create',
17
+ FOLDER_UPDATE: 'folder.update',
18
+ FOLDER_DELETE: 'folder.delete',
19
+ FOLDER_REORDER: 'folder.reorder',
20
+ GROUP_REORDER: 'group.reorder',
21
+ EXPANDED_GROUPS_UPDATE: 'expandedGroups.update',
22
+ SETTINGS_UPDATE: 'settings.update'
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';
36
+
37
+ const toArray = (value) => (Array.isArray(value) ? value : []);
38
+ const clone = (value) => {
39
+ if (value === undefined || value === null) return value;
40
+ return JSON.parse(JSON.stringify(value));
41
+ };
33
42
 
34
43
  const toFiniteTimestamp = (value) => {
35
44
  if (typeof value === 'number') {
@@ -97,9 +106,23 @@ const safeFolderId = (folderId) => {
97
106
  return normalized;
98
107
  };
99
108
 
100
- const normalizeOrder = (value) => Array.from(new Set(
101
- toArray(value).map(normalizeId).filter(Boolean)
102
- ));
109
+ const normalizeOrder = (value) => Array.from(new Set(
110
+ toArray(value).map(normalizeId).filter(Boolean)
111
+ ));
112
+
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);
121
+ });
122
+ return result;
123
+ };
124
+
125
+ const getRecordId = (record) => normalizeId(record?.id || record?.recordId);
103
126
 
104
127
  const normalizeTombstones = (value) => {
105
128
  if (!value) return {};
@@ -187,11 +210,12 @@ export const mergeRecords = (left, right) => {
187
210
  return merged;
188
211
  };
189
212
 
190
- export const normalizeRecords = (records = {}, options = {}) => {
191
- const source = records && typeof records === 'object' ? records : {};
192
- const tombstones = normalizeTombstones(options.deletedRecordTombstones);
193
- const normalized = { all: [], [DEFAULT_FOLDER_ID]: [] };
194
- const placements = new Map();
213
+ export const normalizeRecords = (records = {}, options = {}) => {
214
+ const source = records && typeof records === 'object' ? records : {};
215
+ const tombstones = normalizeTombstones(options.deletedRecordTombstones);
216
+ const folderTombstones = normalizeTombstones(options.deletedFolderTombstones);
217
+ const normalized = { all: [], [DEFAULT_FOLDER_ID]: [] };
218
+ const placements = new Map();
195
219
 
196
220
  Object.entries(source).forEach(([folderId, folderRecords]) => {
197
221
  const safeList = toArray(folderRecords);
@@ -199,23 +223,28 @@ export const normalizeRecords = (records = {}, options = {}) => {
199
223
  if (!record || typeof record !== 'object') return;
200
224
  const id = normalizeId(record.id) || normalizeId(record.recordId);
201
225
  if (!id) return;
202
-
203
- const targetFolderId = resolveRecordFolderId(folderId, record);
204
- if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
205
-
206
- const normalizedRecord = {
207
- ...record,
226
+
227
+ const targetFolderId = resolveRecordFolderId(folderId, record);
228
+
229
+ const normalizedRecord = {
230
+ ...record,
208
231
  id
209
232
  };
210
233
  delete normalizedRecord.folderId;
211
234
  delete normalizedRecord.sortIndex;
212
-
213
- const tombstone = tombstones[id];
214
- if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
215
- return;
216
- }
217
-
218
- const existing = placements.get(id);
235
+
236
+ const tombstone = tombstones[id];
237
+ if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
238
+ return;
239
+ }
240
+ const folderTombstone = folderTombstones[targetFolderId];
241
+ if (folderTombstone && toFiniteTimestamp(folderTombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
242
+ return;
243
+ }
244
+
245
+ if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
246
+
247
+ const existing = placements.get(id);
219
248
  if (!existing) {
220
249
  normalized[targetFolderId].push(normalizedRecord);
221
250
  placements.set(id, {
@@ -345,19 +374,19 @@ const mergeOrderWithLocalIds = (remoteOrder = [], localOrder = [], allowedIds =
345
374
  return merged;
346
375
  };
347
376
 
348
- export const normalizeState = (input = {}) => {
349
- const deletedRecordTombstones = normalizeTombstones(input.deletedRecordTombstones);
350
- const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
351
- const folders = ensureFolders(input.folders).filter((folder) => {
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) => {
352
381
  const tombstone = deletedFolderTombstones[folder.id];
353
382
  if (!tombstone) return true;
354
383
  const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
355
384
  return folderTime > toFiniteTimestamp(tombstone.deletedAt);
356
385
  });
357
- const records = ensureRecordFolders(
358
- normalizeRecords(input.records, { deletedRecordTombstones }),
359
- folders
360
- );
386
+ const records = ensureRecordFolders(
387
+ normalizeRecords(input.records, { deletedRecordTombstones, deletedFolderTombstones }),
388
+ folders
389
+ );
361
390
 
362
391
  return {
363
392
  folders,
@@ -372,11 +401,61 @@ export const normalizeState = (input = {}) => {
372
401
  },
373
402
  deletedRecordTombstones,
374
403
  deletedFolderTombstones,
375
- lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
376
- };
377
- };
378
-
379
- const removeRecordById = (records, recordId) => {
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
442
+ });
443
+ };
444
+
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
455
+ });
456
+ };
457
+
458
+ const removeRecordById = (records, recordId) => {
380
459
  const nextRecords = {};
381
460
  Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
382
461
  nextRecords[folderId] = toArray(folderRecords).filter((record) => record?.id !== recordId);
@@ -384,15 +463,56 @@ const removeRecordById = (records, recordId) => {
384
463
  return nextRecords;
385
464
  };
386
465
 
387
- const findRecordEntry = (records, recordId) => {
388
- for (const [folderId, folderRecords] of Object.entries(records || {})) {
389
- const index = toArray(folderRecords).findIndex((record) => record?.id === recordId);
466
+ const findRecordEntry = (records, recordId) => {
467
+ for (const [folderId, folderRecords] of Object.entries(records || {})) {
468
+ const index = toArray(folderRecords).findIndex((record) => record?.id === recordId);
390
469
  if (index !== -1) {
391
470
  return { folderId, index, record: folderRecords[index] };
392
471
  }
393
472
  }
394
- return null;
395
- };
473
+ return null;
474
+ };
475
+
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
+
491
+ const seen = new Set();
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);
504
+ });
505
+
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]);
513
+ });
514
+ return result;
515
+ };
396
516
 
397
517
  export const createOperation = (type, payload = {}, options = {}) => ({
398
518
  id: options.id || `${type}:${options.clientId || 'client'}:${options.now || Date.now()}:${Math.random().toString(36).slice(2, 10)}`,
@@ -454,9 +574,9 @@ export const applyOperation = (state = {}, operation = {}) => {
454
574
  break;
455
575
  }
456
576
 
457
- case OPERATION_TYPES.RECORD_MOVE: {
458
- const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
459
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
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);
460
580
  if (!recordId) return normalized;
461
581
  const entry = findRecordEntry(nextState.records, recordId);
462
582
  if (!entry) return normalized;
@@ -471,8 +591,56 @@ export const applyOperation = (state = {}, operation = {}) => {
471
591
  ...toArray(nextState.records[targetFolderId]),
472
592
  movedRecord
473
593
  ];
474
- break;
475
- }
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
+ });
618
+
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;
643
+ }
476
644
 
477
645
  case OPERATION_TYPES.RECORD_DELETE: {
478
646
  const recordId = normalizeId(payload.recordId || payload.id);
@@ -538,14 +706,18 @@ export const applyOperation = (state = {}, operation = {}) => {
538
706
  nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
539
707
  break;
540
708
 
541
- case OPERATION_TYPES.GROUP_REORDER:
542
- nextState.groupOrder = normalizeOrder(payload.groupOrder || payload.order);
543
- break;
544
-
545
- case OPERATION_TYPES.SETTINGS_UPDATE:
546
- nextState.settings = {
547
- ...nextState.settings,
548
- ...(payload.patch || payload.settings || {})
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 || {})
549
721
  };
550
722
  break;
551
723
 
@@ -557,12 +729,15 @@ export const applyOperation = (state = {}, operation = {}) => {
557
729
  nextState.rtlSyncMeta = {
558
730
  ...nextState.rtlSyncMeta,
559
731
  lastOperationId: operation.id || nextState.rtlSyncMeta.lastOperationId || null,
560
- lastClientId: operation.clientId || nextState.rtlSyncMeta.lastClientId || null,
732
+ lastOperationType: operation.type || nextState.rtlSyncMeta.lastOperationType || null,
733
+ lastClientId: operation.clientId || nextState.rtlSyncMeta.lastClientId || null,
561
734
  lastOperationAt: operationTime
562
735
  };
563
-
564
- return normalizeState(nextState);
565
- };
736
+
737
+ return normalizeState(nextState);
738
+ };
739
+
740
+ export const applyRecordTimeLabelOperation = (state = {}, operation = {}) => applyOperation(state, operation);
566
741
 
567
742
  export const mergeLocalRemote = ({
568
743
  localState = {},
@@ -586,12 +761,12 @@ export const mergeLocalRemote = ({
586
761
 
587
762
  let mergedState = normalizeState({
588
763
  ...remote,
589
- folders,
590
- records: { ...remote.records },
591
- groupOrder: mergeOrderWithLocalIds(remote.groupOrder, local.groupOrder, folderIds),
592
- folderOrder: mergeOrderWithLocalIds(remote.folderOrder, local.folderOrder, folderIds),
593
- expandedGroups: mergeOrderWithLocalIds(remote.expandedGroups, local.expandedGroups, folderIds),
594
- settings: { ...remote.settings, ...local.settings },
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 },
595
770
  deletedRecordTombstones,
596
771
  deletedFolderTombstones,
597
772
  rtlSyncMeta: {
@@ -705,8 +880,8 @@ export const buildDeletionTombstones = ({
705
880
  };
706
881
  };
707
882
 
708
- const removeRecordSyncFlags = (records = {}) => Object.fromEntries(
709
- Object.entries(normalizeRecords(records || {})).map(([folderId, recordList]) => [
883
+ const removeRecordSyncFlags = (records = {}) => Object.fromEntries(
884
+ Object.entries(normalizeRecords(records || {})).map(([folderId, recordList]) => [
710
885
  folderId,
711
886
  toArray(recordList).map((record) => {
712
887
  const cleanRecord = { ...record };
@@ -714,10 +889,354 @@ const removeRecordSyncFlags = (records = {}) => Object.fromEntries(
714
889
  delete cleanRecord.syncAttempts;
715
890
  return cleanRecord;
716
891
  })
717
- ])
718
- );
719
-
720
- const buildSyncStateInput = (data = {}, operationTime) => ({
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
+ }
905
+ });
906
+
907
+ return settings;
908
+ };
909
+
910
+ const normalizeGroupOrderForOption = (order, normalizer) => (
911
+ typeof normalizer === 'function' ? normalizer(order) : normalizeOrder(order)
912
+ );
913
+
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
+ });
926
+
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
+ };
944
+
945
+ toArray(options.settingKeys).forEach((key) => {
946
+ if (settings[key] !== undefined) {
947
+ storagePatch[key] = settings[key];
948
+ }
949
+ });
950
+
951
+ return storagePatch;
952
+ };
953
+
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)
971
+ };
972
+ };
973
+
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
+ };
985
+
986
+ const mapValuesArray = (value = {}) => {
987
+ if (Array.isArray(value)) return value;
988
+ if (!value || typeof value !== 'object') return [];
989
+ return Object.values(value);
990
+ };
991
+
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
+ };
1003
+ };
1004
+
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
+ };
1015
+
1016
+ const cleanV2OperationDocument = (operation = {}, now) => {
1017
+ const operationId = normalizeId(operation.id);
1018
+ if (!operationId || !operation.type) return null;
1019
+ return {
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,
1057
+ rtlSyncMeta: {
1058
+ ...(normalized.rtlSyncMeta || {}),
1059
+ schemaVersion: 2,
1060
+ ...(options.syncMetaPatch || {})
1061
+ },
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
1073
+ };
1074
+ };
1075
+
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
1126
+ });
1127
+
1128
+ return { state, pendingOps };
1129
+ };
1130
+
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;
1152
+ }
1153
+ return null;
1154
+ };
1155
+
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
+ }
1170
+
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
+ };
1176
+
1177
+ const getLastLocalOperationType = (data = {}) => String(
1178
+ data?.rtlSyncMeta?.lastOperationType ||
1179
+ data?.syncMeta?.lastLocalOperationType ||
1180
+ ''
1181
+ );
1182
+
1183
+ const getLastLocalOperationAt = (data = {}) => toFiniteTimestamp(
1184
+ data?.rtlSyncMeta?.lastOperationAt ||
1185
+ data?.rtlSyncMeta?.lastLocalOperationAt ||
1186
+ data?.syncMeta?.lastLocalOperationAt ||
1187
+ data?.lastModified
1188
+ );
1189
+
1190
+ const getLastRemoteOperationAt = (data = {}) => toFiniteTimestamp(
1191
+ data?.rtlSyncMeta?.lastOperationAt ||
1192
+ data?.rtlSyncMeta?.lastBackgroundSyncAt ||
1193
+ data?.lastModified
1194
+ );
1195
+
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
+ }
1202
+
1203
+ const localOperationAt = getLastLocalOperationAt(localData);
1204
+ const remoteOperationAt = getLastRemoteOperationAt(remoteData);
1205
+ return !localOperationAt || !remoteOperationAt || localOperationAt >= remoteOperationAt;
1206
+ };
1207
+
1208
+ const shouldPreferRecentLocalOrder = (localData = {}, remoteData = {}, operationType) => {
1209
+ if (hasRecentLocalOperation(localData, remoteData, operationType)) {
1210
+ return true;
1211
+ }
1212
+
1213
+ const localOperationAt = getLastLocalOperationAt(localData);
1214
+ const remoteOperationAt = getLastRemoteOperationAt(remoteData);
1215
+ return Boolean(localOperationAt) && (!remoteOperationAt || localOperationAt >= remoteOperationAt);
1216
+ };
1217
+
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
+ }
1231
+
1232
+ if (shouldPreferRecentLocalOrder(localData, remoteData, operationType)) {
1233
+ return mergeOrderArrays(normalizer, localData?.[stateKey], remoteData?.[stateKey]);
1234
+ }
1235
+
1236
+ return mergeOrderArrays(normalizer, remoteData?.[stateKey], localData?.[stateKey]);
1237
+ };
1238
+
1239
+ const buildSyncStateInput = (data = {}, operationTime) => ({
721
1240
  folders: data?.folders || [],
722
1241
  records: data?.records || {},
723
1242
  settings: data?.settings || {},
@@ -730,7 +1249,7 @@ const buildSyncStateInput = (data = {}, operationTime) => ({
730
1249
  lastModified: operationTime
731
1250
  });
732
1251
 
733
- export const buildRecordTimeLabelSyncPayload = ({
1252
+ export const buildRecordTimeLabelSyncPayload = ({
734
1253
  data,
735
1254
  previousCloudData,
736
1255
  pendingOps = [],
@@ -766,7 +1285,390 @@ export const buildRecordTimeLabelSyncPayload = ({
766
1285
  deletedFolderTombstones: deletionTombstones.deletedFolderTombstones,
767
1286
  lastModified: operationTime
768
1287
  });
769
- };
1288
+ };
1289
+
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
+ });
1308
+
1309
+ operations.forEach((operation) => {
1310
+ payloadState = applyRecordTimeLabelOperation(payloadState, operation);
1311
+ });
1312
+
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
+ });
1352
+ };
1353
+
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',
1364
+ now = Date.now()
1365
+ } = {}) => {
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
+ }
1376
+
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
1402
+ });
1403
+
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);
1411
+ });
1412
+
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
+ }
1422
+ });
1423
+
1424
+ return {
1425
+ success: true,
1426
+ syncedCount: pendingOpsForPayload.length,
1427
+ state: stateToPersist,
1428
+ pendingOps: remainingPendingOps
1429
+ };
1430
+ };
1431
+
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 = ({
1507
+ storageAdapter,
1508
+ cloudAdapter,
1509
+ storageKeys,
1510
+ settingKeys = [],
1511
+ groupOrderNormalizer,
1512
+ clientId = 'recordtimelabel-client',
1513
+ clock = () => Date.now(),
1514
+ logger = console
1515
+ } = {}) => {
1516
+ if (!storageAdapter?.load || !storageAdapter?.save) {
1517
+ throw new Error('createRecordTimeLabelController requires a storageAdapter with load() and save()');
1518
+ }
1519
+
1520
+ let state = normalizeState({});
1521
+ let pendingOps = [];
1522
+ let syncMeta = {};
1523
+ let unsubscribeCloud = null;
1524
+ const listeners = new Set();
1525
+
1526
+ const getSnapshot = () => ({
1527
+ state: clone(state),
1528
+ pendingOps: clone(pendingOps) || [],
1529
+ syncMeta: { ...syncMeta }
1530
+ });
1531
+
1532
+ const notify = (event) => {
1533
+ const snapshot = controller.getSnapshot();
1534
+ listeners.forEach((listener) => listener({ ...event, snapshot }));
1535
+ };
1536
+
1537
+ const persist = async () => {
1538
+ await storageAdapter.save({
1539
+ ...buildStoragePatchFromState(state, {
1540
+ settingKeys,
1541
+ groupOrderNormalizer,
1542
+ now: clock()
1543
+ }),
1544
+ pendingOps,
1545
+ syncMeta
1546
+ });
1547
+ };
1548
+
1549
+ const controller = {
1550
+ async init() {
1551
+ const loaded = await storageAdapter.load(storageKeys);
1552
+ state = buildLocalStateFromStorage(loaded || {}, { settingKeys, groupOrderNormalizer });
1553
+ pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
1554
+ syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
1555
+
1556
+ if (cloudAdapter?.load) {
1557
+ const remoteState = await cloudAdapter.load();
1558
+ const merged = mergeRemoteStateIntoLocal({
1559
+ localData: { ...state, pendingOps, syncMeta },
1560
+ remoteData: remoteState,
1561
+ pendingOps,
1562
+ settingKeys,
1563
+ groupOrderNormalizer,
1564
+ clientId,
1565
+ now: clock()
1566
+ });
1567
+ state = merged.state;
1568
+ await persist();
1569
+ }
1570
+
1571
+ if (cloudAdapter?.subscribe) {
1572
+ unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
1573
+ await controller.handleRemoteUpdate(remoteState);
1574
+ });
1575
+ }
1576
+
1577
+ notify({ type: 'initialized' });
1578
+ return getSnapshot();
1579
+ },
1580
+
1581
+ getSnapshot,
1582
+
1583
+ subscribe(listener) {
1584
+ listeners.add(listener);
1585
+ return () => listeners.delete(listener);
1586
+ },
1587
+
1588
+ async apply(operation) {
1589
+ const nextOperation = {
1590
+ ...operation,
1591
+ id: operation.id || createOperation(operation.type, operation.payload, {
1592
+ clientId,
1593
+ now: clock()
1594
+ }).id,
1595
+ clientId: operation.clientId || clientId,
1596
+ createdAt: operation.createdAt || clock()
1597
+ };
1598
+ state = applyRecordTimeLabelOperation(state, nextOperation);
1599
+ pendingOps = [...pendingOps, nextOperation];
1600
+ syncMeta = {
1601
+ ...syncMeta,
1602
+ lastLocalOperationAt: nextOperation.createdAt,
1603
+ lastLocalOperationType: nextOperation.type
1604
+ };
1605
+ await persist();
1606
+ notify({ type: 'local_applied', operation: nextOperation });
1607
+ return getSnapshot();
1608
+ },
1609
+
1610
+ async handleRemoteUpdate(remoteState) {
1611
+ const merged = mergeRemoteStateIntoLocal({
1612
+ localData: { ...state, pendingOps, syncMeta },
1613
+ remoteData: remoteState,
1614
+ pendingOps,
1615
+ settingKeys,
1616
+ groupOrderNormalizer,
1617
+ clientId,
1618
+ now: clock()
1619
+ });
1620
+ state = merged.state;
1621
+ await persist();
1622
+ notify({ type: 'remote_merged' });
1623
+ return getSnapshot();
1624
+ },
1625
+
1626
+ async sync(operations = pendingOps) {
1627
+ if (!cloudAdapter?.save) {
1628
+ return { success: false, reason: 'missing_cloud_save' };
1629
+ }
1630
+ try {
1631
+ const result = await flushPendingOperations({
1632
+ operations,
1633
+ storageAdapter,
1634
+ cloudAdapter,
1635
+ storageKeys,
1636
+ settingKeys,
1637
+ groupOrderNormalizer,
1638
+ clientId,
1639
+ now: clock()
1640
+ });
1641
+ if (result.state) state = result.state;
1642
+ pendingOps = result.pendingOps || [];
1643
+ syncMeta = {
1644
+ ...syncMeta,
1645
+ lastSyncedAt: clock()
1646
+ };
1647
+ notify({ type: 'synced', result });
1648
+ return result;
1649
+ } catch (error) {
1650
+ logger?.error?.('[RecordTimeLabelCore] controller sync failed', error);
1651
+ syncMeta = {
1652
+ ...syncMeta,
1653
+ lastSyncError: error?.message || String(error)
1654
+ };
1655
+ await persist();
1656
+ notify({ type: 'sync_error', error });
1657
+ return { success: false, error };
1658
+ }
1659
+ },
1660
+
1661
+ async destroy() {
1662
+ if (unsubscribeCloud) {
1663
+ unsubscribeCloud();
1664
+ unsubscribeCloud = null;
1665
+ }
1666
+ listeners.clear();
1667
+ }
1668
+ };
1669
+
1670
+ return controller;
1671
+ };
770
1672
 
771
1673
  export const createSyncEngine = ({
772
1674
  storageAdapter,
@@ -931,16 +1833,35 @@ export const createSyncEngine = ({
931
1833
  };
932
1834
 
933
1835
  export default {
934
- RECORD_TIMELABEL_CORE_VERSION,
935
- OPERATION_TYPES,
936
- RECORD_TIMELABEL_SYNC_MODES,
937
- normalizeState,
938
- normalizeRecords,
939
- mergeRecords,
940
- mergeLocalRemote,
941
- buildRecordTimeLabelSyncPayload,
942
- buildDeletionTombstones,
943
- applyOperation,
944
- createOperation,
945
- createSyncEngine
946
- };
1836
+ RECORD_TIMELABEL_CORE_VERSION,
1837
+ OPERATION_TYPES,
1838
+ RECORD_TIMELABEL_SYNC_MODES,
1839
+ RECORD_TIMELABEL_CLOUD_SCHEMAS,
1840
+ FIRESTORE_V2_SETTINGS_DOC_ID,
1841
+ normalizeState,
1842
+ hasMeaningfulRecordTimeLabelCloudState,
1843
+ buildRecordTimeLabelContentFingerprint,
1844
+ buildMigratedRecordTimeLabelV2State,
1845
+ normalizeRecords,
1846
+ mergeRecords,
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,
1862
+ applyOperation,
1863
+ applyRecordTimeLabelOperation,
1864
+ createOperation,
1865
+ createSyncEngine,
1866
+ createRecordTimeLabelController
1867
+ };