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