@recordtimelabel/core 0.1.9 → 0.2.0

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