@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.
- package/README.md +55 -0
- package/package.json +20 -0
- 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
|
+
};
|