@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.
Files changed (3) hide show
  1. package/README.md +58 -51
  2. package/package.json +20 -20
  3. 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.10';
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 JSON.parse(JSON.stringify(value));
48
- };
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
-
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
- export default {
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,