@recordtimelabel/core 0.1.0

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 +55 -0
  2. package/package.json +20 -0
  3. package/src/index.js +809 -0
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @recordtimelabel/core
2
+
3
+ Shared RecordTimeLabel data model, normalization, merge logic, operation reducer, and sync engine.
4
+
5
+ ## Scope
6
+
7
+ This package is intentionally platform-neutral. It must not import React, Firebase, Chrome extension APIs, Electron APIs, or app-specific UI code.
8
+
9
+ Current consumers:
10
+
11
+ - `D:\Programming\RecordTimeLabel`
12
+ - `D:\Programming\YouTwitchDownloader`
13
+
14
+ During local development both apps consume it with:
15
+
16
+ ```json
17
+ "@recordtimelabel/core": "file:../recordtimelabel-core"
18
+ ```
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.
21
+
22
+ ## Public API
23
+
24
+ - `normalizeState(input)`
25
+ - `normalizeRecords(records, options)`
26
+ - `mergeLocalRemote({ localState, remoteState, pendingOps, clientId, now })`
27
+ - `applyOperation(state, operation)`
28
+ - `createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })`
29
+ - `createOperation(type, payload, options)`
30
+ - `OPERATION_TYPES`
31
+
32
+ ## Firestore v1 Compatibility
33
+
34
+ Phase 1 keeps the existing `users/{uid}` document shape:
35
+
36
+ - `folders`
37
+ - `records`
38
+ - `groupOrder`
39
+ - `folderOrder`
40
+ - `expandedGroups`
41
+ - `settings`
42
+
43
+ The core also normalizes and preserves these compatibility metadata fields:
44
+
45
+ - `rtlSyncMeta`
46
+ - `deletedRecordTombstones`
47
+ - `deletedFolderTombstones`
48
+
49
+ 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.
50
+
51
+ ## Test
52
+
53
+ ```bash
54
+ npm test
55
+ ```
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@recordtimelabel/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test tests/*.test.mjs"
12
+ },
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md"
19
+ ]
20
+ }
package/src/index.js ADDED
@@ -0,0 +1,809 @@
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
+
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
+ const toArray = (value) => (Array.isArray(value) ? value : []);
24
+ const clone = (value) => {
25
+ if (value === undefined || value === null) return value;
26
+ return JSON.parse(JSON.stringify(value));
27
+ };
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
+
95
+ const normalizeOrder = (value) => Array.from(new Set(
96
+ toArray(value).map(normalizeId).filter(Boolean)
97
+ ));
98
+
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
+ }
179
+ });
180
+
181
+ if (left.hasVod || right.hasVod) merged.hasVod = true;
182
+ return merged;
183
+ };
184
+
185
+ export const normalizeRecords = (records = {}, options = {}) => {
186
+ const source = records && typeof records === 'object' ? records : {};
187
+ const tombstones = normalizeTombstones(options.deletedRecordTombstones);
188
+ const normalized = { all: [], [DEFAULT_FOLDER_ID]: [] };
189
+ 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;
197
+
198
+ const targetFolderId = resolveRecordFolderId(folderId, record);
199
+ if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
200
+
201
+ const normalizedRecord = {
202
+ ...record,
203
+ id
204
+ };
205
+ delete normalizedRecord.folderId;
206
+ delete normalizedRecord.sortIndex;
207
+
208
+ const tombstone = tombstones[id];
209
+ if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
210
+ return;
211
+ }
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
+ });
221
+ return;
222
+ }
223
+
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
+ });
246
+
247
+ Object.keys(normalized).forEach((folderId) => {
248
+ normalized[folderId] = toArray(normalized[folderId]).filter(Boolean);
249
+ });
250
+
251
+ return normalized;
252
+ };
253
+
254
+ const ensureFolders = (folders = []) => {
255
+ 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 });
263
+ });
264
+
265
+ REQUIRED_FOLDERS.forEach((folder) => {
266
+ if (!seen.has(folder.id)) {
267
+ seen.add(folder.id);
268
+ nextFolders.unshift({ ...folder });
269
+ }
270
+ });
271
+
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;
279
+ }
280
+ return 0;
281
+ });
282
+ };
283
+
284
+ const ensureRecordFolders = (records, folders) => {
285
+ const nextRecords = { ...records };
286
+ folders.forEach((folder) => {
287
+ if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
288
+ });
289
+ if (!nextRecords.all) nextRecords.all = [];
290
+ if (!nextRecords[DEFAULT_FOLDER_ID]) nextRecords[DEFAULT_FOLDER_ID] = [];
291
+ nextRecords.all = [];
292
+ return nextRecords;
293
+ };
294
+
295
+ const getFolderTime = (folder = {}) => Math.max(
296
+ toFiniteTimestamp(folder.updatedAt),
297
+ toFiniteTimestamp(folder.createdAt),
298
+ toFiniteTimestamp(folder.lastModified)
299
+ );
300
+
301
+ const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombstones = {}) => {
302
+ const byId = new Map();
303
+
304
+ const visit = (folder, source) => {
305
+ if (!folder || typeof folder !== 'object') return;
306
+ const id = normalizeId(folder.id);
307
+ if (!id) return;
308
+
309
+ const tombstone = deletedFolderTombstones[id];
310
+ if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
311
+ return;
312
+ }
313
+
314
+ const current = byId.get(id);
315
+ if (!current) {
316
+ byId.set(id, { ...folder, id });
317
+ return;
318
+ }
319
+
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
+ }
325
+ };
326
+
327
+ toArray(remoteFolders).forEach((folder) => visit(folder, 'remote'));
328
+ toArray(localFolders).forEach((folder) => visit(folder, 'local'));
329
+
330
+ return Array.from(byId.values());
331
+ };
332
+
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;
341
+ };
342
+
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
+ );
356
+
357
+ 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 } : {},
364
+ rtlSyncMeta: {
365
+ schemaVersion: 1,
366
+ ...(input.rtlSyncMeta && typeof input.rtlSyncMeta === 'object' ? input.rtlSyncMeta : {})
367
+ },
368
+ deletedRecordTombstones,
369
+ deletedFolderTombstones,
370
+ lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
371
+ };
372
+ };
373
+
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);
378
+ });
379
+ return nextRecords;
380
+ };
381
+
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
+ }
388
+ }
389
+ return null;
390
+ };
391
+
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
+ });
399
+
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
+ }
419
+
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;
427
+
428
+ nextState.records = removeRecordById(nextState.records, recordId);
429
+ nextState.records[folderId] = [
430
+ cleanedRecord,
431
+ ...toArray(nextState.records[folderId])
432
+ ];
433
+ break;
434
+ }
435
+
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
+ }
451
+
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
+ }
471
+
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
+ }
484
+
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
+ }
503
+
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
+ }
515
+
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
+ }
531
+
532
+ case OPERATION_TYPES.FOLDER_REORDER:
533
+ nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
534
+ break;
535
+
536
+ case OPERATION_TYPES.GROUP_REORDER:
537
+ nextState.groupOrder = normalizeOrder(payload.groupOrder || payload.order);
538
+ break;
539
+
540
+ case OPERATION_TYPES.SETTINGS_UPDATE:
541
+ nextState.settings = {
542
+ ...nextState.settings,
543
+ ...(payload.patch || payload.settings || {})
544
+ };
545
+ break;
546
+
547
+ default:
548
+ return normalized;
549
+ }
550
+
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
+ };
558
+
559
+ return normalizeState(nextState);
560
+ };
561
+
562
+ export const mergeLocalRemote = ({
563
+ localState = {},
564
+ remoteState = {},
565
+ pendingOps = [],
566
+ clientId = null,
567
+ now = Date.now()
568
+ } = {}) => {
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));
581
+
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)
599
+ });
600
+
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
+ });
623
+ });
624
+
625
+ toArray(pendingOps).forEach((operation) => {
626
+ mergedState = applyOperation(mergedState, operation);
627
+ });
628
+
629
+ return {
630
+ state: normalizeState(mergedState),
631
+ pendingOps: toArray(pendingOps).map((operation) => ({ ...operation })),
632
+ hasPendingOps: toArray(pendingOps).length > 0,
633
+ mergedAt: now
634
+ };
635
+ };
636
+
637
+ export const createSyncEngine = ({
638
+ storageAdapter,
639
+ cloudAdapter,
640
+ clientId = 'recordtimelabel-client',
641
+ clock = () => Date.now(),
642
+ logger = console
643
+ } = {}) => {
644
+ if (!storageAdapter?.load || !storageAdapter?.save) {
645
+ throw new Error('createSyncEngine requires a storageAdapter with load() and save()');
646
+ }
647
+
648
+ let state = normalizeState({});
649
+ let pendingOps = [];
650
+ let syncMeta = {};
651
+ let unsubscribeCloud = null;
652
+ const listeners = new Set();
653
+
654
+ const notify = (event) => {
655
+ const snapshot = engine.getSnapshot();
656
+ listeners.forEach((listener) => listener({ ...event, snapshot }));
657
+ };
658
+
659
+ const persist = async () => {
660
+ await storageAdapter.save({
661
+ state,
662
+ pendingOps,
663
+ syncMeta
664
+ });
665
+ };
666
+
667
+ const engine = {
668
+ async init() {
669
+ const loaded = await storageAdapter.load();
670
+ state = normalizeState(loaded?.state || loaded || {});
671
+ pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
672
+ syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
673
+
674
+ if (cloudAdapter?.load) {
675
+ const remoteState = await cloudAdapter.load();
676
+ const merged = mergeLocalRemote({
677
+ localState: state,
678
+ remoteState,
679
+ pendingOps,
680
+ clientId,
681
+ now: clock()
682
+ });
683
+ state = merged.state;
684
+ await persist();
685
+ }
686
+
687
+ if (cloudAdapter?.subscribe) {
688
+ unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
689
+ await engine.handleRemoteUpdate(remoteState);
690
+ });
691
+ }
692
+
693
+ notify({ type: 'initialized' });
694
+ return engine.getSnapshot();
695
+ },
696
+
697
+ getSnapshot() {
698
+ return {
699
+ state: clone(state),
700
+ pendingOps: clone(pendingOps) || [],
701
+ syncMeta: { ...syncMeta }
702
+ };
703
+ },
704
+
705
+ subscribe(listener) {
706
+ listeners.add(listener);
707
+ return () => listeners.delete(listener);
708
+ },
709
+
710
+ async apply(operation) {
711
+ const nextOperation = {
712
+ ...operation,
713
+ id: operation.id || createOperation(operation.type, operation.payload, {
714
+ clientId,
715
+ now: clock()
716
+ }).id,
717
+ clientId: operation.clientId || clientId,
718
+ createdAt: operation.createdAt || clock()
719
+ };
720
+ state = applyOperation(state, nextOperation);
721
+ pendingOps = [...pendingOps, nextOperation];
722
+ syncMeta = {
723
+ ...syncMeta,
724
+ lastLocalOperationAt: nextOperation.createdAt
725
+ };
726
+ await persist();
727
+ notify({ type: 'local_applied', operation: nextOperation });
728
+ return engine.getSnapshot();
729
+ },
730
+
731
+ async handleRemoteUpdate(remoteState) {
732
+ const merged = mergeLocalRemote({
733
+ localState: state,
734
+ remoteState,
735
+ pendingOps,
736
+ clientId,
737
+ now: clock()
738
+ });
739
+ state = merged.state;
740
+ await persist();
741
+ notify({ type: 'remote_merged' });
742
+ return engine.getSnapshot();
743
+ },
744
+
745
+ async sync() {
746
+ if (!cloudAdapter?.save) {
747
+ return { success: false, reason: 'missing_cloud_save' };
748
+ }
749
+ try {
750
+ await cloudAdapter.save(state, {
751
+ pendingOps: clone(pendingOps) || [],
752
+ clientId,
753
+ syncMeta
754
+ });
755
+ pendingOps = [];
756
+ syncMeta = {
757
+ ...syncMeta,
758
+ lastSyncedAt: clock()
759
+ };
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 };
775
+ } catch (error) {
776
+ logger?.error?.('[RecordTimeLabelCore] sync failed', error);
777
+ syncMeta = {
778
+ ...syncMeta,
779
+ lastSyncError: error?.message || String(error)
780
+ };
781
+ await persist();
782
+ notify({ type: 'sync_error', error });
783
+ return { success: false, error };
784
+ }
785
+ },
786
+
787
+ async destroy() {
788
+ if (unsubscribeCloud) {
789
+ unsubscribeCloud();
790
+ unsubscribeCloud = null;
791
+ }
792
+ listeners.clear();
793
+ }
794
+ };
795
+
796
+ return engine;
797
+ };
798
+
799
+ export default {
800
+ RECORD_TIMELABEL_CORE_VERSION,
801
+ OPERATION_TYPES,
802
+ normalizeState,
803
+ normalizeRecords,
804
+ mergeRecords,
805
+ mergeLocalRemote,
806
+ applyOperation,
807
+ createOperation,
808
+ createSyncEngine
809
+ };