@recordtimelabel/core 0.1.10 → 0.2.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 +58 -51
- package/package.json +20 -20
- package/src/index.js +1433 -795
package/src/index.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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.
|
|
9
|
-
|
|
1
|
+
const DEFAULT_FOLDER_ID = 'uncategorized';
|
|
2
|
+
const VIRTUAL_FOLDER_IDS = new Set(['all']);
|
|
3
|
+
const REQUIRED_FOLDERS = [
|
|
4
|
+
{ id: 'all', name: 'All' },
|
|
5
|
+
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.2.0';
|
|
9
|
+
export const RTL_SYNC_PROTOCOL_VERSION = 1;
|
|
10
|
+
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
11
|
+
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
12
|
+
export const RTL_MAX_TARGET_WRITES = 100;
|
|
13
|
+
|
|
10
14
|
export const OPERATION_TYPES = Object.freeze({
|
|
11
15
|
RECORD_CREATE: 'record.create',
|
|
12
16
|
RECORD_UPDATE: 'record.update',
|
|
@@ -28,7 +32,7 @@ const ORDER_OPERATION_TYPES = new Set([
|
|
|
28
32
|
OPERATION_TYPES.GROUP_REORDER,
|
|
29
33
|
OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
30
34
|
]);
|
|
31
|
-
|
|
35
|
+
|
|
32
36
|
export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
|
|
33
37
|
FULL: 'full',
|
|
34
38
|
MERGE: 'merge'
|
|
@@ -46,73 +50,73 @@ const clone = (value) => {
|
|
|
46
50
|
if (value === undefined || value === null) return value;
|
|
47
51
|
return JSON.parse(JSON.stringify(value));
|
|
48
52
|
};
|
|
49
|
-
|
|
50
|
-
const toFiniteTimestamp = (value) => {
|
|
51
|
-
if (typeof value === 'number') {
|
|
52
|
-
return Number.isFinite(value) ? value : 0;
|
|
53
|
-
}
|
|
54
|
-
const parsed = new Date(value || 0).getTime();
|
|
55
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const getRecordTime = (record = {}) => Math.max(
|
|
59
|
-
toFiniteTimestamp(record.updatedAt),
|
|
60
|
-
toFiniteTimestamp(record.createdAt),
|
|
61
|
-
toFiniteTimestamp(record.syncedAt)
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
const hasValue = (value) => {
|
|
65
|
-
if (value === undefined || value === null) return false;
|
|
66
|
-
if (typeof value === 'string') return value.trim() !== '';
|
|
67
|
-
return true;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const richFields = [
|
|
71
|
-
'note',
|
|
72
|
-
'title',
|
|
73
|
-
'streamerName',
|
|
74
|
-
'channelName',
|
|
75
|
-
'channelHandle',
|
|
76
|
-
'channelUrl',
|
|
77
|
-
'url',
|
|
78
|
-
'videoUrl',
|
|
79
|
-
'downloadUrl',
|
|
80
|
-
'liveTime',
|
|
81
|
-
'originalLiveTime',
|
|
82
|
-
'platform',
|
|
83
|
-
'hasVod',
|
|
84
|
-
'vodId',
|
|
85
|
-
'vodTitle',
|
|
86
|
-
'avatarUrl',
|
|
87
|
-
'm3u8Type'
|
|
88
|
-
];
|
|
89
|
-
|
|
90
|
-
const getRecordRichness = (record = {}) => richFields.reduce(
|
|
91
|
-
(score, field) => score + (hasValue(record[field]) ? 1 : 0),
|
|
92
|
-
0
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const getRecordPriority = (record = {}) => {
|
|
96
|
-
let score = 0;
|
|
97
|
-
if (record.pendingSync === true) score += 64;
|
|
98
|
-
if (record.hasVod === true) score += 16;
|
|
99
|
-
if (record.videoUrl) score += 8;
|
|
100
|
-
if (record.downloadUrl) score += 4;
|
|
101
|
-
if (record.note) score += 2;
|
|
102
|
-
return score + getRecordRichness(record);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const normalizeId = (value) => {
|
|
106
|
-
if (typeof value !== 'string' && typeof value !== 'number') return '';
|
|
107
|
-
return String(value).trim();
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const safeFolderId = (folderId) => {
|
|
111
|
-
const normalized = normalizeId(folderId);
|
|
112
|
-
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized)) return DEFAULT_FOLDER_ID;
|
|
113
|
-
return normalized;
|
|
114
|
-
};
|
|
115
|
-
|
|
53
|
+
|
|
54
|
+
const toFiniteTimestamp = (value) => {
|
|
55
|
+
if (typeof value === 'number') {
|
|
56
|
+
return Number.isFinite(value) ? value : 0;
|
|
57
|
+
}
|
|
58
|
+
const parsed = new Date(value || 0).getTime();
|
|
59
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const getRecordTime = (record = {}) => Math.max(
|
|
63
|
+
toFiniteTimestamp(record.updatedAt),
|
|
64
|
+
toFiniteTimestamp(record.createdAt),
|
|
65
|
+
toFiniteTimestamp(record.syncedAt)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const hasValue = (value) => {
|
|
69
|
+
if (value === undefined || value === null) return false;
|
|
70
|
+
if (typeof value === 'string') return value.trim() !== '';
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const richFields = [
|
|
75
|
+
'note',
|
|
76
|
+
'title',
|
|
77
|
+
'streamerName',
|
|
78
|
+
'channelName',
|
|
79
|
+
'channelHandle',
|
|
80
|
+
'channelUrl',
|
|
81
|
+
'url',
|
|
82
|
+
'videoUrl',
|
|
83
|
+
'downloadUrl',
|
|
84
|
+
'liveTime',
|
|
85
|
+
'originalLiveTime',
|
|
86
|
+
'platform',
|
|
87
|
+
'hasVod',
|
|
88
|
+
'vodId',
|
|
89
|
+
'vodTitle',
|
|
90
|
+
'avatarUrl',
|
|
91
|
+
'm3u8Type'
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const getRecordRichness = (record = {}) => richFields.reduce(
|
|
95
|
+
(score, field) => score + (hasValue(record[field]) ? 1 : 0),
|
|
96
|
+
0
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const getRecordPriority = (record = {}) => {
|
|
100
|
+
let score = 0;
|
|
101
|
+
if (record.pendingSync === true) score += 64;
|
|
102
|
+
if (record.hasVod === true) score += 16;
|
|
103
|
+
if (record.videoUrl) score += 8;
|
|
104
|
+
if (record.downloadUrl) score += 4;
|
|
105
|
+
if (record.note) score += 2;
|
|
106
|
+
return score + getRecordRichness(record);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const normalizeId = (value) => {
|
|
110
|
+
if (typeof value !== 'string' && typeof value !== 'number') return '';
|
|
111
|
+
return String(value).trim();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const safeFolderId = (folderId) => {
|
|
115
|
+
const normalized = normalizeId(folderId);
|
|
116
|
+
if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized)) return DEFAULT_FOLDER_ID;
|
|
117
|
+
return normalized;
|
|
118
|
+
};
|
|
119
|
+
|
|
116
120
|
const normalizeOrder = (value) => Array.from(new Set(
|
|
117
121
|
toArray(value).map(normalizeId).filter(Boolean)
|
|
118
122
|
));
|
|
@@ -130,115 +134,115 @@ const normalizeIdList = (ids) => {
|
|
|
130
134
|
};
|
|
131
135
|
|
|
132
136
|
const getRecordId = (record) => normalizeId(record?.id || record?.recordId);
|
|
133
|
-
|
|
134
|
-
const normalizeTombstones = (value) => {
|
|
135
|
-
if (!value) return {};
|
|
136
|
-
const entries = Array.isArray(value)
|
|
137
|
-
? value.map((entry) => [entry?.id || entry?.recordId || entry?.folderId, entry])
|
|
138
|
-
: Object.entries(value);
|
|
139
|
-
|
|
140
|
-
return entries.reduce((result, [rawId, rawTombstone]) => {
|
|
141
|
-
const id = normalizeId(rawId);
|
|
142
|
-
if (!id) return result;
|
|
143
|
-
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
144
|
-
? { ...rawTombstone }
|
|
145
|
-
: {};
|
|
146
|
-
const deletedAt = toFiniteTimestamp(tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt);
|
|
147
|
-
result[id] = {
|
|
148
|
-
...tombstone,
|
|
149
|
-
id,
|
|
150
|
-
deletedAt: deletedAt || Date.now()
|
|
151
|
-
};
|
|
152
|
-
return result;
|
|
153
|
-
}, {});
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const mergeTombstones = (left = {}, right = {}) => {
|
|
157
|
-
const merged = { ...left };
|
|
158
|
-
Object.entries(right).forEach(([id, tombstone]) => {
|
|
159
|
-
const current = merged[id];
|
|
160
|
-
if (!current || toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt)) {
|
|
161
|
-
merged[id] = { ...tombstone };
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
return merged;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const resolveRecordFolderId = (folderId, record = {}) => {
|
|
168
|
-
const candidates = [
|
|
169
|
-
record.folderId,
|
|
170
|
-
record.originalFolderId,
|
|
171
|
-
record.sourceFolderId,
|
|
172
|
-
record.targetFolderId,
|
|
173
|
-
folderId
|
|
174
|
-
];
|
|
175
|
-
const match = candidates.map(normalizeId).find((id) => id && !VIRTUAL_FOLDER_IDS.has(id));
|
|
176
|
-
return match || DEFAULT_FOLDER_ID;
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const pickPreferredRecord = (left, right) => {
|
|
180
|
-
if (!left) return right ? { ...right } : null;
|
|
181
|
-
if (!right) return { ...left };
|
|
182
|
-
|
|
183
|
-
const leftTime = getRecordTime(left);
|
|
184
|
-
const rightTime = getRecordTime(right);
|
|
185
|
-
if (leftTime !== rightTime) {
|
|
186
|
-
return rightTime > leftTime ? right : left;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const leftPriority = getRecordPriority(left);
|
|
190
|
-
const rightPriority = getRecordPriority(right);
|
|
191
|
-
if (leftPriority !== rightPriority) {
|
|
192
|
-
return rightPriority > leftPriority ? right : left;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return left;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
export const mergeRecords = (left, right) => {
|
|
199
|
-
if (!left) return right ? { ...right } : null;
|
|
200
|
-
if (!right) return { ...left };
|
|
201
|
-
|
|
202
|
-
const preferred = pickPreferredRecord(left, right);
|
|
203
|
-
const secondary = preferred === left ? right : left;
|
|
204
|
-
const merged = { ...secondary, ...preferred };
|
|
205
|
-
|
|
206
|
-
richFields.forEach((field) => {
|
|
207
|
-
if (!hasValue(merged[field])) {
|
|
208
|
-
if (hasValue(preferred?.[field])) {
|
|
209
|
-
merged[field] = preferred[field];
|
|
210
|
-
} else if (hasValue(secondary?.[field])) {
|
|
211
|
-
merged[field] = secondary[field];
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
217
|
-
return merged;
|
|
218
|
-
};
|
|
219
|
-
|
|
137
|
+
|
|
138
|
+
const normalizeTombstones = (value) => {
|
|
139
|
+
if (!value) return {};
|
|
140
|
+
const entries = Array.isArray(value)
|
|
141
|
+
? value.map((entry) => [entry?.id || entry?.recordId || entry?.folderId, entry])
|
|
142
|
+
: Object.entries(value);
|
|
143
|
+
|
|
144
|
+
return entries.reduce((result, [rawId, rawTombstone]) => {
|
|
145
|
+
const id = normalizeId(rawId);
|
|
146
|
+
if (!id) return result;
|
|
147
|
+
const tombstone = rawTombstone && typeof rawTombstone === 'object'
|
|
148
|
+
? { ...rawTombstone }
|
|
149
|
+
: {};
|
|
150
|
+
const deletedAt = toFiniteTimestamp(tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt);
|
|
151
|
+
result[id] = {
|
|
152
|
+
...tombstone,
|
|
153
|
+
id,
|
|
154
|
+
deletedAt: deletedAt || Date.now()
|
|
155
|
+
};
|
|
156
|
+
return result;
|
|
157
|
+
}, {});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const mergeTombstones = (left = {}, right = {}) => {
|
|
161
|
+
const merged = { ...left };
|
|
162
|
+
Object.entries(right).forEach(([id, tombstone]) => {
|
|
163
|
+
const current = merged[id];
|
|
164
|
+
if (!current || toFiniteTimestamp(tombstone.deletedAt) >= toFiniteTimestamp(current.deletedAt)) {
|
|
165
|
+
merged[id] = { ...tombstone };
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return merged;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const resolveRecordFolderId = (folderId, record = {}) => {
|
|
172
|
+
const candidates = [
|
|
173
|
+
record.folderId,
|
|
174
|
+
record.originalFolderId,
|
|
175
|
+
record.sourceFolderId,
|
|
176
|
+
record.targetFolderId,
|
|
177
|
+
folderId
|
|
178
|
+
];
|
|
179
|
+
const match = candidates.map(normalizeId).find((id) => id && !VIRTUAL_FOLDER_IDS.has(id));
|
|
180
|
+
return match || DEFAULT_FOLDER_ID;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const pickPreferredRecord = (left, right) => {
|
|
184
|
+
if (!left) return right ? { ...right } : null;
|
|
185
|
+
if (!right) return { ...left };
|
|
186
|
+
|
|
187
|
+
const leftTime = getRecordTime(left);
|
|
188
|
+
const rightTime = getRecordTime(right);
|
|
189
|
+
if (leftTime !== rightTime) {
|
|
190
|
+
return rightTime > leftTime ? right : left;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const leftPriority = getRecordPriority(left);
|
|
194
|
+
const rightPriority = getRecordPriority(right);
|
|
195
|
+
if (leftPriority !== rightPriority) {
|
|
196
|
+
return rightPriority > leftPriority ? right : left;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return left;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const mergeRecords = (left, right) => {
|
|
203
|
+
if (!left) return right ? { ...right } : null;
|
|
204
|
+
if (!right) return { ...left };
|
|
205
|
+
|
|
206
|
+
const preferred = pickPreferredRecord(left, right);
|
|
207
|
+
const secondary = preferred === left ? right : left;
|
|
208
|
+
const merged = { ...secondary, ...preferred };
|
|
209
|
+
|
|
210
|
+
richFields.forEach((field) => {
|
|
211
|
+
if (!hasValue(merged[field])) {
|
|
212
|
+
if (hasValue(preferred?.[field])) {
|
|
213
|
+
merged[field] = preferred[field];
|
|
214
|
+
} else if (hasValue(secondary?.[field])) {
|
|
215
|
+
merged[field] = secondary[field];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (left.hasVod || right.hasVod) merged.hasVod = true;
|
|
221
|
+
return merged;
|
|
222
|
+
};
|
|
223
|
+
|
|
220
224
|
export const normalizeRecords = (records = {}, options = {}) => {
|
|
221
225
|
const source = records && typeof records === 'object' ? records : {};
|
|
222
226
|
const tombstones = normalizeTombstones(options.deletedRecordTombstones);
|
|
223
227
|
const folderTombstones = normalizeTombstones(options.deletedFolderTombstones);
|
|
224
228
|
const normalized = { all: [], [DEFAULT_FOLDER_ID]: [] };
|
|
225
229
|
const placements = new Map();
|
|
226
|
-
|
|
227
|
-
Object.entries(source).forEach(([folderId, folderRecords]) => {
|
|
228
|
-
const safeList = toArray(folderRecords);
|
|
229
|
-
safeList.forEach((record, index) => {
|
|
230
|
-
if (!record || typeof record !== 'object') return;
|
|
231
|
-
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
232
|
-
if (!id) return;
|
|
230
|
+
|
|
231
|
+
Object.entries(source).forEach(([folderId, folderRecords]) => {
|
|
232
|
+
const safeList = toArray(folderRecords);
|
|
233
|
+
safeList.forEach((record, index) => {
|
|
234
|
+
if (!record || typeof record !== 'object') return;
|
|
235
|
+
const id = normalizeId(record.id) || normalizeId(record.recordId);
|
|
236
|
+
if (!id) return;
|
|
233
237
|
|
|
234
238
|
const targetFolderId = resolveRecordFolderId(folderId, record);
|
|
235
239
|
|
|
236
240
|
const normalizedRecord = {
|
|
237
241
|
...record,
|
|
238
|
-
id
|
|
239
|
-
};
|
|
240
|
-
delete normalizedRecord.folderId;
|
|
241
|
-
delete normalizedRecord.sortIndex;
|
|
242
|
+
id
|
|
243
|
+
};
|
|
244
|
+
delete normalizedRecord.folderId;
|
|
245
|
+
delete normalizedRecord.sortIndex;
|
|
242
246
|
|
|
243
247
|
const tombstone = tombstones[id];
|
|
244
248
|
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getRecordTime(normalizedRecord)) {
|
|
@@ -252,162 +256,162 @@ export const normalizeRecords = (records = {}, options = {}) => {
|
|
|
252
256
|
if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
|
|
253
257
|
|
|
254
258
|
const existing = placements.get(id);
|
|
255
|
-
if (!existing) {
|
|
256
|
-
normalized[targetFolderId].push(normalizedRecord);
|
|
257
|
-
placements.set(id, {
|
|
258
|
-
folderId: targetFolderId,
|
|
259
|
-
index: normalized[targetFolderId].length - 1,
|
|
260
|
-
record: normalizedRecord
|
|
261
|
-
});
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const merged = mergeRecords(existing.record, normalizedRecord);
|
|
266
|
-
const preferred = pickPreferredRecord(existing.record, normalizedRecord);
|
|
267
|
-
const shouldMove = preferred === normalizedRecord && targetFolderId !== existing.folderId;
|
|
268
|
-
const samePriorityNewer = getRecordTime(normalizedRecord) > getRecordTime(existing.record);
|
|
269
|
-
|
|
270
|
-
if (shouldMove || samePriorityNewer) {
|
|
271
|
-
normalized[existing.folderId][existing.index] = null;
|
|
272
|
-
normalized[targetFolderId].push(merged);
|
|
273
|
-
placements.set(id, {
|
|
274
|
-
folderId: targetFolderId,
|
|
275
|
-
index: normalized[targetFolderId].length - 1,
|
|
276
|
-
record: merged
|
|
277
|
-
});
|
|
278
|
-
} else {
|
|
279
|
-
normalized[existing.folderId][existing.index] = merged;
|
|
280
|
-
placements.set(id, {
|
|
281
|
-
...existing,
|
|
282
|
-
record: merged
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
Object.keys(normalized).forEach((folderId) => {
|
|
289
|
-
normalized[folderId] = toArray(normalized[folderId]).filter(Boolean);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
return normalized;
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
const ensureFolders = (folders = []) => {
|
|
296
|
-
const seen = new Set();
|
|
297
|
-
const nextFolders = [];
|
|
298
|
-
toArray(folders).forEach((folder) => {
|
|
299
|
-
if (!folder || typeof folder !== 'object') return;
|
|
300
|
-
const id = normalizeId(folder.id);
|
|
301
|
-
if (!id || seen.has(id)) return;
|
|
302
|
-
seen.add(id);
|
|
303
|
-
nextFolders.push({ ...folder, id });
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
REQUIRED_FOLDERS.forEach((folder) => {
|
|
307
|
-
if (!seen.has(folder.id)) {
|
|
308
|
-
seen.add(folder.id);
|
|
309
|
-
nextFolders.unshift({ ...folder });
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
return nextFolders.sort((left, right) => {
|
|
314
|
-
const leftRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === left.id);
|
|
315
|
-
const rightRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === right.id);
|
|
316
|
-
if (leftRequired !== -1 || rightRequired !== -1) {
|
|
317
|
-
if (leftRequired === -1) return 1;
|
|
318
|
-
if (rightRequired === -1) return -1;
|
|
319
|
-
return leftRequired - rightRequired;
|
|
320
|
-
}
|
|
321
|
-
return 0;
|
|
322
|
-
});
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
const ensureRecordFolders = (records, folders) => {
|
|
326
|
-
const nextRecords = { ...records };
|
|
327
|
-
folders.forEach((folder) => {
|
|
328
|
-
if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
|
|
329
|
-
});
|
|
330
|
-
if (!nextRecords.all) nextRecords.all = [];
|
|
331
|
-
if (!nextRecords[DEFAULT_FOLDER_ID]) nextRecords[DEFAULT_FOLDER_ID] = [];
|
|
332
|
-
nextRecords.all = [];
|
|
333
|
-
return nextRecords;
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
const getFolderTime = (folder = {}) => Math.max(
|
|
337
|
-
toFiniteTimestamp(folder.updatedAt),
|
|
338
|
-
toFiniteTimestamp(folder.createdAt),
|
|
339
|
-
toFiniteTimestamp(folder.lastModified)
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombstones = {}) => {
|
|
343
|
-
const byId = new Map();
|
|
344
|
-
|
|
345
|
-
const visit = (folder, source) => {
|
|
346
|
-
if (!folder || typeof folder !== 'object') return;
|
|
347
|
-
const id = normalizeId(folder.id);
|
|
348
|
-
if (!id) return;
|
|
349
|
-
|
|
350
|
-
const tombstone = deletedFolderTombstones[id];
|
|
351
|
-
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const current = byId.get(id);
|
|
356
|
-
if (!current) {
|
|
357
|
-
byId.set(id, { ...folder, id });
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const currentTime = getFolderTime(current);
|
|
362
|
-
const nextTime = getFolderTime(folder);
|
|
363
|
-
if (nextTime > currentTime || (nextTime === currentTime && source === 'local')) {
|
|
364
|
-
byId.set(id, { ...current, ...folder, id });
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
toArray(remoteFolders).forEach((folder) => visit(folder, 'remote'));
|
|
369
|
-
toArray(localFolders).forEach((folder) => visit(folder, 'local'));
|
|
370
|
-
|
|
371
|
-
return Array.from(byId.values());
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
const mergeOrderWithLocalIds = (remoteOrder = [], localOrder = [], allowedIds = new Set()) => {
|
|
375
|
-
const merged = normalizeOrder(remoteOrder).filter((id) => allowedIds.has(id));
|
|
376
|
-
normalizeOrder(localOrder).forEach((id) => {
|
|
377
|
-
if (allowedIds.has(id) && !merged.includes(id)) {
|
|
378
|
-
merged.push(id);
|
|
379
|
-
}
|
|
380
|
-
});
|
|
381
|
-
return merged;
|
|
382
|
-
};
|
|
383
|
-
|
|
259
|
+
if (!existing) {
|
|
260
|
+
normalized[targetFolderId].push(normalizedRecord);
|
|
261
|
+
placements.set(id, {
|
|
262
|
+
folderId: targetFolderId,
|
|
263
|
+
index: normalized[targetFolderId].length - 1,
|
|
264
|
+
record: normalizedRecord
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const merged = mergeRecords(existing.record, normalizedRecord);
|
|
270
|
+
const preferred = pickPreferredRecord(existing.record, normalizedRecord);
|
|
271
|
+
const shouldMove = preferred === normalizedRecord && targetFolderId !== existing.folderId;
|
|
272
|
+
const samePriorityNewer = getRecordTime(normalizedRecord) > getRecordTime(existing.record);
|
|
273
|
+
|
|
274
|
+
if (shouldMove || samePriorityNewer) {
|
|
275
|
+
normalized[existing.folderId][existing.index] = null;
|
|
276
|
+
normalized[targetFolderId].push(merged);
|
|
277
|
+
placements.set(id, {
|
|
278
|
+
folderId: targetFolderId,
|
|
279
|
+
index: normalized[targetFolderId].length - 1,
|
|
280
|
+
record: merged
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
normalized[existing.folderId][existing.index] = merged;
|
|
284
|
+
placements.set(id, {
|
|
285
|
+
...existing,
|
|
286
|
+
record: merged
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
Object.keys(normalized).forEach((folderId) => {
|
|
293
|
+
normalized[folderId] = toArray(normalized[folderId]).filter(Boolean);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return normalized;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const ensureFolders = (folders = []) => {
|
|
300
|
+
const seen = new Set();
|
|
301
|
+
const nextFolders = [];
|
|
302
|
+
toArray(folders).forEach((folder) => {
|
|
303
|
+
if (!folder || typeof folder !== 'object') return;
|
|
304
|
+
const id = normalizeId(folder.id);
|
|
305
|
+
if (!id || seen.has(id)) return;
|
|
306
|
+
seen.add(id);
|
|
307
|
+
nextFolders.push({ ...folder, id });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
REQUIRED_FOLDERS.forEach((folder) => {
|
|
311
|
+
if (!seen.has(folder.id)) {
|
|
312
|
+
seen.add(folder.id);
|
|
313
|
+
nextFolders.unshift({ ...folder });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return nextFolders.sort((left, right) => {
|
|
318
|
+
const leftRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === left.id);
|
|
319
|
+
const rightRequired = REQUIRED_FOLDERS.findIndex((folder) => folder.id === right.id);
|
|
320
|
+
if (leftRequired !== -1 || rightRequired !== -1) {
|
|
321
|
+
if (leftRequired === -1) return 1;
|
|
322
|
+
if (rightRequired === -1) return -1;
|
|
323
|
+
return leftRequired - rightRequired;
|
|
324
|
+
}
|
|
325
|
+
return 0;
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const ensureRecordFolders = (records, folders) => {
|
|
330
|
+
const nextRecords = { ...records };
|
|
331
|
+
folders.forEach((folder) => {
|
|
332
|
+
if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
|
|
333
|
+
});
|
|
334
|
+
if (!nextRecords.all) nextRecords.all = [];
|
|
335
|
+
if (!nextRecords[DEFAULT_FOLDER_ID]) nextRecords[DEFAULT_FOLDER_ID] = [];
|
|
336
|
+
nextRecords.all = [];
|
|
337
|
+
return nextRecords;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const getFolderTime = (folder = {}) => Math.max(
|
|
341
|
+
toFiniteTimestamp(folder.updatedAt),
|
|
342
|
+
toFiniteTimestamp(folder.createdAt),
|
|
343
|
+
toFiniteTimestamp(folder.lastModified)
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const mergeFolders = (remoteFolders = [], localFolders = [], deletedFolderTombstones = {}) => {
|
|
347
|
+
const byId = new Map();
|
|
348
|
+
|
|
349
|
+
const visit = (folder, source) => {
|
|
350
|
+
if (!folder || typeof folder !== 'object') return;
|
|
351
|
+
const id = normalizeId(folder.id);
|
|
352
|
+
if (!id) return;
|
|
353
|
+
|
|
354
|
+
const tombstone = deletedFolderTombstones[id];
|
|
355
|
+
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= getFolderTime(folder)) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const current = byId.get(id);
|
|
360
|
+
if (!current) {
|
|
361
|
+
byId.set(id, { ...folder, id });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const currentTime = getFolderTime(current);
|
|
366
|
+
const nextTime = getFolderTime(folder);
|
|
367
|
+
if (nextTime > currentTime || (nextTime === currentTime && source === 'local')) {
|
|
368
|
+
byId.set(id, { ...current, ...folder, id });
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
toArray(remoteFolders).forEach((folder) => visit(folder, 'remote'));
|
|
373
|
+
toArray(localFolders).forEach((folder) => visit(folder, 'local'));
|
|
374
|
+
|
|
375
|
+
return Array.from(byId.values());
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const mergeOrderWithLocalIds = (remoteOrder = [], localOrder = [], allowedIds = new Set()) => {
|
|
379
|
+
const merged = normalizeOrder(remoteOrder).filter((id) => allowedIds.has(id));
|
|
380
|
+
normalizeOrder(localOrder).forEach((id) => {
|
|
381
|
+
if (allowedIds.has(id) && !merged.includes(id)) {
|
|
382
|
+
merged.push(id);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
return merged;
|
|
386
|
+
};
|
|
387
|
+
|
|
384
388
|
export const normalizeState = (input = {}) => {
|
|
385
389
|
const deletedRecordTombstones = normalizeTombstones(input.deletedRecordTombstones);
|
|
386
390
|
const deletedFolderTombstones = normalizeTombstones(input.deletedFolderTombstones);
|
|
387
391
|
const folders = ensureFolders(input.folders).filter((folder) => {
|
|
388
|
-
const tombstone = deletedFolderTombstones[folder.id];
|
|
389
|
-
if (!tombstone) return true;
|
|
390
|
-
const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
|
|
391
|
-
return folderTime > toFiniteTimestamp(tombstone.deletedAt);
|
|
392
|
-
});
|
|
392
|
+
const tombstone = deletedFolderTombstones[folder.id];
|
|
393
|
+
if (!tombstone) return true;
|
|
394
|
+
const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt);
|
|
395
|
+
return folderTime > toFiniteTimestamp(tombstone.deletedAt);
|
|
396
|
+
});
|
|
393
397
|
const records = ensureRecordFolders(
|
|
394
398
|
normalizeRecords(input.records, { deletedRecordTombstones, deletedFolderTombstones }),
|
|
395
399
|
folders
|
|
396
400
|
);
|
|
397
|
-
|
|
398
|
-
return {
|
|
399
|
-
folders,
|
|
400
|
-
records,
|
|
401
|
-
groupOrder: normalizeOrder(input.groupOrder),
|
|
402
|
-
folderOrder: normalizeOrder(input.folderOrder),
|
|
403
|
-
expandedGroups: normalizeOrder(input.expandedGroups),
|
|
404
|
-
settings: input.settings && typeof input.settings === 'object' ? { ...input.settings } : {},
|
|
405
|
-
rtlSyncMeta: {
|
|
406
|
-
schemaVersion: 1,
|
|
407
|
-
...(input.rtlSyncMeta && typeof input.rtlSyncMeta === 'object' ? input.rtlSyncMeta : {})
|
|
408
|
-
},
|
|
409
|
-
deletedRecordTombstones,
|
|
410
|
-
deletedFolderTombstones,
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
folders,
|
|
404
|
+
records,
|
|
405
|
+
groupOrder: normalizeOrder(input.groupOrder),
|
|
406
|
+
folderOrder: normalizeOrder(input.folderOrder),
|
|
407
|
+
expandedGroups: normalizeOrder(input.expandedGroups),
|
|
408
|
+
settings: input.settings && typeof input.settings === 'object' ? { ...input.settings } : {},
|
|
409
|
+
rtlSyncMeta: {
|
|
410
|
+
schemaVersion: 1,
|
|
411
|
+
...(input.rtlSyncMeta && typeof input.rtlSyncMeta === 'object' ? input.rtlSyncMeta : {})
|
|
412
|
+
},
|
|
413
|
+
deletedRecordTombstones,
|
|
414
|
+
deletedFolderTombstones,
|
|
411
415
|
lastModified: toFiniteTimestamp(input.lastModified || input.updatedAt || input.lastUpdated)
|
|
412
416
|
};
|
|
413
417
|
};
|
|
@@ -463,20 +467,20 @@ export const buildMigratedRecordTimeLabelV2State = (legacyState = {}, options =
|
|
|
463
467
|
};
|
|
464
468
|
|
|
465
469
|
const removeRecordById = (records, recordId) => {
|
|
466
|
-
const nextRecords = {};
|
|
467
|
-
Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
|
|
468
|
-
nextRecords[folderId] = toArray(folderRecords).filter((record) => record?.id !== recordId);
|
|
469
|
-
});
|
|
470
|
-
return nextRecords;
|
|
471
|
-
};
|
|
472
|
-
|
|
470
|
+
const nextRecords = {};
|
|
471
|
+
Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
|
|
472
|
+
nextRecords[folderId] = toArray(folderRecords).filter((record) => record?.id !== recordId);
|
|
473
|
+
});
|
|
474
|
+
return nextRecords;
|
|
475
|
+
};
|
|
476
|
+
|
|
473
477
|
const findRecordEntry = (records, recordId) => {
|
|
474
478
|
for (const [folderId, folderRecords] of Object.entries(records || {})) {
|
|
475
479
|
const index = toArray(folderRecords).findIndex((record) => record?.id === recordId);
|
|
476
|
-
if (index !== -1) {
|
|
477
|
-
return { folderId, index, record: folderRecords[index] };
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
+
if (index !== -1) {
|
|
481
|
+
return { folderId, index, record: folderRecords[index] };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
480
484
|
return null;
|
|
481
485
|
};
|
|
482
486
|
|
|
@@ -520,7 +524,7 @@ export const preserveRecordOrderFromLocal = (records = {}, localRecords = {}) =>
|
|
|
520
524
|
});
|
|
521
525
|
return result;
|
|
522
526
|
};
|
|
523
|
-
|
|
527
|
+
|
|
524
528
|
export const createOperation = (type, payload = {}, options = {}) => ({
|
|
525
529
|
id: options.id || `${type}:${options.clientId || 'client'}:${options.now || Date.now()}:${Math.random().toString(36).slice(2, 10)}`,
|
|
526
530
|
type,
|
|
@@ -572,74 +576,74 @@ const buildOperationSyncMeta = (previousSyncMeta = {}, operation = {}, payload =
|
|
|
572
576
|
};
|
|
573
577
|
|
|
574
578
|
export const applyOperation = (state = {}, operation = {}) => {
|
|
575
|
-
const normalized = normalizeState(state);
|
|
576
|
-
const type = operation.type;
|
|
577
|
-
const payload = operation.payload || {};
|
|
578
|
-
const operationTime = toFiniteTimestamp(operation.createdAt || payload.updatedAt || Date.now()) || Date.now();
|
|
579
|
-
|
|
580
|
-
const nextState = clone(normalized);
|
|
581
|
-
|
|
582
|
-
switch (type) {
|
|
583
|
-
case OPERATION_TYPES.RECORD_CREATE: {
|
|
584
|
-
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
585
|
-
if (!record) return normalized;
|
|
586
|
-
const recordId = normalizeId(record.id || payload.recordId);
|
|
587
|
-
if (!recordId) return normalized;
|
|
588
|
-
const tombstone = nextState.deletedRecordTombstones[recordId];
|
|
589
|
-
const recordTime = getRecordTime(record) || operationTime;
|
|
590
|
-
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
|
|
591
|
-
return normalized;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const folderId = safeFolderId(payload.folderId || record.folderId);
|
|
595
|
-
const cleanedRecord = {
|
|
596
|
-
...record,
|
|
597
|
-
id: recordId,
|
|
598
|
-
updatedAt: record.updatedAt || operationTime
|
|
599
|
-
};
|
|
600
|
-
delete cleanedRecord.folderId;
|
|
601
|
-
|
|
602
|
-
nextState.records = removeRecordById(nextState.records, recordId);
|
|
603
|
-
nextState.records[folderId] = [
|
|
604
|
-
cleanedRecord,
|
|
605
|
-
...toArray(nextState.records[folderId])
|
|
606
|
-
];
|
|
607
|
-
break;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
611
|
-
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
612
|
-
if (!recordId) return normalized;
|
|
613
|
-
const entry = findRecordEntry(nextState.records, recordId);
|
|
614
|
-
if (!entry) return normalized;
|
|
615
|
-
const patch = payload.patch || payload.record || {};
|
|
616
|
-
const updatedRecord = mergeRecords(entry.record, {
|
|
617
|
-
...entry.record,
|
|
618
|
-
...patch,
|
|
619
|
-
id: recordId,
|
|
620
|
-
updatedAt: patch.updatedAt || operationTime
|
|
621
|
-
});
|
|
622
|
-
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
623
|
-
break;
|
|
624
|
-
}
|
|
625
|
-
|
|
579
|
+
const normalized = normalizeState(state);
|
|
580
|
+
const type = operation.type;
|
|
581
|
+
const payload = operation.payload || {};
|
|
582
|
+
const operationTime = toFiniteTimestamp(operation.createdAt || payload.updatedAt || Date.now()) || Date.now();
|
|
583
|
+
|
|
584
|
+
const nextState = clone(normalized);
|
|
585
|
+
|
|
586
|
+
switch (type) {
|
|
587
|
+
case OPERATION_TYPES.RECORD_CREATE: {
|
|
588
|
+
const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
|
|
589
|
+
if (!record) return normalized;
|
|
590
|
+
const recordId = normalizeId(record.id || payload.recordId);
|
|
591
|
+
if (!recordId) return normalized;
|
|
592
|
+
const tombstone = nextState.deletedRecordTombstones[recordId];
|
|
593
|
+
const recordTime = getRecordTime(record) || operationTime;
|
|
594
|
+
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) {
|
|
595
|
+
return normalized;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const folderId = safeFolderId(payload.folderId || record.folderId);
|
|
599
|
+
const cleanedRecord = {
|
|
600
|
+
...record,
|
|
601
|
+
id: recordId,
|
|
602
|
+
updatedAt: record.updatedAt || operationTime
|
|
603
|
+
};
|
|
604
|
+
delete cleanedRecord.folderId;
|
|
605
|
+
|
|
606
|
+
nextState.records = removeRecordById(nextState.records, recordId);
|
|
607
|
+
nextState.records[folderId] = [
|
|
608
|
+
cleanedRecord,
|
|
609
|
+
...toArray(nextState.records[folderId])
|
|
610
|
+
];
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
615
|
+
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
616
|
+
if (!recordId) return normalized;
|
|
617
|
+
const entry = findRecordEntry(nextState.records, recordId);
|
|
618
|
+
if (!entry) return normalized;
|
|
619
|
+
const patch = payload.patch || payload.record || {};
|
|
620
|
+
const updatedRecord = mergeRecords(entry.record, {
|
|
621
|
+
...entry.record,
|
|
622
|
+
...patch,
|
|
623
|
+
id: recordId,
|
|
624
|
+
updatedAt: patch.updatedAt || operationTime
|
|
625
|
+
});
|
|
626
|
+
nextState.records[entry.folderId][entry.index] = updatedRecord;
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
|
|
626
630
|
case OPERATION_TYPES.RECORD_MOVE: {
|
|
627
631
|
const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
|
|
628
632
|
const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
|
|
629
|
-
if (!recordId) return normalized;
|
|
630
|
-
const entry = findRecordEntry(nextState.records, recordId);
|
|
631
|
-
if (!entry) return normalized;
|
|
632
|
-
const movedRecord = {
|
|
633
|
-
...entry.record,
|
|
634
|
-
...(payload.record || {}),
|
|
635
|
-
id: recordId,
|
|
636
|
-
updatedAt: payload.updatedAt || operationTime
|
|
637
|
-
};
|
|
638
|
-
nextState.records = removeRecordById(nextState.records, recordId);
|
|
639
|
-
nextState.records[targetFolderId] = [
|
|
640
|
-
...toArray(nextState.records[targetFolderId]),
|
|
641
|
-
movedRecord
|
|
642
|
-
];
|
|
633
|
+
if (!recordId) return normalized;
|
|
634
|
+
const entry = findRecordEntry(nextState.records, recordId);
|
|
635
|
+
if (!entry) return normalized;
|
|
636
|
+
const movedRecord = {
|
|
637
|
+
...entry.record,
|
|
638
|
+
...(payload.record || {}),
|
|
639
|
+
id: recordId,
|
|
640
|
+
updatedAt: payload.updatedAt || operationTime
|
|
641
|
+
};
|
|
642
|
+
nextState.records = removeRecordById(nextState.records, recordId);
|
|
643
|
+
nextState.records[targetFolderId] = [
|
|
644
|
+
...toArray(nextState.records[targetFolderId]),
|
|
645
|
+
movedRecord
|
|
646
|
+
];
|
|
643
647
|
break;
|
|
644
648
|
}
|
|
645
649
|
|
|
@@ -690,71 +694,71 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
690
694
|
nextState.records[folderId] = reorderedRecords;
|
|
691
695
|
break;
|
|
692
696
|
}
|
|
693
|
-
|
|
694
|
-
case OPERATION_TYPES.RECORD_DELETE: {
|
|
695
|
-
const recordId = normalizeId(payload.recordId || payload.id);
|
|
696
|
-
if (!recordId) return normalized;
|
|
697
|
-
nextState.records = removeRecordById(nextState.records, recordId);
|
|
698
|
-
nextState.deletedRecordTombstones[recordId] = {
|
|
699
|
-
id: recordId,
|
|
700
|
-
deletedAt: payload.deletedAt || operationTime,
|
|
701
|
-
clientId: operation.clientId || payload.clientId || null,
|
|
702
|
-
operationId: operation.id || null
|
|
703
|
-
};
|
|
704
|
-
break;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
case OPERATION_TYPES.FOLDER_CREATE: {
|
|
708
|
-
const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
|
|
709
|
-
const folderId = normalizeId(folder?.id || payload.folderId);
|
|
710
|
-
if (!folderId) return normalized;
|
|
711
|
-
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
712
|
-
const nextFolder = {
|
|
713
|
-
...(folder || {}),
|
|
714
|
-
id: folderId,
|
|
715
|
-
updatedAt: folder?.updatedAt || operationTime
|
|
716
|
-
};
|
|
717
|
-
if (existingIndex === -1) {
|
|
718
|
-
nextState.folders.push(nextFolder);
|
|
719
|
-
} else {
|
|
720
|
-
nextState.folders[existingIndex] = { ...nextState.folders[existingIndex], ...nextFolder };
|
|
721
|
-
}
|
|
722
|
-
if (!nextState.records[folderId]) nextState.records[folderId] = [];
|
|
723
|
-
break;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
727
|
-
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
728
|
-
if (!folderId) return normalized;
|
|
729
|
-
const patch = payload.patch || payload.folder || {};
|
|
730
|
-
nextState.folders = nextState.folders.map((folder) => (
|
|
731
|
-
folder.id === folderId
|
|
732
|
-
? { ...folder, ...patch, id: folderId, updatedAt: patch.updatedAt || operationTime }
|
|
733
|
-
: folder
|
|
734
|
-
));
|
|
735
|
-
break;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
739
|
-
const folderId = normalizeId(payload.folderId || payload.id);
|
|
740
|
-
if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
|
|
741
|
-
nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
|
|
742
|
-
delete nextState.records[folderId];
|
|
743
|
-
nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
|
|
744
|
-
nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
|
|
745
|
-
nextState.deletedFolderTombstones[folderId] = {
|
|
746
|
-
id: folderId,
|
|
747
|
-
deletedAt: payload.deletedAt || operationTime,
|
|
748
|
-
clientId: operation.clientId || payload.clientId || null,
|
|
749
|
-
operationId: operation.id || null
|
|
750
|
-
};
|
|
751
|
-
break;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
case OPERATION_TYPES.FOLDER_REORDER:
|
|
755
|
-
nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
|
|
756
|
-
break;
|
|
757
|
-
|
|
697
|
+
|
|
698
|
+
case OPERATION_TYPES.RECORD_DELETE: {
|
|
699
|
+
const recordId = normalizeId(payload.recordId || payload.id);
|
|
700
|
+
if (!recordId) return normalized;
|
|
701
|
+
nextState.records = removeRecordById(nextState.records, recordId);
|
|
702
|
+
nextState.deletedRecordTombstones[recordId] = {
|
|
703
|
+
id: recordId,
|
|
704
|
+
deletedAt: payload.deletedAt || operationTime,
|
|
705
|
+
clientId: operation.clientId || payload.clientId || null,
|
|
706
|
+
operationId: operation.id || null
|
|
707
|
+
};
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
case OPERATION_TYPES.FOLDER_CREATE: {
|
|
712
|
+
const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
|
|
713
|
+
const folderId = normalizeId(folder?.id || payload.folderId);
|
|
714
|
+
if (!folderId) return normalized;
|
|
715
|
+
const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
|
|
716
|
+
const nextFolder = {
|
|
717
|
+
...(folder || {}),
|
|
718
|
+
id: folderId,
|
|
719
|
+
updatedAt: folder?.updatedAt || operationTime
|
|
720
|
+
};
|
|
721
|
+
if (existingIndex === -1) {
|
|
722
|
+
nextState.folders.push(nextFolder);
|
|
723
|
+
} else {
|
|
724
|
+
nextState.folders[existingIndex] = { ...nextState.folders[existingIndex], ...nextFolder };
|
|
725
|
+
}
|
|
726
|
+
if (!nextState.records[folderId]) nextState.records[folderId] = [];
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
731
|
+
const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
|
|
732
|
+
if (!folderId) return normalized;
|
|
733
|
+
const patch = payload.patch || payload.folder || {};
|
|
734
|
+
nextState.folders = nextState.folders.map((folder) => (
|
|
735
|
+
folder.id === folderId
|
|
736
|
+
? { ...folder, ...patch, id: folderId, updatedAt: patch.updatedAt || operationTime }
|
|
737
|
+
: folder
|
|
738
|
+
));
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
743
|
+
const folderId = normalizeId(payload.folderId || payload.id);
|
|
744
|
+
if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
|
|
745
|
+
nextState.folders = nextState.folders.filter((folder) => folder.id !== folderId);
|
|
746
|
+
delete nextState.records[folderId];
|
|
747
|
+
nextState.folderOrder = nextState.folderOrder.filter((id) => id !== folderId);
|
|
748
|
+
nextState.groupOrder = nextState.groupOrder.filter((id) => id !== folderId);
|
|
749
|
+
nextState.deletedFolderTombstones[folderId] = {
|
|
750
|
+
id: folderId,
|
|
751
|
+
deletedAt: payload.deletedAt || operationTime,
|
|
752
|
+
clientId: operation.clientId || payload.clientId || null,
|
|
753
|
+
operationId: operation.id || null
|
|
754
|
+
};
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
case OPERATION_TYPES.FOLDER_REORDER:
|
|
759
|
+
nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
|
|
760
|
+
break;
|
|
761
|
+
|
|
758
762
|
case OPERATION_TYPES.GROUP_REORDER:
|
|
759
763
|
nextState.groupOrder = normalizeOrder(payload.groupOrder || payload.order);
|
|
760
764
|
break;
|
|
@@ -767,11 +771,11 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
767
771
|
nextState.settings = {
|
|
768
772
|
...nextState.settings,
|
|
769
773
|
...(payload.patch || payload.settings || {})
|
|
770
|
-
};
|
|
771
|
-
break;
|
|
772
|
-
|
|
773
|
-
default:
|
|
774
|
-
return normalized;
|
|
774
|
+
};
|
|
775
|
+
break;
|
|
776
|
+
|
|
777
|
+
default:
|
|
778
|
+
return normalized;
|
|
775
779
|
}
|
|
776
780
|
|
|
777
781
|
nextState.lastModified = Math.max(nextState.lastModified || 0, operationTime);
|
|
@@ -786,157 +790,157 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
786
790
|
};
|
|
787
791
|
|
|
788
792
|
export const applyRecordTimeLabelOperation = (state = {}, operation = {}) => applyOperation(state, operation);
|
|
789
|
-
|
|
790
|
-
export const mergeLocalRemote = ({
|
|
791
|
-
localState = {},
|
|
792
|
-
remoteState = {},
|
|
793
|
-
pendingOps = [],
|
|
794
|
-
clientId = null,
|
|
795
|
-
now = Date.now()
|
|
796
|
-
} = {}) => {
|
|
797
|
-
const local = normalizeState(localState);
|
|
798
|
-
const remote = normalizeState(remoteState);
|
|
799
|
-
const deletedRecordTombstones = mergeTombstones(
|
|
800
|
-
remote.deletedRecordTombstones,
|
|
801
|
-
local.deletedRecordTombstones
|
|
802
|
-
);
|
|
803
|
-
const deletedFolderTombstones = mergeTombstones(
|
|
804
|
-
remote.deletedFolderTombstones,
|
|
805
|
-
local.deletedFolderTombstones
|
|
806
|
-
);
|
|
807
|
-
const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
|
|
808
|
-
const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
|
|
809
|
-
|
|
810
|
-
let mergedState = normalizeState({
|
|
811
|
-
...remote,
|
|
793
|
+
|
|
794
|
+
export const mergeLocalRemote = ({
|
|
795
|
+
localState = {},
|
|
796
|
+
remoteState = {},
|
|
797
|
+
pendingOps = [],
|
|
798
|
+
clientId = null,
|
|
799
|
+
now = Date.now()
|
|
800
|
+
} = {}) => {
|
|
801
|
+
const local = normalizeState(localState);
|
|
802
|
+
const remote = normalizeState(remoteState);
|
|
803
|
+
const deletedRecordTombstones = mergeTombstones(
|
|
804
|
+
remote.deletedRecordTombstones,
|
|
805
|
+
local.deletedRecordTombstones
|
|
806
|
+
);
|
|
807
|
+
const deletedFolderTombstones = mergeTombstones(
|
|
808
|
+
remote.deletedFolderTombstones,
|
|
809
|
+
local.deletedFolderTombstones
|
|
810
|
+
);
|
|
811
|
+
const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
|
|
812
|
+
const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
|
|
813
|
+
|
|
814
|
+
let mergedState = normalizeState({
|
|
815
|
+
...remote,
|
|
812
816
|
folders,
|
|
813
817
|
records: { ...remote.records },
|
|
814
818
|
groupOrder: mergeOrderArrays(null, remote.groupOrder, local.groupOrder),
|
|
815
819
|
folderOrder: mergeOrderWithLocalIds(remote.folderOrder, local.folderOrder, folderIds),
|
|
816
820
|
expandedGroups: mergeOrderArrays(null, remote.expandedGroups, local.expandedGroups),
|
|
817
821
|
settings: { ...remote.settings, ...local.settings },
|
|
818
|
-
deletedRecordTombstones,
|
|
819
|
-
deletedFolderTombstones,
|
|
820
|
-
rtlSyncMeta: {
|
|
821
|
-
...remote.rtlSyncMeta,
|
|
822
|
-
...local.rtlSyncMeta,
|
|
823
|
-
lastMergedAt: now,
|
|
824
|
-
lastMergedClientId: clientId
|
|
825
|
-
},
|
|
826
|
-
lastModified: Math.max(remote.lastModified || 0, local.lastModified || 0)
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
Object.entries(local.records || {}).forEach(([folderId, folderRecords]) => {
|
|
830
|
-
toArray(folderRecords).forEach((localRecord) => {
|
|
831
|
-
if (!localRecord?.id) return;
|
|
832
|
-
const tombstone = deletedRecordTombstones[localRecord.id];
|
|
833
|
-
const recordTime = getRecordTime(localRecord);
|
|
834
|
-
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) return;
|
|
835
|
-
|
|
836
|
-
const remoteEntry = findRecordEntry(mergedState.records, localRecord.id);
|
|
837
|
-
const shouldKeepLocal = localRecord.pendingSync === true ||
|
|
838
|
-
recordTime > getRecordTime(remoteEntry?.record || {});
|
|
839
|
-
if (!shouldKeepLocal) return;
|
|
840
|
-
|
|
841
|
-
const targetFolderId = safeFolderId(folderId);
|
|
842
|
-
const mergedRecord = remoteEntry
|
|
843
|
-
? mergeRecords(remoteEntry.record, localRecord)
|
|
844
|
-
: { ...localRecord };
|
|
845
|
-
mergedState.records = removeRecordById(mergedState.records, localRecord.id);
|
|
846
|
-
mergedState.records[targetFolderId] = [
|
|
847
|
-
mergedRecord,
|
|
848
|
-
...toArray(mergedState.records[targetFolderId])
|
|
849
|
-
];
|
|
850
|
-
});
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
toArray(pendingOps).forEach((operation) => {
|
|
854
|
-
mergedState = applyOperation(mergedState, operation);
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
return {
|
|
858
|
-
state: normalizeState(mergedState),
|
|
859
|
-
pendingOps: toArray(pendingOps).map((operation) => ({ ...operation })),
|
|
860
|
-
hasPendingOps: toArray(pendingOps).length > 0,
|
|
861
|
-
mergedAt: now
|
|
862
|
-
};
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
const flattenRecordsById = (records = {}) => {
|
|
866
|
-
const result = new Map();
|
|
867
|
-
Object.entries(normalizeRecords(records || {})).forEach(([folderId, recordList]) => {
|
|
868
|
-
toArray(recordList).forEach((record) => {
|
|
869
|
-
if (!record?.id) return;
|
|
870
|
-
result.set(record.id, { folderId, record });
|
|
871
|
-
});
|
|
872
|
-
});
|
|
873
|
-
return result;
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
export const buildDeletionTombstones = ({
|
|
877
|
-
previousState,
|
|
878
|
-
nextRecords,
|
|
879
|
-
nextFolders,
|
|
880
|
-
existingRecordTombstones,
|
|
881
|
-
existingFolderTombstones,
|
|
882
|
-
deletedAt,
|
|
883
|
-
clientId = null
|
|
884
|
-
} = {}) => {
|
|
885
|
-
const deletionTime = toFiniteTimestamp(deletedAt) || Date.now();
|
|
886
|
-
const previous = normalizeState(previousState || {});
|
|
887
|
-
const previousRecords = flattenRecordsById(previous.records || {});
|
|
888
|
-
const nextRecordsById = flattenRecordsById(nextRecords || {});
|
|
889
|
-
const deletedRecordTombstones = mergeTombstones(
|
|
890
|
-
previous.deletedRecordTombstones || {},
|
|
891
|
-
normalizeTombstones(existingRecordTombstones || {})
|
|
892
|
-
);
|
|
893
|
-
|
|
894
|
-
previousRecords.forEach(({ folderId }, recordId) => {
|
|
895
|
-
if (nextRecordsById.has(recordId)) return;
|
|
896
|
-
const current = deletedRecordTombstones[recordId];
|
|
897
|
-
if (current && toFiniteTimestamp(current.deletedAt) >= deletionTime) return;
|
|
898
|
-
deletedRecordTombstones[recordId] = {
|
|
899
|
-
id: recordId,
|
|
900
|
-
folderId,
|
|
901
|
-
deletedAt: deletionTime,
|
|
902
|
-
clientId
|
|
903
|
-
};
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
const nextFolderIds = new Set(toArray(nextFolders)
|
|
907
|
-
.map((folder) => folder?.id)
|
|
908
|
-
.filter(Boolean));
|
|
909
|
-
const deletedFolderTombstones = mergeTombstones(
|
|
910
|
-
previous.deletedFolderTombstones || {},
|
|
911
|
-
normalizeTombstones(existingFolderTombstones || {})
|
|
912
|
-
);
|
|
913
|
-
|
|
914
|
-
toArray(previous.folders).forEach((folder) => {
|
|
915
|
-
if (!folder?.id || folder.id === 'all' || folder.id === DEFAULT_FOLDER_ID || nextFolderIds.has(folder.id)) return;
|
|
916
|
-
const current = deletedFolderTombstones[folder.id];
|
|
917
|
-
if (current && toFiniteTimestamp(current.deletedAt) >= deletionTime) return;
|
|
918
|
-
deletedFolderTombstones[folder.id] = {
|
|
919
|
-
id: folder.id,
|
|
920
|
-
deletedAt: deletionTime,
|
|
921
|
-
clientId
|
|
922
|
-
};
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
return {
|
|
926
|
-
deletedRecordTombstones,
|
|
927
|
-
deletedFolderTombstones
|
|
928
|
-
};
|
|
929
|
-
};
|
|
930
|
-
|
|
822
|
+
deletedRecordTombstones,
|
|
823
|
+
deletedFolderTombstones,
|
|
824
|
+
rtlSyncMeta: {
|
|
825
|
+
...remote.rtlSyncMeta,
|
|
826
|
+
...local.rtlSyncMeta,
|
|
827
|
+
lastMergedAt: now,
|
|
828
|
+
lastMergedClientId: clientId
|
|
829
|
+
},
|
|
830
|
+
lastModified: Math.max(remote.lastModified || 0, local.lastModified || 0)
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
Object.entries(local.records || {}).forEach(([folderId, folderRecords]) => {
|
|
834
|
+
toArray(folderRecords).forEach((localRecord) => {
|
|
835
|
+
if (!localRecord?.id) return;
|
|
836
|
+
const tombstone = deletedRecordTombstones[localRecord.id];
|
|
837
|
+
const recordTime = getRecordTime(localRecord);
|
|
838
|
+
if (tombstone && toFiniteTimestamp(tombstone.deletedAt) >= recordTime) return;
|
|
839
|
+
|
|
840
|
+
const remoteEntry = findRecordEntry(mergedState.records, localRecord.id);
|
|
841
|
+
const shouldKeepLocal = localRecord.pendingSync === true ||
|
|
842
|
+
recordTime > getRecordTime(remoteEntry?.record || {});
|
|
843
|
+
if (!shouldKeepLocal) return;
|
|
844
|
+
|
|
845
|
+
const targetFolderId = safeFolderId(folderId);
|
|
846
|
+
const mergedRecord = remoteEntry
|
|
847
|
+
? mergeRecords(remoteEntry.record, localRecord)
|
|
848
|
+
: { ...localRecord };
|
|
849
|
+
mergedState.records = removeRecordById(mergedState.records, localRecord.id);
|
|
850
|
+
mergedState.records[targetFolderId] = [
|
|
851
|
+
mergedRecord,
|
|
852
|
+
...toArray(mergedState.records[targetFolderId])
|
|
853
|
+
];
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
toArray(pendingOps).forEach((operation) => {
|
|
858
|
+
mergedState = applyOperation(mergedState, operation);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
return {
|
|
862
|
+
state: normalizeState(mergedState),
|
|
863
|
+
pendingOps: toArray(pendingOps).map((operation) => ({ ...operation })),
|
|
864
|
+
hasPendingOps: toArray(pendingOps).length > 0,
|
|
865
|
+
mergedAt: now
|
|
866
|
+
};
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const flattenRecordsById = (records = {}) => {
|
|
870
|
+
const result = new Map();
|
|
871
|
+
Object.entries(normalizeRecords(records || {})).forEach(([folderId, recordList]) => {
|
|
872
|
+
toArray(recordList).forEach((record) => {
|
|
873
|
+
if (!record?.id) return;
|
|
874
|
+
result.set(record.id, { folderId, record });
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
return result;
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
export const buildDeletionTombstones = ({
|
|
881
|
+
previousState,
|
|
882
|
+
nextRecords,
|
|
883
|
+
nextFolders,
|
|
884
|
+
existingRecordTombstones,
|
|
885
|
+
existingFolderTombstones,
|
|
886
|
+
deletedAt,
|
|
887
|
+
clientId = null
|
|
888
|
+
} = {}) => {
|
|
889
|
+
const deletionTime = toFiniteTimestamp(deletedAt) || Date.now();
|
|
890
|
+
const previous = normalizeState(previousState || {});
|
|
891
|
+
const previousRecords = flattenRecordsById(previous.records || {});
|
|
892
|
+
const nextRecordsById = flattenRecordsById(nextRecords || {});
|
|
893
|
+
const deletedRecordTombstones = mergeTombstones(
|
|
894
|
+
previous.deletedRecordTombstones || {},
|
|
895
|
+
normalizeTombstones(existingRecordTombstones || {})
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
previousRecords.forEach(({ folderId }, recordId) => {
|
|
899
|
+
if (nextRecordsById.has(recordId)) return;
|
|
900
|
+
const current = deletedRecordTombstones[recordId];
|
|
901
|
+
if (current && toFiniteTimestamp(current.deletedAt) >= deletionTime) return;
|
|
902
|
+
deletedRecordTombstones[recordId] = {
|
|
903
|
+
id: recordId,
|
|
904
|
+
folderId,
|
|
905
|
+
deletedAt: deletionTime,
|
|
906
|
+
clientId
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
const nextFolderIds = new Set(toArray(nextFolders)
|
|
911
|
+
.map((folder) => folder?.id)
|
|
912
|
+
.filter(Boolean));
|
|
913
|
+
const deletedFolderTombstones = mergeTombstones(
|
|
914
|
+
previous.deletedFolderTombstones || {},
|
|
915
|
+
normalizeTombstones(existingFolderTombstones || {})
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
toArray(previous.folders).forEach((folder) => {
|
|
919
|
+
if (!folder?.id || folder.id === 'all' || folder.id === DEFAULT_FOLDER_ID || nextFolderIds.has(folder.id)) return;
|
|
920
|
+
const current = deletedFolderTombstones[folder.id];
|
|
921
|
+
if (current && toFiniteTimestamp(current.deletedAt) >= deletionTime) return;
|
|
922
|
+
deletedFolderTombstones[folder.id] = {
|
|
923
|
+
id: folder.id,
|
|
924
|
+
deletedAt: deletionTime,
|
|
925
|
+
clientId
|
|
926
|
+
};
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
deletedRecordTombstones,
|
|
931
|
+
deletedFolderTombstones
|
|
932
|
+
};
|
|
933
|
+
};
|
|
934
|
+
|
|
931
935
|
const removeRecordSyncFlags = (records = {}) => Object.fromEntries(
|
|
932
936
|
Object.entries(normalizeRecords(records || {})).map(([folderId, recordList]) => [
|
|
933
|
-
folderId,
|
|
934
|
-
toArray(recordList).map((record) => {
|
|
935
|
-
const cleanRecord = { ...record };
|
|
936
|
-
delete cleanRecord.pendingSync;
|
|
937
|
-
delete cleanRecord.syncAttempts;
|
|
938
|
-
return cleanRecord;
|
|
939
|
-
})
|
|
937
|
+
folderId,
|
|
938
|
+
toArray(recordList).map((record) => {
|
|
939
|
+
const cleanRecord = { ...record };
|
|
940
|
+
delete cleanRecord.pendingSync;
|
|
941
|
+
delete cleanRecord.syncAttempts;
|
|
942
|
+
return cleanRecord;
|
|
943
|
+
})
|
|
940
944
|
])
|
|
941
945
|
);
|
|
942
946
|
|
|
@@ -1610,14 +1614,14 @@ const buildSyncStateInput = (data = {}, operationTime, options = {}) => ({
|
|
|
1610
1614
|
folderOrder: data?.folderOrder || [],
|
|
1611
1615
|
expandedGroups: data?.expandedGroups || [],
|
|
1612
1616
|
rtlSyncMeta: data?.rtlSyncMeta || {},
|
|
1613
|
-
deletedRecordTombstones: data?.deletedRecordTombstones || {},
|
|
1614
|
-
deletedFolderTombstones: data?.deletedFolderTombstones || {},
|
|
1615
|
-
lastModified: operationTime
|
|
1616
|
-
});
|
|
1617
|
-
|
|
1617
|
+
deletedRecordTombstones: data?.deletedRecordTombstones || {},
|
|
1618
|
+
deletedFolderTombstones: data?.deletedFolderTombstones || {},
|
|
1619
|
+
lastModified: operationTime
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1618
1622
|
export const buildRecordTimeLabelSyncPayload = ({
|
|
1619
|
-
data,
|
|
1620
|
-
previousCloudData,
|
|
1623
|
+
data,
|
|
1624
|
+
previousCloudData,
|
|
1621
1625
|
pendingOps = [],
|
|
1622
1626
|
syncMode = RECORD_TIMELABEL_SYNC_MODES.FULL,
|
|
1623
1627
|
clientId = 'recordtimelabel-client',
|
|
@@ -1636,23 +1640,23 @@ export const buildRecordTimeLabelSyncPayload = ({
|
|
|
1636
1640
|
: buildSyncStateInput(data || {}, operationTime, { groupOrderNormalizer });
|
|
1637
1641
|
const sanitizedRecords = removeRecordSyncFlags(payloadSource.records || {});
|
|
1638
1642
|
const deletionTombstones = buildDeletionTombstones({
|
|
1639
|
-
previousState: previousCloudData,
|
|
1640
|
-
nextRecords: sanitizedRecords,
|
|
1641
|
-
nextFolders: payloadSource.folders || [],
|
|
1642
|
-
existingRecordTombstones: payloadSource.deletedRecordTombstones || {},
|
|
1643
|
-
existingFolderTombstones: payloadSource.deletedFolderTombstones || {},
|
|
1644
|
-
deletedAt: operationTime,
|
|
1645
|
-
clientId
|
|
1646
|
-
});
|
|
1647
|
-
|
|
1643
|
+
previousState: previousCloudData,
|
|
1644
|
+
nextRecords: sanitizedRecords,
|
|
1645
|
+
nextFolders: payloadSource.folders || [],
|
|
1646
|
+
existingRecordTombstones: payloadSource.deletedRecordTombstones || {},
|
|
1647
|
+
existingFolderTombstones: payloadSource.deletedFolderTombstones || {},
|
|
1648
|
+
deletedAt: operationTime,
|
|
1649
|
+
clientId
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1648
1652
|
return normalizeState({
|
|
1649
1653
|
...payloadSource,
|
|
1650
1654
|
groupOrder: normalizeGroupOrderForOption(payloadSource.groupOrder, groupOrderNormalizer),
|
|
1651
1655
|
records: sanitizedRecords,
|
|
1652
1656
|
deletedRecordTombstones: deletionTombstones.deletedRecordTombstones,
|
|
1653
1657
|
deletedFolderTombstones: deletionTombstones.deletedFolderTombstones,
|
|
1654
|
-
lastModified: operationTime
|
|
1655
|
-
});
|
|
1658
|
+
lastModified: operationTime
|
|
1659
|
+
});
|
|
1656
1660
|
};
|
|
1657
1661
|
|
|
1658
1662
|
export const buildSyncPayloadWithOperations = ({
|
|
@@ -2038,171 +2042,799 @@ export const createRecordTimeLabelController = ({
|
|
|
2038
2042
|
|
|
2039
2043
|
return controller;
|
|
2040
2044
|
};
|
|
2041
|
-
|
|
2042
|
-
export const createSyncEngine = ({
|
|
2043
|
-
storageAdapter,
|
|
2044
|
-
cloudAdapter,
|
|
2045
|
-
clientId = 'recordtimelabel-client',
|
|
2046
|
-
clock = () => Date.now(),
|
|
2047
|
-
logger = console
|
|
2048
|
-
} = {}) => {
|
|
2049
|
-
if (!storageAdapter?.load || !storageAdapter?.save) {
|
|
2050
|
-
throw new Error('createSyncEngine requires a storageAdapter with load() and save()');
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
let state = normalizeState({});
|
|
2054
|
-
let pendingOps = [];
|
|
2055
|
-
let syncMeta = {};
|
|
2056
|
-
let unsubscribeCloud = null;
|
|
2057
|
-
const listeners = new Set();
|
|
2058
|
-
|
|
2059
|
-
const notify = (event) => {
|
|
2060
|
-
const snapshot = engine.getSnapshot();
|
|
2061
|
-
listeners.forEach((listener) => listener({ ...event, snapshot }));
|
|
2062
|
-
};
|
|
2063
|
-
|
|
2064
|
-
const persist = async () => {
|
|
2065
|
-
await storageAdapter.save({
|
|
2066
|
-
state,
|
|
2067
|
-
pendingOps,
|
|
2068
|
-
syncMeta
|
|
2069
|
-
});
|
|
2070
|
-
};
|
|
2071
|
-
|
|
2072
|
-
const engine = {
|
|
2073
|
-
async init() {
|
|
2074
|
-
const loaded = await storageAdapter.load();
|
|
2075
|
-
state = normalizeState(loaded?.state || loaded || {});
|
|
2076
|
-
pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
|
|
2077
|
-
syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
|
|
2078
|
-
|
|
2079
|
-
if (cloudAdapter?.load) {
|
|
2080
|
-
const remoteState = await cloudAdapter.load();
|
|
2081
|
-
const merged = mergeLocalRemote({
|
|
2082
|
-
localState: state,
|
|
2083
|
-
remoteState,
|
|
2084
|
-
pendingOps,
|
|
2085
|
-
clientId,
|
|
2086
|
-
now: clock()
|
|
2087
|
-
});
|
|
2088
|
-
state = merged.state;
|
|
2089
|
-
await persist();
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
if (cloudAdapter?.subscribe) {
|
|
2093
|
-
unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
|
|
2094
|
-
await engine.handleRemoteUpdate(remoteState);
|
|
2095
|
-
});
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
notify({ type: 'initialized' });
|
|
2099
|
-
return engine.getSnapshot();
|
|
2100
|
-
},
|
|
2101
|
-
|
|
2102
|
-
getSnapshot() {
|
|
2103
|
-
return {
|
|
2104
|
-
state: clone(state),
|
|
2105
|
-
pendingOps: clone(pendingOps) || [],
|
|
2106
|
-
syncMeta: { ...syncMeta }
|
|
2107
|
-
};
|
|
2108
|
-
},
|
|
2109
|
-
|
|
2110
|
-
subscribe(listener) {
|
|
2111
|
-
listeners.add(listener);
|
|
2112
|
-
return () => listeners.delete(listener);
|
|
2113
|
-
},
|
|
2114
|
-
|
|
2115
|
-
async apply(operation) {
|
|
2116
|
-
const nextOperation = {
|
|
2117
|
-
...operation,
|
|
2118
|
-
id: operation.id || createOperation(operation.type, operation.payload, {
|
|
2119
|
-
clientId,
|
|
2120
|
-
now: clock()
|
|
2121
|
-
}).id,
|
|
2122
|
-
clientId: operation.clientId || clientId,
|
|
2123
|
-
createdAt: operation.createdAt || clock()
|
|
2124
|
-
};
|
|
2125
|
-
state = applyOperation(state, nextOperation);
|
|
2126
|
-
pendingOps = [...pendingOps, nextOperation];
|
|
2127
|
-
syncMeta = {
|
|
2128
|
-
...syncMeta,
|
|
2129
|
-
lastLocalOperationAt: nextOperation.createdAt
|
|
2130
|
-
};
|
|
2131
|
-
await persist();
|
|
2132
|
-
notify({ type: 'local_applied', operation: nextOperation });
|
|
2133
|
-
return engine.getSnapshot();
|
|
2134
|
-
},
|
|
2135
|
-
|
|
2136
|
-
async handleRemoteUpdate(remoteState) {
|
|
2137
|
-
const merged = mergeLocalRemote({
|
|
2138
|
-
localState: state,
|
|
2139
|
-
remoteState,
|
|
2140
|
-
pendingOps,
|
|
2141
|
-
clientId,
|
|
2142
|
-
now: clock()
|
|
2143
|
-
});
|
|
2144
|
-
state = merged.state;
|
|
2145
|
-
await persist();
|
|
2146
|
-
notify({ type: 'remote_merged' });
|
|
2147
|
-
return engine.getSnapshot();
|
|
2148
|
-
},
|
|
2149
|
-
|
|
2150
|
-
async sync() {
|
|
2151
|
-
if (!cloudAdapter?.save) {
|
|
2152
|
-
return { success: false, reason: 'missing_cloud_save' };
|
|
2153
|
-
}
|
|
2154
|
-
try {
|
|
2155
|
-
await cloudAdapter.save(state, {
|
|
2156
|
-
pendingOps: clone(pendingOps) || [],
|
|
2157
|
-
clientId,
|
|
2158
|
-
syncMeta
|
|
2159
|
-
});
|
|
2160
|
-
pendingOps = [];
|
|
2161
|
-
syncMeta = {
|
|
2162
|
-
...syncMeta,
|
|
2163
|
-
lastSyncedAt: clock()
|
|
2164
|
-
};
|
|
2165
|
-
state = normalizeState({
|
|
2166
|
-
...state,
|
|
2167
|
-
records: Object.fromEntries(Object.entries(state.records).map(([folderId, records]) => [
|
|
2168
|
-
folderId,
|
|
2169
|
-
toArray(records).map((record) => {
|
|
2170
|
-
const cleanRecord = { ...record };
|
|
2171
|
-
delete cleanRecord.pendingSync;
|
|
2172
|
-
delete cleanRecord.syncAttempts;
|
|
2173
|
-
return cleanRecord;
|
|
2174
|
-
})
|
|
2175
|
-
]))
|
|
2176
|
-
});
|
|
2177
|
-
await persist();
|
|
2178
|
-
notify({ type: 'synced' });
|
|
2179
|
-
return { success: true };
|
|
2180
|
-
} catch (error) {
|
|
2181
|
-
logger?.error?.('[RecordTimeLabelCore] sync failed', error);
|
|
2182
|
-
syncMeta = {
|
|
2183
|
-
...syncMeta,
|
|
2184
|
-
lastSyncError: error?.message || String(error)
|
|
2185
|
-
};
|
|
2186
|
-
await persist();
|
|
2187
|
-
notify({ type: 'sync_error', error });
|
|
2188
|
-
return { success: false, error };
|
|
2189
|
-
}
|
|
2190
|
-
},
|
|
2191
|
-
|
|
2192
|
-
async destroy() {
|
|
2193
|
-
if (unsubscribeCloud) {
|
|
2194
|
-
unsubscribeCloud();
|
|
2195
|
-
unsubscribeCloud = null;
|
|
2196
|
-
}
|
|
2197
|
-
listeners.clear();
|
|
2198
|
-
}
|
|
2199
|
-
};
|
|
2200
|
-
|
|
2201
|
-
return engine;
|
|
2202
|
-
};
|
|
2203
|
-
|
|
2204
|
-
|
|
2045
|
+
|
|
2046
|
+
export const createSyncEngine = ({
|
|
2047
|
+
storageAdapter,
|
|
2048
|
+
cloudAdapter,
|
|
2049
|
+
clientId = 'recordtimelabel-client',
|
|
2050
|
+
clock = () => Date.now(),
|
|
2051
|
+
logger = console
|
|
2052
|
+
} = {}) => {
|
|
2053
|
+
if (!storageAdapter?.load || !storageAdapter?.save) {
|
|
2054
|
+
throw new Error('createSyncEngine requires a storageAdapter with load() and save()');
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
let state = normalizeState({});
|
|
2058
|
+
let pendingOps = [];
|
|
2059
|
+
let syncMeta = {};
|
|
2060
|
+
let unsubscribeCloud = null;
|
|
2061
|
+
const listeners = new Set();
|
|
2062
|
+
|
|
2063
|
+
const notify = (event) => {
|
|
2064
|
+
const snapshot = engine.getSnapshot();
|
|
2065
|
+
listeners.forEach((listener) => listener({ ...event, snapshot }));
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
const persist = async () => {
|
|
2069
|
+
await storageAdapter.save({
|
|
2070
|
+
state,
|
|
2071
|
+
pendingOps,
|
|
2072
|
+
syncMeta
|
|
2073
|
+
});
|
|
2074
|
+
};
|
|
2075
|
+
|
|
2076
|
+
const engine = {
|
|
2077
|
+
async init() {
|
|
2078
|
+
const loaded = await storageAdapter.load();
|
|
2079
|
+
state = normalizeState(loaded?.state || loaded || {});
|
|
2080
|
+
pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
|
|
2081
|
+
syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
|
|
2082
|
+
|
|
2083
|
+
if (cloudAdapter?.load) {
|
|
2084
|
+
const remoteState = await cloudAdapter.load();
|
|
2085
|
+
const merged = mergeLocalRemote({
|
|
2086
|
+
localState: state,
|
|
2087
|
+
remoteState,
|
|
2088
|
+
pendingOps,
|
|
2089
|
+
clientId,
|
|
2090
|
+
now: clock()
|
|
2091
|
+
});
|
|
2092
|
+
state = merged.state;
|
|
2093
|
+
await persist();
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (cloudAdapter?.subscribe) {
|
|
2097
|
+
unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
|
|
2098
|
+
await engine.handleRemoteUpdate(remoteState);
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
notify({ type: 'initialized' });
|
|
2103
|
+
return engine.getSnapshot();
|
|
2104
|
+
},
|
|
2105
|
+
|
|
2106
|
+
getSnapshot() {
|
|
2107
|
+
return {
|
|
2108
|
+
state: clone(state),
|
|
2109
|
+
pendingOps: clone(pendingOps) || [],
|
|
2110
|
+
syncMeta: { ...syncMeta }
|
|
2111
|
+
};
|
|
2112
|
+
},
|
|
2113
|
+
|
|
2114
|
+
subscribe(listener) {
|
|
2115
|
+
listeners.add(listener);
|
|
2116
|
+
return () => listeners.delete(listener);
|
|
2117
|
+
},
|
|
2118
|
+
|
|
2119
|
+
async apply(operation) {
|
|
2120
|
+
const nextOperation = {
|
|
2121
|
+
...operation,
|
|
2122
|
+
id: operation.id || createOperation(operation.type, operation.payload, {
|
|
2123
|
+
clientId,
|
|
2124
|
+
now: clock()
|
|
2125
|
+
}).id,
|
|
2126
|
+
clientId: operation.clientId || clientId,
|
|
2127
|
+
createdAt: operation.createdAt || clock()
|
|
2128
|
+
};
|
|
2129
|
+
state = applyOperation(state, nextOperation);
|
|
2130
|
+
pendingOps = [...pendingOps, nextOperation];
|
|
2131
|
+
syncMeta = {
|
|
2132
|
+
...syncMeta,
|
|
2133
|
+
lastLocalOperationAt: nextOperation.createdAt
|
|
2134
|
+
};
|
|
2135
|
+
await persist();
|
|
2136
|
+
notify({ type: 'local_applied', operation: nextOperation });
|
|
2137
|
+
return engine.getSnapshot();
|
|
2138
|
+
},
|
|
2139
|
+
|
|
2140
|
+
async handleRemoteUpdate(remoteState) {
|
|
2141
|
+
const merged = mergeLocalRemote({
|
|
2142
|
+
localState: state,
|
|
2143
|
+
remoteState,
|
|
2144
|
+
pendingOps,
|
|
2145
|
+
clientId,
|
|
2146
|
+
now: clock()
|
|
2147
|
+
});
|
|
2148
|
+
state = merged.state;
|
|
2149
|
+
await persist();
|
|
2150
|
+
notify({ type: 'remote_merged' });
|
|
2151
|
+
return engine.getSnapshot();
|
|
2152
|
+
},
|
|
2153
|
+
|
|
2154
|
+
async sync() {
|
|
2155
|
+
if (!cloudAdapter?.save) {
|
|
2156
|
+
return { success: false, reason: 'missing_cloud_save' };
|
|
2157
|
+
}
|
|
2158
|
+
try {
|
|
2159
|
+
await cloudAdapter.save(state, {
|
|
2160
|
+
pendingOps: clone(pendingOps) || [],
|
|
2161
|
+
clientId,
|
|
2162
|
+
syncMeta
|
|
2163
|
+
});
|
|
2164
|
+
pendingOps = [];
|
|
2165
|
+
syncMeta = {
|
|
2166
|
+
...syncMeta,
|
|
2167
|
+
lastSyncedAt: clock()
|
|
2168
|
+
};
|
|
2169
|
+
state = normalizeState({
|
|
2170
|
+
...state,
|
|
2171
|
+
records: Object.fromEntries(Object.entries(state.records).map(([folderId, records]) => [
|
|
2172
|
+
folderId,
|
|
2173
|
+
toArray(records).map((record) => {
|
|
2174
|
+
const cleanRecord = { ...record };
|
|
2175
|
+
delete cleanRecord.pendingSync;
|
|
2176
|
+
delete cleanRecord.syncAttempts;
|
|
2177
|
+
return cleanRecord;
|
|
2178
|
+
})
|
|
2179
|
+
]))
|
|
2180
|
+
});
|
|
2181
|
+
await persist();
|
|
2182
|
+
notify({ type: 'synced' });
|
|
2183
|
+
return { success: true };
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
logger?.error?.('[RecordTimeLabelCore] sync failed', error);
|
|
2186
|
+
syncMeta = {
|
|
2187
|
+
...syncMeta,
|
|
2188
|
+
lastSyncError: error?.message || String(error)
|
|
2189
|
+
};
|
|
2190
|
+
await persist();
|
|
2191
|
+
notify({ type: 'sync_error', error });
|
|
2192
|
+
return { success: false, error };
|
|
2193
|
+
}
|
|
2194
|
+
},
|
|
2195
|
+
|
|
2196
|
+
async destroy() {
|
|
2197
|
+
if (unsubscribeCloud) {
|
|
2198
|
+
unsubscribeCloud();
|
|
2199
|
+
unsubscribeCloud = null;
|
|
2200
|
+
}
|
|
2201
|
+
listeners.clear();
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
|
|
2205
|
+
return engine;
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
const RTL_SYNC_CLIENT_APPS = new Set(['extension', 'flowmoor']);
|
|
2209
|
+
const RTL_SUPPORTED_OPERATION_TYPES = new Set(Object.values(OPERATION_TYPES));
|
|
2210
|
+
const RTL_BULK_OPERATION_TYPES = new Set([OPERATION_TYPES.FOLDER_DELETE]);
|
|
2211
|
+
const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
|
|
2212
|
+
|
|
2213
|
+
const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
|
|
2214
|
+
const rtlOperationRecordId = (operation = {}) => normalizeId(
|
|
2215
|
+
operation?.payload?.recordId || operation?.payload?.id || operation?.payload?.record?.id
|
|
2216
|
+
);
|
|
2217
|
+
const rtlOperationFolderId = (operation = {}) => normalizeId(
|
|
2218
|
+
operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
|
|
2219
|
+
);
|
|
2220
|
+
const rtlCloneDocuments = (documents = {}) => ({
|
|
2221
|
+
root: clone(documents.root || null),
|
|
2222
|
+
records: clone(documents.records || {}),
|
|
2223
|
+
folders: clone(documents.folders || {}),
|
|
2224
|
+
ops: {}
|
|
2225
|
+
});
|
|
2226
|
+
const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
|
|
2227
|
+
const rtlNormalizeGroupOrder = (order) => rtlMergeOrder(order)
|
|
2228
|
+
.filter((id) => id.startsWith('group-'));
|
|
2229
|
+
const rtlPayloadOrder = (operation, keys) => {
|
|
2230
|
+
for (const key of keys) {
|
|
2231
|
+
if (Array.isArray(operation?.payload?.[key])) return operation.payload[key];
|
|
2232
|
+
}
|
|
2233
|
+
return [];
|
|
2234
|
+
};
|
|
2235
|
+
const rtlHasInvalidPayloadValue = (value, depth = 0) => {
|
|
2236
|
+
if (depth > 20) return true;
|
|
2237
|
+
if (typeof value === 'number') return !Number.isFinite(value);
|
|
2238
|
+
if (typeof value === 'string') return rtlByteLength(value) > 256 * 1024;
|
|
2239
|
+
if (Array.isArray(value)) {
|
|
2240
|
+
return value.some((entry) => rtlHasInvalidPayloadValue(entry, depth + 1));
|
|
2241
|
+
}
|
|
2242
|
+
if (!value || typeof value !== 'object') return false;
|
|
2243
|
+
return Object.values(value).some((entry) => rtlHasInvalidPayloadValue(entry, depth + 1));
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
2247
|
+
const rtlValidateIdList = (value, index, field, errors) => {
|
|
2248
|
+
if (!Array.isArray(value)) {
|
|
2249
|
+
errors.push(`operation_${index}_invalid_${field}`);
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (value.some((id) => !normalizeId(id) || normalizeId(id).length > 512)) {
|
|
2253
|
+
errors.push(`operation_${index}_invalid_${field}`);
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
const rtlValidateOperationPayload = (operation, index, errors) => {
|
|
2257
|
+
const payload = operation.payload || {};
|
|
2258
|
+
const recordId = rtlOperationRecordId(operation);
|
|
2259
|
+
const folderId = rtlOperationFolderId(operation);
|
|
2260
|
+
switch (operation.type) {
|
|
2261
|
+
case OPERATION_TYPES.RECORD_CREATE:
|
|
2262
|
+
if (!rtlIsObject(payload.record) || !recordId) {
|
|
2263
|
+
errors.push(`operation_${index}_invalid_record`);
|
|
2264
|
+
}
|
|
2265
|
+
break;
|
|
2266
|
+
case OPERATION_TYPES.RECORD_UPDATE:
|
|
2267
|
+
if (!recordId) errors.push(`operation_${index}_missing_record_id`);
|
|
2268
|
+
if (!rtlIsObject(payload.patch)) errors.push(`operation_${index}_invalid_patch`);
|
|
2269
|
+
break;
|
|
2270
|
+
case OPERATION_TYPES.RECORD_MOVE:
|
|
2271
|
+
if (!recordId) errors.push(`operation_${index}_missing_record_id`);
|
|
2272
|
+
if (!normalizeId(payload.targetFolderId || payload.folderId)) {
|
|
2273
|
+
errors.push(`operation_${index}_missing_target_folder_id`);
|
|
2274
|
+
}
|
|
2275
|
+
break;
|
|
2276
|
+
case OPERATION_TYPES.RECORD_DELETE:
|
|
2277
|
+
if (!recordId) errors.push(`operation_${index}_missing_record_id`);
|
|
2278
|
+
break;
|
|
2279
|
+
case OPERATION_TYPES.RECORD_REORDER:
|
|
2280
|
+
if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
|
|
2281
|
+
rtlValidateIdList(payload.recordIds, index, 'record_ids', errors);
|
|
2282
|
+
break;
|
|
2283
|
+
case OPERATION_TYPES.FOLDER_CREATE:
|
|
2284
|
+
if (!rtlIsObject(payload.folder) || !folderId) {
|
|
2285
|
+
errors.push(`operation_${index}_invalid_folder`);
|
|
2286
|
+
}
|
|
2287
|
+
break;
|
|
2288
|
+
case OPERATION_TYPES.FOLDER_UPDATE:
|
|
2289
|
+
if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
|
|
2290
|
+
if (!rtlIsObject(payload.patch)) errors.push(`operation_${index}_invalid_patch`);
|
|
2291
|
+
break;
|
|
2292
|
+
case OPERATION_TYPES.FOLDER_DELETE:
|
|
2293
|
+
if (!folderId) errors.push(`operation_${index}_missing_folder_id`);
|
|
2294
|
+
break;
|
|
2295
|
+
case OPERATION_TYPES.FOLDER_REORDER:
|
|
2296
|
+
rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
|
|
2297
|
+
break;
|
|
2298
|
+
case OPERATION_TYPES.GROUP_REORDER:
|
|
2299
|
+
rtlValidateIdList(payload.groupOrder || payload.order, index, 'group_order', errors);
|
|
2300
|
+
break;
|
|
2301
|
+
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
2302
|
+
rtlValidateIdList(
|
|
2303
|
+
payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
|
|
2304
|
+
index,
|
|
2305
|
+
'expanded_groups',
|
|
2306
|
+
errors
|
|
2307
|
+
);
|
|
2308
|
+
break;
|
|
2309
|
+
case OPERATION_TYPES.SETTINGS_UPDATE:
|
|
2310
|
+
if (!rtlIsObject(payload.patch || payload.settings)) {
|
|
2311
|
+
errors.push(`operation_${index}_invalid_settings_patch`);
|
|
2312
|
+
}
|
|
2313
|
+
break;
|
|
2314
|
+
default:
|
|
2315
|
+
break;
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Validates one complete v2 gateway request without reading Firebase state.
|
|
2321
|
+
*/
|
|
2322
|
+
export const validateRecordTimeLabelOperationBatch = (body = {}) => {
|
|
2323
|
+
const errors = [];
|
|
2324
|
+
let requestBytes = 0;
|
|
2325
|
+
try {
|
|
2326
|
+
requestBytes = rtlByteLength(JSON.stringify(body));
|
|
2327
|
+
} catch {
|
|
2328
|
+
errors.push('invalid_json_payload');
|
|
2329
|
+
}
|
|
2330
|
+
if (requestBytes > RTL_MAX_REQUEST_BYTES) errors.push('request_too_large');
|
|
2331
|
+
if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
|
|
2332
|
+
errors.push('unsupported_protocol_version');
|
|
2333
|
+
}
|
|
2334
|
+
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(normalizeId(body.requestId))) {
|
|
2335
|
+
errors.push('invalid_request_id');
|
|
2336
|
+
}
|
|
2337
|
+
if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
|
|
2338
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(normalizeId(body.client?.version))) {
|
|
2339
|
+
errors.push('invalid_client_version');
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const operations = Array.isArray(body.operations) ? body.operations : [];
|
|
2343
|
+
if (operations.length < 1 || operations.length > RTL_MAX_OPERATIONS_PER_REQUEST) {
|
|
2344
|
+
errors.push('invalid_operation_count');
|
|
2345
|
+
}
|
|
2346
|
+
const operationIds = new Set();
|
|
2347
|
+
operations.forEach((operation, index) => {
|
|
2348
|
+
const id = normalizeId(operation?.id);
|
|
2349
|
+
if (!id || id.length > 200) errors.push(`operation_${index}_invalid_id`);
|
|
2350
|
+
if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
|
|
2351
|
+
operationIds.add(id);
|
|
2352
|
+
if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
|
|
2353
|
+
errors.push(`operation_${index}_unsupported_type`);
|
|
2354
|
+
}
|
|
2355
|
+
if (RTL_BULK_OPERATION_TYPES.has(operation?.type)) {
|
|
2356
|
+
errors.push(`operation_${index}_bulk_required`);
|
|
2357
|
+
}
|
|
2358
|
+
if (!operation?.payload || typeof operation.payload !== 'object' || Array.isArray(operation.payload)) {
|
|
2359
|
+
errors.push(`operation_${index}_invalid_payload`);
|
|
2360
|
+
} else {
|
|
2361
|
+
let operationBytes = Infinity;
|
|
2362
|
+
try {
|
|
2363
|
+
operationBytes = rtlByteLength(JSON.stringify(operation));
|
|
2364
|
+
} catch {
|
|
2365
|
+
// Kept as Infinity so the malformed payload fails closed below.
|
|
2366
|
+
}
|
|
2367
|
+
if (rtlHasInvalidPayloadValue(operation.payload) || operationBytes > 128 * 1024) {
|
|
2368
|
+
errors.push(`operation_${index}_payload_too_large`);
|
|
2369
|
+
}
|
|
2370
|
+
rtlValidateOperationPayload(operation, index, errors);
|
|
2371
|
+
}
|
|
2372
|
+
if (!toFiniteTimestamp(operation?.createdAt)) {
|
|
2373
|
+
errors.push(`operation_${index}_invalid_created_at`);
|
|
2374
|
+
}
|
|
2375
|
+
if (rtlOperationRecordId(operation).length > 512) {
|
|
2376
|
+
errors.push(`operation_${index}_invalid_record_id`);
|
|
2377
|
+
}
|
|
2378
|
+
if (rtlOperationFolderId(operation).length > 512) {
|
|
2379
|
+
errors.push(`operation_${index}_invalid_folder_id`);
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
return {ok: errors.length === 0, errors, requestBytes, operations};
|
|
2384
|
+
};
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Returns only the records and folders required to apply a bounded operation batch.
|
|
2388
|
+
*/
|
|
2389
|
+
export const buildFirestoreV2OperationReadPlan = (operations = []) => {
|
|
2390
|
+
const recordIds = new Set();
|
|
2391
|
+
const folderIds = new Set();
|
|
2392
|
+
const folderDeleteIds = new Set();
|
|
2393
|
+
toArray(operations).filter(Boolean).forEach((operation) => {
|
|
2394
|
+
const payload = operation.payload || {};
|
|
2395
|
+
switch (operation.type) {
|
|
2396
|
+
case OPERATION_TYPES.RECORD_CREATE:
|
|
2397
|
+
case OPERATION_TYPES.RECORD_MOVE: {
|
|
2398
|
+
const recordId = rtlOperationRecordId(operation);
|
|
2399
|
+
if (recordId) recordIds.add(recordId);
|
|
2400
|
+
folderIds.add(safeFolderId(
|
|
2401
|
+
payload.targetFolderId || payload.folderId || payload.record?.folderId
|
|
2402
|
+
));
|
|
2403
|
+
break;
|
|
2404
|
+
}
|
|
2405
|
+
case OPERATION_TYPES.RECORD_UPDATE:
|
|
2406
|
+
case OPERATION_TYPES.RECORD_DELETE: {
|
|
2407
|
+
const recordId = rtlOperationRecordId(operation);
|
|
2408
|
+
if (recordId) recordIds.add(recordId);
|
|
2409
|
+
break;
|
|
2410
|
+
}
|
|
2411
|
+
case OPERATION_TYPES.RECORD_REORDER:
|
|
2412
|
+
folderIds.add(safeFolderId(payload.folderId));
|
|
2413
|
+
break;
|
|
2414
|
+
case OPERATION_TYPES.FOLDER_CREATE:
|
|
2415
|
+
case OPERATION_TYPES.FOLDER_UPDATE:
|
|
2416
|
+
case OPERATION_TYPES.FOLDER_DELETE: {
|
|
2417
|
+
const folderId = rtlOperationFolderId(operation);
|
|
2418
|
+
if (folderId) folderIds.add(folderId);
|
|
2419
|
+
if (folderId && operation.type === OPERATION_TYPES.FOLDER_DELETE) {
|
|
2420
|
+
folderDeleteIds.add(folderId);
|
|
2421
|
+
}
|
|
2422
|
+
break;
|
|
2423
|
+
}
|
|
2424
|
+
default:
|
|
2425
|
+
break;
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
return {recordIds, folderIds, folderDeleteIds};
|
|
2429
|
+
};
|
|
2430
|
+
|
|
2431
|
+
export const extendFirestoreV2OperationReadPlanWithRecords = (readPlan, records = {}) => {
|
|
2432
|
+
const next = {
|
|
2433
|
+
recordIds: new Set(readPlan?.recordIds || []),
|
|
2434
|
+
folderIds: new Set(readPlan?.folderIds || []),
|
|
2435
|
+
folderDeleteIds: new Set(readPlan?.folderDeleteIds || [])
|
|
2436
|
+
};
|
|
2437
|
+
Object.values(records || {}).forEach((record) => {
|
|
2438
|
+
next.folderIds.add(safeFolderId(record?.folderId));
|
|
2439
|
+
});
|
|
2440
|
+
return next;
|
|
2441
|
+
};
|
|
2442
|
+
|
|
2443
|
+
const rtlApplyOperationToPartialDocuments = ({root, records, folders, operation, now}) => {
|
|
2444
|
+
const {state} = buildStateFromFirestoreV2Documents({
|
|
2445
|
+
root: root || {}, records: records || {}, folders: folders || {}, ops: {}
|
|
2446
|
+
});
|
|
2447
|
+
const nextState = applyRecordTimeLabelOperation(state, operation);
|
|
2448
|
+
return {
|
|
2449
|
+
state: nextState,
|
|
2450
|
+
documents: buildFirestoreV2DocumentsFromState(nextState, {now})
|
|
2451
|
+
};
|
|
2452
|
+
};
|
|
2453
|
+
|
|
2454
|
+
const rtlCanRestoreLocalFolder = (root, folder) => {
|
|
2455
|
+
if (!folder?.id) return false;
|
|
2456
|
+
const tombstone = root?.deletedFolderTombstones?.[folder.id];
|
|
2457
|
+
if (!tombstone) return true;
|
|
2458
|
+
const folderTime = toFiniteTimestamp(folder.updatedAt || folder.createdAt || folder.lastModified);
|
|
2459
|
+
const deletedAt = toFiniteTimestamp(
|
|
2460
|
+
tombstone.deletedAt || tombstone.updatedAt || tombstone.createdAt
|
|
2461
|
+
);
|
|
2462
|
+
return Boolean(folderTime) && Boolean(deletedAt) && folderTime > deletedAt;
|
|
2463
|
+
};
|
|
2464
|
+
|
|
2465
|
+
const rtlBuildLocalFolderDocument = (folder, now) => {
|
|
2466
|
+
if (!folder?.id) return null;
|
|
2467
|
+
return buildFirestoreV2DocumentsFromState({
|
|
2468
|
+
folders: [folder], records: {[folder.id]: []}, lastModified: now
|
|
2469
|
+
}, {now}).folders[folder.id] || null;
|
|
2470
|
+
};
|
|
2471
|
+
|
|
2472
|
+
const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
|
|
2473
|
+
if (folders[folderId]) return folders[folderId];
|
|
2474
|
+
const localFolder = toArray(localState?.folders)
|
|
2475
|
+
.find((folder) => folder?.id === folderId);
|
|
2476
|
+
if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
|
|
2477
|
+
const document = rtlBuildLocalFolderDocument(localFolder, now);
|
|
2478
|
+
if (!document) return null;
|
|
2479
|
+
folders[folderId] = {...document, recordOrder: []};
|
|
2480
|
+
if (!RTL_PROTECTED_FOLDER_IDS.has(folderId)) {
|
|
2481
|
+
root.folderOrder = rtlMergeOrder(localState?.folderOrder, root.folderOrder, [folderId]);
|
|
2482
|
+
}
|
|
2483
|
+
return folders[folderId];
|
|
2484
|
+
};
|
|
2485
|
+
|
|
2486
|
+
const rtlSetRoot = (target, source) => {
|
|
2487
|
+
Object.keys(target).forEach((key) => delete target[key]);
|
|
2488
|
+
Object.assign(target, clone(source || {}));
|
|
2489
|
+
};
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* Produces a pure document-level mutation plan. This function never accesses Firebase.
|
|
2493
|
+
*/
|
|
2494
|
+
export const planFirestoreV2OperationChanges = ({
|
|
2495
|
+
documents,
|
|
2496
|
+
operations,
|
|
2497
|
+
localState = {},
|
|
2498
|
+
now = Date.now()
|
|
2499
|
+
} = {}) => {
|
|
2500
|
+
const previousDocuments = rtlCloneDocuments(documents);
|
|
2501
|
+
const nextDocuments = rtlCloneDocuments(documents);
|
|
2502
|
+
const normalizedLocalState = normalizeState(localState || {});
|
|
2503
|
+
const operationResults = [];
|
|
2504
|
+
let changed = false;
|
|
2505
|
+
if (!nextDocuments.root) {
|
|
2506
|
+
return {requiresMigration: true, previousDocuments, nextDocuments, operationResults};
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
for (const operation of toArray(operations).filter(Boolean)) {
|
|
2510
|
+
const payload = operation.payload || {};
|
|
2511
|
+
const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
|
|
2512
|
+
const recordId = rtlOperationRecordId(operation);
|
|
2513
|
+
const folderId = rtlOperationFolderId(operation);
|
|
2514
|
+
let applied = null;
|
|
2515
|
+
let reason = null;
|
|
2516
|
+
|
|
2517
|
+
switch (operation.type) {
|
|
2518
|
+
case OPERATION_TYPES.RECORD_CREATE: {
|
|
2519
|
+
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
2520
|
+
if (nextDocuments.records[recordId]) { reason = 'already_exists'; break; }
|
|
2521
|
+
const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
|
|
2522
|
+
applied = rtlApplyOperationToPartialDocuments({
|
|
2523
|
+
...nextDocuments,
|
|
2524
|
+
operation: {...operation, payload: {...payload, folderId: targetFolderId}},
|
|
2525
|
+
now: operationNow
|
|
2526
|
+
});
|
|
2527
|
+
const recordDocument = applied.documents.records[recordId];
|
|
2528
|
+
if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
|
|
2529
|
+
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
2530
|
+
nextDocuments.records[recordId] = recordDocument;
|
|
2531
|
+
const targetFolder = rtlEnsureTargetFolder({
|
|
2532
|
+
root: nextDocuments.root,
|
|
2533
|
+
folders: nextDocuments.folders,
|
|
2534
|
+
localState: normalizedLocalState,
|
|
2535
|
+
folderId: targetFolderId,
|
|
2536
|
+
now: operationNow
|
|
2537
|
+
});
|
|
2538
|
+
if (!targetFolder) {
|
|
2539
|
+
reason = 'folder_not_found';
|
|
2540
|
+
delete nextDocuments.records[recordId];
|
|
2541
|
+
break;
|
|
2542
|
+
}
|
|
2543
|
+
targetFolder.recordOrder = rtlMergeOrder([recordId], targetFolder.recordOrder);
|
|
2544
|
+
changed = true;
|
|
2545
|
+
break;
|
|
2546
|
+
}
|
|
2547
|
+
case OPERATION_TYPES.RECORD_UPDATE: {
|
|
2548
|
+
const existing = nextDocuments.records[recordId];
|
|
2549
|
+
if (!recordId || !existing) {
|
|
2550
|
+
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
2551
|
+
break;
|
|
2552
|
+
}
|
|
2553
|
+
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
2554
|
+
const recordDocument = applied.documents.records[recordId];
|
|
2555
|
+
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
2556
|
+
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
2557
|
+
nextDocuments.records[recordId] = recordDocument;
|
|
2558
|
+
changed = true;
|
|
2559
|
+
break;
|
|
2560
|
+
}
|
|
2561
|
+
case OPERATION_TYPES.RECORD_MOVE: {
|
|
2562
|
+
const existing = nextDocuments.records[recordId];
|
|
2563
|
+
if (!recordId || !existing) {
|
|
2564
|
+
reason = recordId ? 'record_not_found' : 'missing_record_id';
|
|
2565
|
+
break;
|
|
2566
|
+
}
|
|
2567
|
+
const sourceFolderId = safeFolderId(existing.folderId);
|
|
2568
|
+
const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
|
|
2569
|
+
applied = rtlApplyOperationToPartialDocuments({
|
|
2570
|
+
...nextDocuments,
|
|
2571
|
+
operation: {...operation, payload: {...payload, targetFolderId}},
|
|
2572
|
+
now: operationNow
|
|
2573
|
+
});
|
|
2574
|
+
const recordDocument = applied.documents.records[recordId];
|
|
2575
|
+
if (!recordDocument) { reason = 'record_not_found'; break; }
|
|
2576
|
+
const targetFolder = rtlEnsureTargetFolder({
|
|
2577
|
+
root: nextDocuments.root,
|
|
2578
|
+
folders: nextDocuments.folders,
|
|
2579
|
+
localState: normalizedLocalState,
|
|
2580
|
+
folderId: targetFolderId,
|
|
2581
|
+
now: operationNow
|
|
2582
|
+
});
|
|
2583
|
+
if (!targetFolder) { reason = 'folder_not_found'; break; }
|
|
2584
|
+
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
2585
|
+
nextDocuments.records[recordId] = recordDocument;
|
|
2586
|
+
if (nextDocuments.folders[sourceFolderId]) {
|
|
2587
|
+
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
2588
|
+
nextDocuments.folders[sourceFolderId].recordOrder
|
|
2589
|
+
).filter((id) => id !== recordId);
|
|
2590
|
+
}
|
|
2591
|
+
targetFolder.recordOrder = [
|
|
2592
|
+
...rtlMergeOrder(targetFolder.recordOrder).filter((id) => id !== recordId),
|
|
2593
|
+
recordId
|
|
2594
|
+
];
|
|
2595
|
+
changed = true;
|
|
2596
|
+
break;
|
|
2597
|
+
}
|
|
2598
|
+
case OPERATION_TYPES.RECORD_REORDER: {
|
|
2599
|
+
const targetFolderId = safeFolderId(payload.folderId);
|
|
2600
|
+
const targetFolder = nextDocuments.folders[targetFolderId];
|
|
2601
|
+
const requested = rtlPayloadOrder(operation, ['recordIds']);
|
|
2602
|
+
if (!targetFolder || requested.length === 0) {
|
|
2603
|
+
reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
|
|
2604
|
+
break;
|
|
2605
|
+
}
|
|
2606
|
+
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
2607
|
+
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
2608
|
+
targetFolder.recordOrder = rtlMergeOrder(requested, targetFolder.recordOrder);
|
|
2609
|
+
changed = true;
|
|
2610
|
+
break;
|
|
2611
|
+
}
|
|
2612
|
+
case OPERATION_TYPES.RECORD_DELETE: {
|
|
2613
|
+
if (!recordId) { reason = 'missing_record_id'; break; }
|
|
2614
|
+
const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
|
|
2615
|
+
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
2616
|
+
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
2617
|
+
delete nextDocuments.records[recordId];
|
|
2618
|
+
if (nextDocuments.folders[sourceFolderId]) {
|
|
2619
|
+
nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
|
|
2620
|
+
nextDocuments.folders[sourceFolderId].recordOrder
|
|
2621
|
+
).filter((id) => id !== recordId);
|
|
2622
|
+
}
|
|
2623
|
+
changed = true;
|
|
2624
|
+
break;
|
|
2625
|
+
}
|
|
2626
|
+
case OPERATION_TYPES.FOLDER_CREATE:
|
|
2627
|
+
case OPERATION_TYPES.FOLDER_UPDATE: {
|
|
2628
|
+
if (!folderId) { reason = 'missing_folder_id'; break; }
|
|
2629
|
+
if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders[folderId]) {
|
|
2630
|
+
reason = 'folder_not_found';
|
|
2631
|
+
break;
|
|
2632
|
+
}
|
|
2633
|
+
const previousOrder = nextDocuments.folders[folderId]?.recordOrder || [];
|
|
2634
|
+
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
2635
|
+
const folderDocument = applied.documents.folders[folderId];
|
|
2636
|
+
if (!folderDocument) { reason = 'folder_not_found'; break; }
|
|
2637
|
+
rtlSetRoot(nextDocuments.root, applied.documents.root);
|
|
2638
|
+
nextDocuments.folders[folderId] = {
|
|
2639
|
+
...folderDocument,
|
|
2640
|
+
recordOrder: rtlMergeOrder(previousOrder)
|
|
2641
|
+
};
|
|
2642
|
+
changed = true;
|
|
2643
|
+
break;
|
|
2644
|
+
}
|
|
2645
|
+
case OPERATION_TYPES.FOLDER_DELETE:
|
|
2646
|
+
reason = 'bulk_required';
|
|
2647
|
+
break;
|
|
2648
|
+
case OPERATION_TYPES.FOLDER_REORDER:
|
|
2649
|
+
case OPERATION_TYPES.GROUP_REORDER:
|
|
2650
|
+
case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
|
|
2651
|
+
case OPERATION_TYPES.SETTINGS_UPDATE: {
|
|
2652
|
+
applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
|
|
2653
|
+
const appliedRoot = applied.documents.root;
|
|
2654
|
+
if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
|
|
2655
|
+
appliedRoot.folderOrder = rtlMergeOrder(
|
|
2656
|
+
rtlPayloadOrder(operation, ['folderOrder', 'order']),
|
|
2657
|
+
nextDocuments.root.folderOrder,
|
|
2658
|
+
normalizedLocalState.folderOrder
|
|
2659
|
+
);
|
|
2660
|
+
} else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
|
|
2661
|
+
appliedRoot.groupOrder = rtlNormalizeGroupOrder(rtlMergeOrder(
|
|
2662
|
+
rtlPayloadOrder(operation, ['groupOrder', 'order']),
|
|
2663
|
+
nextDocuments.root.groupOrder,
|
|
2664
|
+
normalizedLocalState.groupOrder
|
|
2665
|
+
));
|
|
2666
|
+
} else if (operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
|
|
2667
|
+
appliedRoot.expandedGroups = rtlMergeOrder(
|
|
2668
|
+
rtlPayloadOrder(operation, ['expandedGroups', 'groupIds', 'order', 'ids']),
|
|
2669
|
+
nextDocuments.root.expandedGroups,
|
|
2670
|
+
normalizedLocalState.expandedGroups
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
rtlSetRoot(nextDocuments.root, appliedRoot);
|
|
2674
|
+
changed = true;
|
|
2675
|
+
break;
|
|
2676
|
+
}
|
|
2677
|
+
default:
|
|
2678
|
+
reason = 'unsupported_operation';
|
|
2679
|
+
}
|
|
2680
|
+
operationResults.push({
|
|
2681
|
+
id: operation.id || null,
|
|
2682
|
+
type: operation.type || null,
|
|
2683
|
+
applied: !reason,
|
|
2684
|
+
reason
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
if (changed) {
|
|
2689
|
+
const {state} = buildStateFromFirestoreV2Documents({
|
|
2690
|
+
root: nextDocuments.root, records: {}, folders: {}, ops: {}
|
|
2691
|
+
});
|
|
2692
|
+
nextDocuments.root = buildFirestoreV2DocumentsFromState(state, {
|
|
2693
|
+
now,
|
|
2694
|
+
syncMetaPatch: {lastBackgroundSyncAt: now}
|
|
2695
|
+
}).root;
|
|
2696
|
+
}
|
|
2697
|
+
const changes = buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, {
|
|
2698
|
+
allowDeletes: true
|
|
2699
|
+
});
|
|
2700
|
+
return {
|
|
2701
|
+
requiresMigration: false,
|
|
2702
|
+
previousDocuments,
|
|
2703
|
+
nextDocuments,
|
|
2704
|
+
changes,
|
|
2705
|
+
operationResults
|
|
2706
|
+
};
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2709
|
+
/**
|
|
2710
|
+
* Counts all billable writes before a transaction reserves quota.
|
|
2711
|
+
*/
|
|
2712
|
+
export const estimateFirestoreV2WriteUnits = (changes = {}, overhead = 3) => {
|
|
2713
|
+
const collectionWrites = ['records', 'folders', 'ops'].reduce((count, key) => (
|
|
2714
|
+
count + Object.keys(changes?.[key]?.upserts || {}).length +
|
|
2715
|
+
toArray(changes?.[key]?.deleteIds).length
|
|
2716
|
+
), 0);
|
|
2717
|
+
const rootWrites = changes?.root?.upsert ? 1 : 0;
|
|
2718
|
+
const targetWrites = rootWrites + collectionWrites;
|
|
2719
|
+
return {
|
|
2720
|
+
targetWrites,
|
|
2721
|
+
overheadWrites: overhead,
|
|
2722
|
+
totalWrites: targetWrites + overhead,
|
|
2723
|
+
exceedsTargetLimit: targetWrites > RTL_MAX_TARGET_WRITES
|
|
2724
|
+
};
|
|
2725
|
+
};
|
|
2726
|
+
|
|
2727
|
+
const rtlStateRecordMap = (state) => {
|
|
2728
|
+
const records = new Map();
|
|
2729
|
+
Object.entries(normalizeState(state || {}).records).forEach(([folderId, entries]) => {
|
|
2730
|
+
toArray(entries).forEach((record) => records.set(record.id, {folderId, record}));
|
|
2731
|
+
});
|
|
2732
|
+
return records;
|
|
2733
|
+
};
|
|
2734
|
+
|
|
2735
|
+
const rtlComparableJson = (value) => JSON.stringify(value || {});
|
|
2736
|
+
|
|
2737
|
+
/**
|
|
2738
|
+
* Converts a snapshot merge/import diff into stable, resumable operation batches.
|
|
2739
|
+
*/
|
|
2740
|
+
export const buildOperationsFromSnapshotDiff = ({
|
|
2741
|
+
previousState = {},
|
|
2742
|
+
nextState = {},
|
|
2743
|
+
now = Date.now(),
|
|
2744
|
+
operationIdPrefix = `snapshot:${now}`,
|
|
2745
|
+
batchSize = RTL_MAX_OPERATIONS_PER_REQUEST
|
|
2746
|
+
} = {}) => {
|
|
2747
|
+
const previous = normalizeState(previousState);
|
|
2748
|
+
const next = normalizeState(nextState);
|
|
2749
|
+
const previousRecords = rtlStateRecordMap(previous);
|
|
2750
|
+
const nextRecords = rtlStateRecordMap(next);
|
|
2751
|
+
const previousFolders = new Map(previous.folders.map((folder) => [folder.id, folder]));
|
|
2752
|
+
const nextFolders = new Map(next.folders.map((folder) => [folder.id, folder]));
|
|
2753
|
+
const drafts = [];
|
|
2754
|
+
|
|
2755
|
+
nextFolders.forEach((folder, folderId) => {
|
|
2756
|
+
if (RTL_PROTECTED_FOLDER_IDS.has(folderId)) return;
|
|
2757
|
+
const oldFolder = previousFolders.get(folderId);
|
|
2758
|
+
if (!oldFolder) {
|
|
2759
|
+
drafts.push({type: OPERATION_TYPES.FOLDER_CREATE, payload: {folder}});
|
|
2760
|
+
} else if (rtlComparableJson(oldFolder) !== rtlComparableJson(folder)) {
|
|
2761
|
+
drafts.push({type: OPERATION_TYPES.FOLDER_UPDATE, payload: {folderId, patch: folder}});
|
|
2762
|
+
}
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
nextRecords.forEach(({folderId, record}, recordId) => {
|
|
2766
|
+
const oldEntry = previousRecords.get(recordId);
|
|
2767
|
+
if (!oldEntry) {
|
|
2768
|
+
drafts.push({type: OPERATION_TYPES.RECORD_CREATE, payload: {folderId, record}});
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (oldEntry.folderId !== folderId) {
|
|
2772
|
+
drafts.push({
|
|
2773
|
+
type: OPERATION_TYPES.RECORD_MOVE,
|
|
2774
|
+
payload: {recordId, targetFolderId: folderId}
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
if (rtlComparableJson(oldEntry.record) !== rtlComparableJson(record)) {
|
|
2778
|
+
drafts.push({type: OPERATION_TYPES.RECORD_UPDATE, payload: {recordId, patch: record}});
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
previousRecords.forEach((entry, recordId) => {
|
|
2783
|
+
if (!nextRecords.has(recordId)) {
|
|
2784
|
+
drafts.push({type: OPERATION_TYPES.RECORD_DELETE, payload: {recordId, deletedAt: now}});
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
previousFolders.forEach((folder, folderId) => {
|
|
2789
|
+
if (!RTL_PROTECTED_FOLDER_IDS.has(folderId) && !nextFolders.has(folderId)) {
|
|
2790
|
+
drafts.push({type: OPERATION_TYPES.FOLDER_DELETE, payload: {folderId}});
|
|
2791
|
+
}
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
if (rtlComparableJson(previous.folderOrder) !== rtlComparableJson(next.folderOrder)) {
|
|
2795
|
+
drafts.push({type: OPERATION_TYPES.FOLDER_REORDER, payload: {folderOrder: next.folderOrder}});
|
|
2796
|
+
}
|
|
2797
|
+
if (rtlComparableJson(previous.groupOrder) !== rtlComparableJson(next.groupOrder)) {
|
|
2798
|
+
drafts.push({type: OPERATION_TYPES.GROUP_REORDER, payload: {groupOrder: next.groupOrder}});
|
|
2799
|
+
}
|
|
2800
|
+
if (rtlComparableJson(previous.expandedGroups) !== rtlComparableJson(next.expandedGroups)) {
|
|
2801
|
+
drafts.push({
|
|
2802
|
+
type: OPERATION_TYPES.EXPANDED_GROUPS_UPDATE,
|
|
2803
|
+
payload: {expandedGroups: next.expandedGroups}
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
if (rtlComparableJson(previous.settings) !== rtlComparableJson(next.settings)) {
|
|
2807
|
+
drafts.push({type: OPERATION_TYPES.SETTINGS_UPDATE, payload: {patch: next.settings}});
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
const operations = drafts.map((draft, index) => ({
|
|
2811
|
+
id: `${operationIdPrefix}:${String(index).padStart(6, '0')}`,
|
|
2812
|
+
createdAt: now + index,
|
|
2813
|
+
...draft
|
|
2814
|
+
}));
|
|
2815
|
+
const boundedBatchSize = Math.max(1, Math.min(
|
|
2816
|
+
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
2817
|
+
Number.isSafeInteger(batchSize) ? batchSize : RTL_MAX_OPERATIONS_PER_REQUEST
|
|
2818
|
+
));
|
|
2819
|
+
const batches = [];
|
|
2820
|
+
for (let index = 0; index < operations.length; index += boundedBatchSize) {
|
|
2821
|
+
batches.push(operations.slice(index, index + boundedBatchSize));
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
const previousDocuments = buildFirestoreV2DocumentsFromState(previous, {now});
|
|
2825
|
+
const nextDocuments = buildFirestoreV2DocumentsFromState(next, {now});
|
|
2826
|
+
const changes = buildFirestoreV2DocumentChangeSet(previousDocuments, nextDocuments, {
|
|
2827
|
+
allowDeletes: true
|
|
2828
|
+
});
|
|
2829
|
+
return {operations, batches, changes, previousDocuments, nextDocuments};
|
|
2830
|
+
};
|
|
2831
|
+
|
|
2832
|
+
export default {
|
|
2205
2833
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
2834
|
+
RTL_SYNC_PROTOCOL_VERSION,
|
|
2835
|
+
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
2836
|
+
RTL_MAX_REQUEST_BYTES,
|
|
2837
|
+
RTL_MAX_TARGET_WRITES,
|
|
2206
2838
|
OPERATION_TYPES,
|
|
2207
2839
|
RECORD_TIMELABEL_SYNC_MODES,
|
|
2208
2840
|
RECORD_TIMELABEL_CLOUD_SCHEMAS,
|
|
@@ -2227,6 +2859,12 @@ export default {
|
|
|
2227
2859
|
buildFirestoreV2DocumentsFromState,
|
|
2228
2860
|
buildFirestoreV2DocumentChangeSet,
|
|
2229
2861
|
buildStateFromFirestoreV2Documents,
|
|
2862
|
+
validateRecordTimeLabelOperationBatch,
|
|
2863
|
+
buildFirestoreV2OperationReadPlan,
|
|
2864
|
+
extendFirestoreV2OperationReadPlanWithRecords,
|
|
2865
|
+
planFirestoreV2OperationChanges,
|
|
2866
|
+
estimateFirestoreV2WriteUnits,
|
|
2867
|
+
buildOperationsFromSnapshotDiff,
|
|
2230
2868
|
flushPendingOperations,
|
|
2231
2869
|
mergeRemoteStateIntoLocal,
|
|
2232
2870
|
applyOperation,
|