@recordtimelabel/core 0.1.1 → 0.1.3
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 +40 -10
- package/package.json +1 -1
- 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
|
|
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,
|
|
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
|
-
- `
|
|
29
|
-
- `
|
|
30
|
-
- `
|
|
31
|
-
- `
|
|
32
|
-
- `
|
|
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
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.
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
194
|
-
const
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
546
|
-
nextState.
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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:
|
|
592
|
-
folderOrder: mergeOrderWithLocalIds(remote.folderOrder, local.folderOrder, folderIds),
|
|
593
|
-
expandedGroups:
|
|
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
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
+
};
|