@recordtimelabel/core 0.1.3 → 0.1.5

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +279 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -21,6 +21,13 @@ export const OPERATION_TYPES = Object.freeze({
21
21
  EXPANDED_GROUPS_UPDATE: 'expandedGroups.update',
22
22
  SETTINGS_UPDATE: 'settings.update'
23
23
  });
24
+
25
+ const ORDER_OPERATION_TYPES = new Set([
26
+ OPERATION_TYPES.RECORD_REORDER,
27
+ OPERATION_TYPES.FOLDER_REORDER,
28
+ OPERATION_TYPES.GROUP_REORDER,
29
+ OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
30
+ ]);
24
31
 
25
32
  export const RECORD_TIMELABEL_SYNC_MODES = Object.freeze({
26
33
  FULL: 'full',
@@ -514,15 +521,53 @@ export const preserveRecordOrderFromLocal = (records = {}, localRecords = {}) =>
514
521
  return result;
515
522
  };
516
523
 
517
- export const createOperation = (type, payload = {}, options = {}) => ({
518
- id: options.id || `${type}:${options.clientId || 'client'}:${options.now || Date.now()}:${Math.random().toString(36).slice(2, 10)}`,
519
- type,
520
- payload,
521
- clientId: options.clientId || null,
522
- createdAt: options.now || Date.now()
523
- });
524
-
525
- export const applyOperation = (state = {}, operation = {}) => {
524
+ export const createOperation = (type, payload = {}, options = {}) => ({
525
+ id: options.id || `${type}:${options.clientId || 'client'}:${options.now || Date.now()}:${Math.random().toString(36).slice(2, 10)}`,
526
+ type,
527
+ payload,
528
+ clientId: options.clientId || null,
529
+ createdAt: options.now || Date.now()
530
+ });
531
+
532
+ const getOperationClientInstanceId = (operation = {}, payload = {}) => (
533
+ operation.clientInstanceId ||
534
+ operation.instanceId ||
535
+ payload.clientInstanceId ||
536
+ payload.instanceId ||
537
+ null
538
+ );
539
+
540
+ const buildOperationSyncMeta = (previousSyncMeta = {}, operation = {}, payload = {}, operationTime) => {
541
+ const nextSyncMeta = {
542
+ ...(previousSyncMeta || {}),
543
+ lastOperationId: operation.id || previousSyncMeta?.lastOperationId || null,
544
+ lastOperationType: operation.type || previousSyncMeta?.lastOperationType || null,
545
+ lastClientId: operation.clientId || previousSyncMeta?.lastClientId || null,
546
+ lastOperationAt: operationTime
547
+ };
548
+ const nextClientInstanceId = getOperationClientInstanceId(operation, payload);
549
+
550
+ if (nextClientInstanceId) {
551
+ nextSyncMeta.lastClientInstanceId = nextClientInstanceId;
552
+ } else if (operation.clientId) {
553
+ delete nextSyncMeta.lastClientInstanceId;
554
+ }
555
+
556
+ if (ORDER_OPERATION_TYPES.has(operation.type)) {
557
+ nextSyncMeta.lastOrderOperationAtByType = {
558
+ ...(previousSyncMeta?.lastOrderOperationAtByType || {}),
559
+ [operation.type]: operationTime
560
+ };
561
+ nextSyncMeta.lastOrderOperationIdByType = {
562
+ ...(previousSyncMeta?.lastOrderOperationIdByType || {}),
563
+ [operation.type]: operation.id || null
564
+ };
565
+ }
566
+
567
+ return nextSyncMeta;
568
+ };
569
+
570
+ export const applyOperation = (state = {}, operation = {}) => {
526
571
  const normalized = normalizeState(state);
527
572
  const type = operation.type;
528
573
  const payload = operation.payload || {};
@@ -723,16 +768,15 @@ export const applyOperation = (state = {}, operation = {}) => {
723
768
 
724
769
  default:
725
770
  return normalized;
726
- }
727
-
728
- nextState.lastModified = Math.max(nextState.lastModified || 0, operationTime);
729
- nextState.rtlSyncMeta = {
730
- ...nextState.rtlSyncMeta,
731
- lastOperationId: operation.id || nextState.rtlSyncMeta.lastOperationId || null,
732
- lastOperationType: operation.type || nextState.rtlSyncMeta.lastOperationType || null,
733
- lastClientId: operation.clientId || nextState.rtlSyncMeta.lastClientId || null,
734
- lastOperationAt: operationTime
735
- };
771
+ }
772
+
773
+ nextState.lastModified = Math.max(nextState.lastModified || 0, operationTime);
774
+ nextState.rtlSyncMeta = buildOperationSyncMeta(
775
+ nextState.rtlSyncMeta,
776
+ operation,
777
+ payload,
778
+ operationTime
779
+ );
736
780
 
737
781
  return normalizeState(nextState);
738
782
  };
@@ -989,6 +1033,146 @@ const mapValuesArray = (value = {}) => {
989
1033
  return Object.values(value);
990
1034
  };
991
1035
 
1036
+ const buildLiveV2RecordIdsByFolder = (recordDocuments = {}) => {
1037
+ const liveRecordIdsByFolder = {};
1038
+ const liveRecordIdSetsByFolder = {};
1039
+
1040
+ Object.values(recordDocuments || {}).forEach((recordDoc) => {
1041
+ const recordId = normalizeId(recordDoc?.id);
1042
+ const folderId = safeFolderId(recordDoc?.folderId);
1043
+ if (!recordId) return;
1044
+
1045
+ if (!liveRecordIdsByFolder[folderId]) {
1046
+ liveRecordIdsByFolder[folderId] = [];
1047
+ liveRecordIdSetsByFolder[folderId] = new Set();
1048
+ }
1049
+
1050
+ if (liveRecordIdSetsByFolder[folderId].has(recordId)) return;
1051
+ liveRecordIdSetsByFolder[folderId].add(recordId);
1052
+ liveRecordIdsByFolder[folderId].push(recordId);
1053
+ });
1054
+
1055
+ return {
1056
+ liveRecordIdsByFolder,
1057
+ liveRecordIdSetsByFolder
1058
+ };
1059
+ };
1060
+
1061
+ const buildV2RecordOrderByFolder = (records = {}, recordDocuments = {}) => {
1062
+ const {
1063
+ liveRecordIdsByFolder,
1064
+ liveRecordIdSetsByFolder
1065
+ } = buildLiveV2RecordIdsByFolder(recordDocuments);
1066
+ const hasLiveRecordDocuments = Object.keys(recordDocuments || {}).length > 0;
1067
+ const orders = {};
1068
+ const seenByFolder = {};
1069
+
1070
+ Object.keys(liveRecordIdsByFolder).forEach((folderId) => {
1071
+ orders[folderId] = [];
1072
+ seenByFolder[folderId] = new Set();
1073
+ });
1074
+
1075
+ Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
1076
+ const normalizedFolderId = safeFolderId(folderId);
1077
+ if (hasLiveRecordDocuments && !liveRecordIdSetsByFolder[normalizedFolderId]) return;
1078
+
1079
+ const seen = seenByFolder[normalizedFolderId] || new Set();
1080
+ orders[normalizedFolderId] = orders[normalizedFolderId] || [];
1081
+
1082
+ toArray(folderRecords).forEach((record) => {
1083
+ const recordId = normalizeId(record?.id);
1084
+ if (!recordId || seen.has(recordId)) return;
1085
+ if (
1086
+ liveRecordIdSetsByFolder[normalizedFolderId] &&
1087
+ !liveRecordIdSetsByFolder[normalizedFolderId].has(recordId)
1088
+ ) {
1089
+ return;
1090
+ }
1091
+
1092
+ seen.add(recordId);
1093
+ orders[normalizedFolderId].push(recordId);
1094
+ });
1095
+
1096
+ seenByFolder[normalizedFolderId] = seen;
1097
+ });
1098
+
1099
+ Object.entries(liveRecordIdsByFolder).forEach(([folderId, liveRecordIds]) => {
1100
+ const seen = seenByFolder[folderId] || new Set();
1101
+ orders[folderId] = orders[folderId] || [];
1102
+
1103
+ liveRecordIds.forEach((recordId) => {
1104
+ if (seen.has(recordId)) return;
1105
+ seen.add(recordId);
1106
+ orders[folderId].push(recordId);
1107
+ });
1108
+ });
1109
+
1110
+ return orders;
1111
+ };
1112
+
1113
+ const extractV2RecordOrderByFolder = (folderDocuments = {}) => {
1114
+ const orders = {};
1115
+
1116
+ Object.entries(folderDocuments || {}).forEach(([rawFolderId, folderDoc]) => {
1117
+ const folderId = normalizeId(folderDoc?.id || rawFolderId);
1118
+ if (!folderId || !Array.isArray(folderDoc?.recordOrder)) return;
1119
+
1120
+ const seen = new Set();
1121
+ orders[folderId] = [];
1122
+
1123
+ folderDoc.recordOrder.forEach((recordId) => {
1124
+ const normalized = normalizeId(recordId);
1125
+ if (!normalized || seen.has(normalized)) return;
1126
+ seen.add(normalized);
1127
+ orders[folderId].push(normalized);
1128
+ });
1129
+ });
1130
+
1131
+ return orders;
1132
+ };
1133
+
1134
+ const reorderV2RecordsByFolderRecordOrder = (records = {}, recordOrderByFolder = {}) => {
1135
+ const nextRecords = {};
1136
+
1137
+ Object.entries(records || {}).forEach(([folderId, folderRecords]) => {
1138
+ const fallbackRecords = toArray(folderRecords);
1139
+ const recordOrder = recordOrderByFolder[folderId];
1140
+
1141
+ if (!Array.isArray(recordOrder)) {
1142
+ nextRecords[folderId] = fallbackRecords;
1143
+ return;
1144
+ }
1145
+
1146
+ const recordsById = new Map();
1147
+ fallbackRecords.forEach((record) => {
1148
+ const recordId = normalizeId(record?.id);
1149
+ if (recordId && !recordsById.has(recordId)) {
1150
+ recordsById.set(recordId, { ...record, id: recordId });
1151
+ }
1152
+ });
1153
+
1154
+ const seen = new Set();
1155
+ const orderedRecords = [];
1156
+ recordOrder.forEach((recordId) => {
1157
+ const record = recordsById.get(recordId);
1158
+ if (!record || seen.has(recordId)) return;
1159
+ seen.add(recordId);
1160
+ orderedRecords.push(record);
1161
+ });
1162
+
1163
+ fallbackRecords.forEach((record) => {
1164
+ const recordId = normalizeId(record?.id);
1165
+ if (!recordId || seen.has(recordId)) return;
1166
+ seen.add(recordId);
1167
+ orderedRecords.push(record);
1168
+ });
1169
+
1170
+ nextRecords[folderId] = orderedRecords;
1171
+ });
1172
+
1173
+ return nextRecords;
1174
+ };
1175
+
992
1176
  const cleanV2RecordDocument = (record = {}, folderId, now) => {
993
1177
  const recordId = normalizeId(record.id);
994
1178
  if (!recordId) return null;
@@ -1005,8 +1189,15 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
1005
1189
  const cleanV2FolderDocument = (folder = {}, now) => {
1006
1190
  const folderId = normalizeId(folder.id);
1007
1191
  if (!folderId) return null;
1192
+ const {
1193
+ schemaVersion,
1194
+ recordOrder,
1195
+ groupOrder,
1196
+ folderOrder,
1197
+ ...folderFields
1198
+ } = folder || {};
1008
1199
  return {
1009
- ...folder,
1200
+ ...folderFields,
1010
1201
  id: folderId,
1011
1202
  updatedAt: folder.updatedAt || folder.createdAt || now,
1012
1203
  schemaVersion: 2
@@ -1035,10 +1226,16 @@ export const buildFirestoreV2DocumentsFromState = (state = {}, options = {}) =>
1035
1226
  });
1036
1227
  });
1037
1228
 
1229
+ const recordOrderByFolder = buildV2RecordOrderByFolder(normalized.records || {}, records);
1038
1230
  const folders = {};
1039
1231
  toArray(normalized.folders).forEach((folder) => {
1040
1232
  const folderDoc = cleanV2FolderDocument(folder, now);
1041
- if (folderDoc) folders[folderDoc.id] = folderDoc;
1233
+ if (folderDoc) {
1234
+ folders[folderDoc.id] = {
1235
+ ...folderDoc,
1236
+ recordOrder: recordOrderByFolder[folderDoc.id] || []
1237
+ };
1238
+ }
1042
1239
  });
1043
1240
 
1044
1241
  const ops = {};
@@ -1094,11 +1291,12 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1094
1291
  ];
1095
1292
  });
1096
1293
 
1294
+ const recordOrderByFolder = extractV2RecordOrderByFolder(documents?.folders);
1097
1295
  const folders = mapValuesArray(documents?.folders)
1098
1296
  .map((folderDoc) => {
1099
1297
  const folderId = normalizeId(folderDoc?.id);
1100
1298
  if (!folderId) return null;
1101
- const { schemaVersion, ...folder } = folderDoc;
1299
+ const { schemaVersion, recordOrder, groupOrder, folderOrder, ...folder } = folderDoc;
1102
1300
  return { ...folder, id: folderId };
1103
1301
  })
1104
1302
  .filter(Boolean);
@@ -1114,7 +1312,7 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
1114
1312
 
1115
1313
  const state = normalizeState({
1116
1314
  folders,
1117
- records,
1315
+ records: reorderV2RecordsByFolderRecordOrder(records, recordOrderByFolder),
1118
1316
  settings: rootDoc.settings || {},
1119
1317
  groupOrder: normalizeGroupOrderForOption(rootDoc.groupOrder, options.groupOrderNormalizer),
1120
1318
  folderOrder: rootDoc.folderOrder || [],
@@ -1180,6 +1378,12 @@ const getLastLocalOperationType = (data = {}) => String(
1180
1378
  ''
1181
1379
  );
1182
1380
 
1381
+ const getLastRemoteOperationType = (data = {}) => String(
1382
+ data?.rtlSyncMeta?.lastOperationType ||
1383
+ data?.syncMeta?.lastRemoteOperationType ||
1384
+ ''
1385
+ );
1386
+
1183
1387
  const getLastLocalOperationAt = (data = {}) => toFiniteTimestamp(
1184
1388
  data?.rtlSyncMeta?.lastOperationAt ||
1185
1389
  data?.rtlSyncMeta?.lastLocalOperationAt ||
@@ -1193,23 +1397,70 @@ const getLastRemoteOperationAt = (data = {}) => toFiniteTimestamp(
1193
1397
  data?.lastModified
1194
1398
  );
1195
1399
 
1400
+ const getLastClientId = (data = {}) => String(
1401
+ data?.rtlSyncMeta?.lastClientId ||
1402
+ data?.syncMeta?.lastClientId ||
1403
+ data?.syncMeta?.lastMergedClientId ||
1404
+ ''
1405
+ );
1406
+
1407
+ const getOrderOperationAtByType = (data = {}, operationType) => toFiniteTimestamp(
1408
+ data?.rtlSyncMeta?.lastOrderOperationAtByType?.[operationType] ||
1409
+ data?.syncMeta?.lastOrderOperationAtByType?.[operationType]
1410
+ );
1411
+
1412
+ const hasOperationMarker = (data = {}, operationType, operationTypeResolver = getLastRemoteOperationType) => {
1413
+ const lastOperationId = String(data?.rtlSyncMeta?.lastOperationId || '');
1414
+ const lastOperationType = operationTypeResolver(data);
1415
+ return lastOperationId.startsWith(`${operationType}:`) || lastOperationType === operationType;
1416
+ };
1417
+
1418
+ const getMarkedOrderOperationAt = (data = {}, operationType, {
1419
+ operationTypeResolver = getLastRemoteOperationType,
1420
+ operationAtResolver = getLastRemoteOperationAt
1421
+ } = {}) => {
1422
+ const operationAtByType = getOrderOperationAtByType(data, operationType);
1423
+ if (operationAtByType) return operationAtByType;
1424
+ return hasOperationMarker(data, operationType, operationTypeResolver)
1425
+ ? operationAtResolver(data)
1426
+ : 0;
1427
+ };
1428
+
1196
1429
  const hasRecentLocalOperation = (localData = {}, remoteData = {}, operationType) => {
1197
- const lastOperationId = String(localData?.rtlSyncMeta?.lastOperationId || '');
1198
- const lastOperationType = getLastLocalOperationType(localData);
1199
- if (!lastOperationId.startsWith(`${operationType}:`) && lastOperationType !== operationType) {
1430
+ const localOperationAt = getMarkedOrderOperationAt(localData, operationType, {
1431
+ operationTypeResolver: getLastLocalOperationType,
1432
+ operationAtResolver: getLastLocalOperationAt
1433
+ });
1434
+ if (!localOperationAt) {
1200
1435
  return false;
1201
1436
  }
1202
1437
 
1203
- const localOperationAt = getLastLocalOperationAt(localData);
1204
- const remoteOperationAt = getLastRemoteOperationAt(remoteData);
1438
+ const remoteOrderOperationAt = getMarkedOrderOperationAt(remoteData, operationType, {
1439
+ operationTypeResolver: getLastRemoteOperationType,
1440
+ operationAtResolver: getLastRemoteOperationAt
1441
+ });
1442
+ const remoteOperationAt = remoteOrderOperationAt || getLastRemoteOperationAt(remoteData);
1205
1443
  return !localOperationAt || !remoteOperationAt || localOperationAt >= remoteOperationAt;
1206
1444
  };
1207
1445
 
1446
+ const hasRemoteOrderOperationFromDifferentClient = (localData = {}, remoteData = {}, operationType) => {
1447
+ if (!hasOperationMarker(remoteData, operationType, getLastRemoteOperationType)) {
1448
+ return false;
1449
+ }
1450
+
1451
+ const remoteClientId = getLastClientId(remoteData);
1452
+ return Boolean(remoteClientId) && remoteClientId !== getLastClientId(localData);
1453
+ };
1454
+
1208
1455
  const shouldPreferRecentLocalOrder = (localData = {}, remoteData = {}, operationType) => {
1209
1456
  if (hasRecentLocalOperation(localData, remoteData, operationType)) {
1210
1457
  return true;
1211
1458
  }
1212
1459
 
1460
+ if (hasRemoteOrderOperationFromDifferentClient(localData, remoteData, operationType)) {
1461
+ return false;
1462
+ }
1463
+
1213
1464
  const localOperationAt = getLastLocalOperationAt(localData);
1214
1465
  const remoteOperationAt = getLastRemoteOperationAt(remoteData);
1215
1466
  return Boolean(localOperationAt) && (!remoteOperationAt || localOperationAt >= remoteOperationAt);