retold-data-service 2.1.2 → 2.1.6

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 (84) hide show
  1. package/.github/workflows/publish-image.yml +2 -2
  2. package/BUILDING-AND-PUBLISHING.md +6 -6
  3. package/CONTRIBUTING.md +1 -1
  4. package/Dockerfile +1 -1
  5. package/README.md +12 -27
  6. package/build-all.js +66 -0
  7. package/diagrams/architecture.excalidraw +2966 -0
  8. package/diagrams/architecture.mmd +17 -0
  9. package/diagrams/architecture.svg +2 -0
  10. package/docs/README.md +12 -12
  11. package/docs/_brand.json +18 -0
  12. package/docs/_cover.md +1 -1
  13. package/docs/_topbar.md +1 -1
  14. package/docs/_version.json +3 -3
  15. package/docs/api/reference.md +8 -8
  16. package/docs/architecture.md +6 -84
  17. package/docs/diagrams/component-diagram.excalidraw +2807 -0
  18. package/docs/diagrams/component-diagram.mmd +14 -0
  19. package/docs/diagrams/component-diagram.svg +2 -0
  20. package/docs/diagrams/component-stack.excalidraw +1169 -0
  21. package/docs/diagrams/component-stack.mmd +6 -0
  22. package/docs/diagrams/component-stack.svg +2 -0
  23. package/docs/diagrams/hook-execution-order.excalidraw +3230 -0
  24. package/docs/diagrams/hook-execution-order.mmd +19 -0
  25. package/docs/diagrams/hook-execution-order.svg +2 -0
  26. package/docs/diagrams/initialization-flow.excalidraw +1800 -0
  27. package/docs/diagrams/initialization-flow.mmd +22 -0
  28. package/docs/diagrams/initialization-flow.svg +2 -0
  29. package/docs/index.html +6 -7
  30. package/docs/lifecycle-hooks.md +2 -21
  31. package/docs/retold-catalog.json +141 -141
  32. package/docs/retold-keyword-index.json +6818 -1608
  33. package/package.json +130 -96
  34. package/source/services/RetoldDataService-Brand.js +13 -0
  35. package/source/services/comprehension-loader/pict-app/Pict-Application-ComprehensionLoader.js +65 -15
  36. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Layout.js +28 -74
  37. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Load.js +17 -17
  38. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-SettingsPanel.js +62 -0
  39. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Shell.js +142 -0
  40. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-StatusBar.js +125 -0
  41. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-StatusDetail.js +89 -0
  42. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-TopBar-Nav.js +42 -0
  43. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-TopBar-User.js +48 -0
  44. package/source/services/comprehension-loader/web/comprehension-loader.js +5517 -6183
  45. package/source/services/comprehension-loader/web/comprehension-loader.js.map +1 -1
  46. package/source/services/comprehension-loader/web/comprehension-loader.min.js +75 -1
  47. package/source/services/comprehension-loader/web/comprehension-loader.min.js.map +1 -1
  48. package/source/services/comprehension-loader/web/favicons/favicon-dark.svg +13 -0
  49. package/source/services/comprehension-loader/web/favicons/favicon-light.svg +13 -0
  50. package/source/services/comprehension-loader/web/favicons/favicon.svg +13 -0
  51. package/source/services/comprehension-loader/web/index.html +3 -0
  52. package/source/services/comprehension-loader/web/pict.min.js +12 -0
  53. package/source/services/data-cloner/DataCloner-Command-Headless.js +102 -4
  54. package/source/services/data-cloner/DataCloner-Command-Schema.js +105 -8
  55. package/source/services/data-cloner/DataCloner-Command-Sync.js +111 -75
  56. package/source/services/data-cloner/pict-app/Pict-Application-DataCloner.js +70 -47
  57. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Export.js +3 -3
  58. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Layout.js +40 -86
  59. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-SettingsPanel.js +61 -0
  60. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Shell.js +136 -0
  61. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-StatusBar.js +117 -0
  62. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-StatusDetail.js +81 -0
  63. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Sync.js +18 -18
  64. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-TopBar-Nav.js +42 -0
  65. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-TopBar-User.js +48 -0
  66. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-ViewData.js +2 -2
  67. package/source/services/data-cloner/web/data-cloner.js +5874 -7986
  68. package/source/services/data-cloner/web/data-cloner.js.map +1 -1
  69. package/source/services/data-cloner/web/data-cloner.min.js +75 -1
  70. package/source/services/data-cloner/web/data-cloner.min.js.map +1 -1
  71. package/source/services/data-cloner/web/favicons/favicon-dark.svg +13 -0
  72. package/source/services/data-cloner/web/favicons/favicon-light.svg +13 -0
  73. package/source/services/data-cloner/web/favicons/favicon.svg +13 -0
  74. package/source/services/data-cloner/web/favicons/favicons/favicon-dark.svg +13 -0
  75. package/source/services/data-cloner/web/favicons/favicons/favicon-light.svg +13 -0
  76. package/source/services/data-cloner/web/favicons/favicons/favicon.svg +13 -0
  77. package/source/services/data-cloner/web/index.html +3 -0
  78. package/source/services/data-cloner/web/pict.min.js +12 -0
  79. package/test/Bundles_smoke_tests.js +43 -0
  80. package/test/ComprehensionLoader_smoke_tests.js +95 -0
  81. package/test/DataCloner-RuntimeOverrides_tests.js +344 -0
  82. package/test/DataCloner_smoke_tests.js +87 -0
  83. package/tools/smoke-converge-endpoint.js +190 -0
  84. package/docs/css/docuserve.css +0 -327
@@ -240,6 +240,46 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
240
240
  });
241
241
  };
242
242
 
243
+ // Step 5b: Converge each deployed table's indexes to the clone index policy.
244
+ //
245
+ // Historically nothing in the headless pipeline touched indices, so headless
246
+ // clones ran with just the clustered primary key — leaving the
247
+ // OngoingEventualConsistency Deleted-filtered range counts clustered-scanning
248
+ // wide rows, which can stall large clones. This step declaratively brings the
249
+ // clone's indexes in line with the desired set (standard operational indexes +
250
+ // any Sync.IndexPolicy.TableIndexes extras): create missing, then drop
251
+ // undeclared per prune scope (default 'managed' — only policy-owned / legacy
252
+ // artifacts, never a hand-authored index). Create-before-drop and
253
+ // no-loss-on-failure keep coverage intact. It must never block the sync, so a
254
+ // failure here is logged and the pipeline proceeds. Opt out with
255
+ // Sync.CreateOperationalIndices=false or Sync.IndexPolicy.Enabled=false.
256
+ let fStep5b_ConvergeIndices = (fNext) =>
257
+ {
258
+ let tmpSync = pConfig.Sync || {};
259
+ let tmpIndexPolicy = (tmpSync.IndexPolicy && typeof(tmpSync.IndexPolicy) === 'object') ? tmpSync.IndexPolicy : {};
260
+ if (tmpSync.CreateOperationalIndices === false || tmpIndexPolicy.Enabled === false)
261
+ {
262
+ tmpFable.log.info('Headless: Index convergence disabled by config; skipping.');
263
+ return fNext();
264
+ }
265
+
266
+ let tmpTables = pConfig.Tables || [];
267
+ tmpFable.log.info(`Headless: Converging indexes on ${tmpTables.length > 0 ? tmpTables.length + ' selected' : 'all'} tables (prune scope: ${tmpIndexPolicy.PruneScope || 'managed'})...`);
268
+ fPost(`${tmpPrefix}/schema/indices/converge`, { Tables: tmpTables, IndexPolicy: tmpIndexPolicy },
269
+ (pError, pData) =>
270
+ {
271
+ if (pError || !pData || !pData.Success)
272
+ {
273
+ tmpFable.log.warn(`Headless: Index convergence reported a problem (continuing to sync): ${pError || (pData && pData.Error) || 'Unknown error'}`);
274
+ }
275
+ else
276
+ {
277
+ tmpFable.log.info(`Headless: ${pData.Message}`);
278
+ }
279
+ return fNext();
280
+ });
281
+ };
282
+
243
283
  // Step 6: Start sync
244
284
  let fStep6_Sync = (fNext) =>
245
285
  {
@@ -253,7 +293,9 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
253
293
  MaxRecordsPerEntity: tmpMaxRecords || tmpSync.MaxRecordsPerEntity || 0,
254
294
  DateTimePrecisionMS: tmpSync.DateTimePrecisionMS,
255
295
  BackSyncTimeLimit: tmpSync.BackSyncTimeLimit,
256
- TrueUpPageSize: tmpSync.TrueUpPageSize
296
+ TrueUpPageSize: tmpSync.TrueUpPageSize,
297
+ SyncRecordConcurrency: tmpSync.SyncRecordConcurrency,
298
+ SyncEntityOptions: tmpSync.SyncEntityOptions
257
299
  });
258
300
 
259
301
  tmpFable.log.info(`Headless: Starting ${tmpSyncBody.SyncMode} sync...`);
@@ -267,6 +309,38 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
267
309
  }
268
310
  tmpFable.log.info(`Headless: ${pData.Message}`);
269
311
 
312
+ // Stall detection. Synced/Total freezes during the (time-budgeted)
313
+ // delete phase, so a healthy delete pass looks identical to a hung
314
+ // one from the poll's vantage point. Only treat it as a stall when
315
+ // NOTHING in the status changes for well beyond one phase budget —
316
+ // default max(2x BackSyncTimeLimit, 20 min) — then exit non-zero so
317
+ // the Docker restart loop can recover (cursor/progress state persists
318
+ // on the mounted volume). Configure via Sync.StallTimeoutMs; set it
319
+ // to 0 to disable.
320
+ let tmpStallConfigured = parseInt(tmpSync.StallTimeoutMs, 10);
321
+ let tmpStallTimeoutMs;
322
+ if (tmpStallConfigured === 0)
323
+ {
324
+ tmpStallTimeoutMs = 0;
325
+ }
326
+ else if (!isNaN(tmpStallConfigured) && tmpStallConfigured > 0)
327
+ {
328
+ tmpStallTimeoutMs = tmpStallConfigured;
329
+ }
330
+ else
331
+ {
332
+ let tmpBudget = parseInt(tmpSync.BackSyncTimeLimit, 10);
333
+ if (isNaN(tmpBudget) || tmpBudget <= 0)
334
+ {
335
+ tmpBudget = 600000;
336
+ }
337
+ tmpStallTimeoutMs = Math.max(2 * tmpBudget, 1200000);
338
+ }
339
+ let fLogStall = (typeof(tmpFable.log.fatal) === 'function') ? tmpFable.log.fatal.bind(tmpFable.log) : tmpFable.log.error.bind(tmpFable.log);
340
+ let tmpLastProgressSignature = null;
341
+ let tmpLastProgressAtMs = Date.now();
342
+ tmpFable.log.info(`Headless: Stall detector ${tmpStallTimeoutMs > 0 ? 'armed at ' + (tmpStallTimeoutMs / 60000).toFixed(1) + ' min of no observable progress' : 'disabled'}.`);
343
+
270
344
  // Poll for completion
271
345
  let fPoll = () =>
272
346
  {
@@ -290,6 +364,27 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
290
364
  let tmpA = tmpTables[tmpActive[0]];
291
365
  tmpFable.log.info(`Headless: [${tmpDone.length}/${tmpNames.length}] Syncing ${tmpActive[0]}: ${tmpA.Synced}/${tmpA.Total}`);
292
366
  }
367
+
368
+ // Advance the stall clock only when the observable status
369
+ // changes (any table's Status or Synced/Total counters).
370
+ let tmpProgressSignature = tmpNames.slice().sort().map((pName) =>
371
+ {
372
+ let tmpT = tmpTables[pName];
373
+ return `${pName}:${tmpT.Status}:${tmpT.Synced}/${tmpT.Total}`;
374
+ }).join('|');
375
+ let tmpNowMs = Date.now();
376
+ if (tmpProgressSignature !== tmpLastProgressSignature)
377
+ {
378
+ tmpLastProgressSignature = tmpProgressSignature;
379
+ tmpLastProgressAtMs = tmpNowMs;
380
+ }
381
+ else if (tmpStallTimeoutMs > 0 && (tmpNowMs - tmpLastProgressAtMs) >= tmpStallTimeoutMs)
382
+ {
383
+ let tmpStalledMin = ((tmpNowMs - tmpLastProgressAtMs) / 60000).toFixed(1);
384
+ fLogStall(`Headless: STALL DETECTED — sync status unchanged for ${tmpStalledMin} min (threshold ${(tmpStallTimeoutMs / 60000).toFixed(1)} min). Active: [${tmpActive.join(', ') || 'none'}], ${tmpDone.length}/${tmpNames.length} tables done. Exiting non-zero so the container restart loop can recover; cursor/progress state persists on the mounted volume.`);
385
+ return process.exit(1);
386
+ }
387
+
293
388
  return setTimeout(fPoll, 5000);
294
389
  }
295
390
 
@@ -346,10 +441,13 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
346
441
  {
347
442
  fStep5_Deploy(() =>
348
443
  {
349
- fStep6_Sync(() =>
444
+ fStep5b_ConvergeIndices(() =>
350
445
  {
351
- tmpFable.log.info('Headless: Pipeline complete.');
352
- return fCallback();
446
+ fStep6_Sync(() =>
447
+ {
448
+ tmpFable.log.info('Headless: Pipeline complete.');
449
+ return fCallback();
450
+ });
353
451
  });
354
452
  });
355
453
  });
@@ -16,6 +16,8 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
16
16
 
17
17
  let libFs = require('fs');
18
18
  let _ProviderRegistry = require('./DataCloner-ProviderRegistry.js');
19
+ let libIndexPolicy = require('meadow-integration/source/services/clone/Meadow-Service-IndexPolicy.js');
20
+ let libIndexConvergence = require('meadow-integration/source/services/clone/Meadow-Service-IndexConvergence.js');
19
21
 
20
22
  // POST /clone/schema/fetch
21
23
  // Accepts either:
@@ -889,7 +891,7 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
889
891
  let tmpTableNames = tmpFilterTables || Object.keys(tmpModelTables);
890
892
  let tmpCreatedIndices = [];
891
893
 
892
- tmpFable.log.info(`Data Cloner: Creating missing GUID indices for ${tmpTableNames.length} tables...`);
894
+ tmpFable.log.info(`Data Cloner: Creating missing operational indices (GUID + sync) for ${tmpTableNames.length} tables...`);
893
895
 
894
896
  tmpFable.Utility.eachLimit(tmpTableNames, 1,
895
897
  (pTableName, fNextTable) =>
@@ -903,10 +905,15 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
903
905
  // Generate all index statements for this table (provider-specific)
904
906
  let tmpAllStatements = tmpSchemaProvider.generateCreateIndexStatements(tmpTableSchema);
905
907
 
906
- // Filter to GUID indices only (AK_M_GUID*)
907
- let tmpGUIDStatements = tmpAllStatements.filter((pStmt) => pStmt.Name && pStmt.Name.indexOf('AK_M_GUID') === 0);
908
+ // Filter to the clone's operational indices: GUID uniqueness
909
+ // (AK_M_GUID*) plus the standard sync indices (IX_M_SYNC*) that
910
+ // keep the OngoingEventualConsistency range-count / delete walks
911
+ // seekable. Application ForeignKey / Indexed-column indices are
912
+ // intentionally left out of the clone.
913
+ let tmpCloneIndexStatements = tmpAllStatements.filter((pStmt) => pStmt.Name &&
914
+ (pStmt.Name.indexOf('AK_M_GUID') === 0 || pStmt.Name.indexOf('IX_M_SYNC') === 0));
908
915
 
909
- if (tmpGUIDStatements.length < 1)
916
+ if (tmpCloneIndexStatements.length < 1)
910
917
  {
911
918
  return fNextTable();
912
919
  }
@@ -914,12 +921,12 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
914
921
  // createIndex is idempotent on all providers — safe to call even if index exists
915
922
  let fCreateNext = (pIndex) =>
916
923
  {
917
- if (pIndex >= tmpGUIDStatements.length)
924
+ if (pIndex >= tmpCloneIndexStatements.length)
918
925
  {
919
926
  return fNextTable();
920
927
  }
921
928
 
922
- let tmpStmt = tmpGUIDStatements[pIndex];
929
+ let tmpStmt = tmpCloneIndexStatements[pIndex];
923
930
  tmpSchemaProvider.createIndex(tmpStmt,
924
931
  (pCreateError) =>
925
932
  {
@@ -946,8 +953,8 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
946
953
  () =>
947
954
  {
948
955
  let tmpMessage = tmpCreatedIndices.length > 0
949
- ? `${tmpCreatedIndices.length} GUID index(es) created.`
950
- : 'No new GUID indices were needed.';
956
+ ? `${tmpCreatedIndices.length} operational index(es) created.`
957
+ : 'No new operational indices were needed.';
951
958
 
952
959
  tmpFable.log.info(`Data Cloner: ${tmpMessage}`);
953
960
 
@@ -960,4 +967,94 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
960
967
  return fNext();
961
968
  });
962
969
  });
970
+
971
+ // POST /clone/schema/indices/converge — Converge each deployed table's indexes
972
+ // to the clone index policy (create missing, then drop undeclared per prune
973
+ // scope). This is the authoritative, declarative index mechanism: the desired
974
+ // set = standard operational (Deleted composite + non-unique GUID) + any
975
+ // caller-declared per-table extras (body.IndexPolicy.TableIndexes).
976
+ pOratorServiceServer.post(`${tmpPrefix}/schema/indices/converge`,
977
+ (pRequest, pResponse, fNext) =>
978
+ {
979
+ if (!tmpCloneState.DeployedModelObject)
980
+ {
981
+ pResponse.send(400, { Success: false, Error: 'No schema deployed. Deploy tables first.' });
982
+ return fNext();
983
+ }
984
+
985
+ let tmpProviderName = tmpCloneState.ConnectionProvider;
986
+ let tmpProviderRegistryEntry = _ProviderRegistry[tmpProviderName];
987
+ let tmpActiveProvider = tmpProviderRegistryEntry ? tmpFable[tmpProviderRegistryEntry.serviceName] : null;
988
+
989
+ if (!tmpActiveProvider || !tmpActiveProvider.connected)
990
+ {
991
+ pResponse.send(400, { Success: false, Error: 'No connected provider available.' });
992
+ return fNext();
993
+ }
994
+
995
+ if (typeof(tmpActiveProvider.introspectTableIndices) !== 'function'
996
+ || typeof(tmpActiveProvider.generateCreateIndexStatements) !== 'function'
997
+ || typeof(tmpActiveProvider.createIndex) !== 'function'
998
+ || typeof(tmpActiveProvider.dropIndex) !== 'function')
999
+ {
1000
+ pResponse.send(400, { Success: false, Error: `Provider ${tmpProviderName} does not support index convergence.` });
1001
+ return fNext();
1002
+ }
1003
+
1004
+ let tmpBody = pRequest.body || {};
1005
+ let tmpIndexConfig = (tmpBody.IndexPolicy && typeof(tmpBody.IndexPolicy) === 'object') ? tmpBody.IndexPolicy : {};
1006
+ let tmpPruneScope = tmpIndexConfig.PruneScope || libIndexConvergence.PRUNE_MANAGED;
1007
+ let tmpFilterTables = Array.isArray(tmpBody.Tables) && tmpBody.Tables.length > 0 ? tmpBody.Tables : null;
1008
+
1009
+ let tmpModelTables = tmpCloneState.DeployedModelObject.Tables || {};
1010
+ let tmpTableNames = tmpFilterTables || Object.keys(tmpModelTables);
1011
+ let tmpResults = [];
1012
+
1013
+ tmpFable.log.info(`Data Cloner: Converging indexes for ${tmpTableNames.length} tables (prune scope: ${tmpPruneScope})...`);
1014
+
1015
+ tmpFable.Utility.eachLimit(tmpTableNames, 1,
1016
+ (pTableName, fNextTable) =>
1017
+ {
1018
+ let tmpTableSchema = tmpModelTables[pTableName];
1019
+ if (!tmpTableSchema)
1020
+ {
1021
+ return fNextTable();
1022
+ }
1023
+
1024
+ let tmpDesired = libIndexPolicy.resolveDesiredIndexes(tmpTableSchema, tmpIndexConfig);
1025
+ libIndexConvergence.convergeTableIndexes(tmpActiveProvider, tmpTableSchema, tmpDesired,
1026
+ { PruneScope: tmpPruneScope, log: tmpFable.log },
1027
+ (pConvergeError, pResult) =>
1028
+ {
1029
+ if (pConvergeError)
1030
+ {
1031
+ tmpFable.log.warn(`Data Cloner: Index convergence for ${pTableName} failed: ${pConvergeError}`);
1032
+ }
1033
+ else if (pResult && (pResult.created.length > 0 || pResult.dropped.length > 0 || pResult.skipped.length > 0))
1034
+ {
1035
+ tmpFable.log.info(`Data Cloner: ${pTableName} — created [${pResult.created.join(', ')}], dropped [${pResult.dropped.join(', ')}]${pResult.skipped.length > 0 ? `, skipped [${pResult.skipped.join(', ')}]` : ''}`);
1036
+ tmpResults.push(pResult);
1037
+ }
1038
+ return fNextTable();
1039
+ });
1040
+ },
1041
+ () =>
1042
+ {
1043
+ let tmpCreated = tmpResults.reduce((pSum, pR) => { return pSum + pR.created.length; }, 0);
1044
+ let tmpDropped = tmpResults.reduce((pSum, pR) => { return pSum + pR.dropped.length; }, 0);
1045
+ let tmpMessage = (tmpCreated > 0 || tmpDropped > 0)
1046
+ ? `Index convergence: ${tmpCreated} created, ${tmpDropped} dropped across ${tmpResults.length} table(s).`
1047
+ : 'Index convergence: all tables already match the desired index set.';
1048
+
1049
+ tmpFable.log.info(`Data Cloner: ${tmpMessage}`);
1050
+
1051
+ pResponse.send(200,
1052
+ {
1053
+ Success: true,
1054
+ Results: tmpResults,
1055
+ Message: tmpMessage
1056
+ });
1057
+ return fNext();
1058
+ });
1059
+ });
963
1060
  };
@@ -10,6 +10,110 @@
10
10
  const libFableLog = require('fable-log');
11
11
  const libPath = require('path');
12
12
 
13
+ // Runtime-overridable sync properties for the /clone/sync/start route.
14
+ // Each entry is a coerce function returning the value to apply, or null
15
+ // if the input is invalid. Drives both the top-level body overrides and
16
+ // the per-entity `SyncEntityOptions` overrides — adding a new knob is a
17
+ // one-line change here.
18
+ const _RuntimeSyncProperties = {
19
+ BackSyncTimeLimit: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
20
+ MaxRecordsPerEntity: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
21
+ DateTimePrecisionMS: (pVal) => { let tmpN = parseInt(pVal, 10); return !isNaN(tmpN) ? tmpN : null; },
22
+ TrueUpPageSize: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
23
+ SyncRecordConcurrency: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
24
+ UseAdvancedIDPagination: (pVal) => !!pVal,
25
+ SyncDeletedRecords: (pVal) => !!pVal
26
+ };
27
+
28
+ /**
29
+ * Apply runtime sync overrides from a request body to a live MeadowSync.
30
+ *
31
+ * Mutates `pMeadowSync.MeadowSyncEntities[*]` properties, plus mirrors the
32
+ * "base" globals onto the MeadowSync orchestrator and `cloneState.SyncDeletedRecords`.
33
+ * Top-level body keys form the base config; per-entity overrides under
34
+ * `pBody.SyncEntityOptions[<TableName>]` are layered on top (per-entity wins).
35
+ *
36
+ * Only properties listed in `_RuntimeSyncProperties` are honored; unknown keys
37
+ * and invalid values are silently ignored. Unknown entity names produce a warn.
38
+ *
39
+ * @param {Object} pBody - Request body (or any plain object with the same shape).
40
+ * @param {Object} pMeadowSync - MeadowSync orchestrator instance with `MeadowSyncEntities`.
41
+ * @param {Object} pCloneState - Clone-state object whose `SyncDeletedRecords` mirrors the base.
42
+ * @param {Object} pLog - Logger with `info(msg)` and `warn(msg)`.
43
+ */
44
+ const applyRuntimeSyncOverrides = (pBody, pMeadowSync, pCloneState, pLog) =>
45
+ {
46
+ // Compute the base runtime config from top-level body keys.
47
+ let tmpBase = {};
48
+ let tmpBaseSchemaKeys = Object.keys(_RuntimeSyncProperties);
49
+ for (let i = 0; i < tmpBaseSchemaKeys.length; i++)
50
+ {
51
+ let tmpKey = tmpBaseSchemaKeys[i];
52
+ if (!pBody.hasOwnProperty(tmpKey)) continue;
53
+ let tmpCoerced = _RuntimeSyncProperties[tmpKey](pBody[tmpKey]);
54
+ if (tmpCoerced === null) continue;
55
+ tmpBase[tmpKey] = tmpCoerced;
56
+ }
57
+
58
+ let tmpPerEntity = (pBody.SyncEntityOptions && typeof(pBody.SyncEntityOptions) === 'object') ? pBody.SyncEntityOptions : {};
59
+
60
+ // Mirror base values onto the MeadowSync orchestrator + cloneState.
61
+ let tmpBaseKeys = Object.keys(tmpBase);
62
+ for (let i = 0; i < tmpBaseKeys.length; i++)
63
+ {
64
+ if (pMeadowSync.hasOwnProperty(tmpBaseKeys[i]))
65
+ {
66
+ pMeadowSync[tmpBaseKeys[i]] = tmpBase[tmpBaseKeys[i]];
67
+ }
68
+ }
69
+ if (tmpBase.hasOwnProperty('SyncDeletedRecords'))
70
+ {
71
+ pCloneState.SyncDeletedRecords = tmpBase.SyncDeletedRecords;
72
+ }
73
+
74
+ // Apply base to every entity.
75
+ let tmpAllEntityNames = Object.keys(pMeadowSync.MeadowSyncEntities || {});
76
+ for (let i = 0; i < tmpAllEntityNames.length; i++)
77
+ {
78
+ let tmpEntity = pMeadowSync.MeadowSyncEntities[tmpAllEntityNames[i]];
79
+ for (let j = 0; j < tmpBaseKeys.length; j++)
80
+ {
81
+ tmpEntity[tmpBaseKeys[j]] = tmpBase[tmpBaseKeys[j]];
82
+ }
83
+ }
84
+
85
+ // Layer per-entity overrides on top. Log per entity that actually had
86
+ // overrides applied; warn on unknown entity names.
87
+ let tmpPerEntityNames = Object.keys(tmpPerEntity);
88
+ for (let i = 0; i < tmpPerEntityNames.length; i++)
89
+ {
90
+ let tmpEntityName = tmpPerEntityNames[i];
91
+ let tmpEntity = pMeadowSync.MeadowSyncEntities && pMeadowSync.MeadowSyncEntities[tmpEntityName];
92
+ if (!tmpEntity)
93
+ {
94
+ pLog.warn(`SyncEntityOptions: no sync entity ${tmpEntityName} loaded; ignoring overrides for it.`);
95
+ continue;
96
+ }
97
+ let tmpOverrides = tmpPerEntity[tmpEntityName];
98
+ if (!tmpOverrides || typeof(tmpOverrides) !== 'object') continue;
99
+ let tmpApplied = [];
100
+ let tmpOverrideKeys = Object.keys(tmpOverrides);
101
+ for (let j = 0; j < tmpOverrideKeys.length; j++)
102
+ {
103
+ let tmpKey = tmpOverrideKeys[j];
104
+ if (!_RuntimeSyncProperties.hasOwnProperty(tmpKey)) continue;
105
+ let tmpCoerced = _RuntimeSyncProperties[tmpKey](tmpOverrides[tmpKey]);
106
+ if (tmpCoerced === null) continue;
107
+ tmpEntity[tmpKey] = tmpCoerced;
108
+ tmpApplied.push(`${tmpKey}=${tmpCoerced}`);
109
+ }
110
+ if (tmpApplied.length > 0)
111
+ {
112
+ pLog.info(`SyncEntityOptions: ${tmpEntityName} ← ${tmpApplied.join(', ')}`);
113
+ }
114
+ }
115
+ };
116
+
13
117
  module.exports = (pDataClonerService, pOratorServiceServer) =>
14
118
  {
15
119
  let tmpFable = pDataClonerService.fable;
@@ -43,81 +147,9 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
43
147
  let tmpRequestedMode = tmpBody.SyncMode || 'Initial';
44
148
  let tmpMaxRecords = parseInt(tmpBody.MaxRecordsPerEntity, 10) || 0;
45
149
 
46
- // Update SyncDeletedRecords from request if provided
47
- if (tmpBody.hasOwnProperty('SyncDeletedRecords'))
48
- {
49
- tmpCloneState.SyncDeletedRecords = !!tmpBody.SyncDeletedRecords;
50
- let tmpEntityNames = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
51
- for (let i = 0; i < tmpEntityNames.length; i++)
52
- {
53
- tmpFable.MeadowSync.MeadowSyncEntities[tmpEntityNames[i]].SyncDeletedRecords = tmpCloneState.SyncDeletedRecords;
54
- }
55
- }
56
-
57
- // Update MaxRecordsPerEntity on sync entities
58
- if (tmpMaxRecords > 0)
59
- {
60
- let tmpEntityNames = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
61
- for (let i = 0; i < tmpEntityNames.length; i++)
62
- {
63
- tmpFable.MeadowSync.MeadowSyncEntities[tmpEntityNames[i]].MaxRecordsPerEntity = tmpMaxRecords;
64
- }
65
- }
66
-
67
- // Update UseAdvancedIDPagination on all sync entities
68
- if (tmpBody.hasOwnProperty('UseAdvancedIDPagination'))
69
- {
70
- let tmpEntityNames = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
71
- for (let i = 0; i < tmpEntityNames.length; i++)
72
- {
73
- tmpFable.MeadowSync.MeadowSyncEntities[tmpEntityNames[i]].UseAdvancedIDPagination = !!tmpBody.UseAdvancedIDPagination;
74
- }
75
- }
76
-
77
- // Update DateTimePrecisionMS on MeadowSync and all sync entities
78
- if (tmpBody.hasOwnProperty('DateTimePrecisionMS'))
79
- {
80
- let tmpPrecision = parseInt(tmpBody.DateTimePrecisionMS, 10);
81
- if (!isNaN(tmpPrecision))
82
- {
83
- tmpFable.MeadowSync.DateTimePrecisionMS = tmpPrecision;
84
- let tmpEntityNames = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
85
- for (let i = 0; i < tmpEntityNames.length; i++)
86
- {
87
- tmpFable.MeadowSync.MeadowSyncEntities[tmpEntityNames[i]].DateTimePrecisionMS = tmpPrecision;
88
- }
89
- }
90
- }
91
-
92
- // Update BackSyncTimeLimit on MeadowSync and all sync entities
93
- if (tmpBody.hasOwnProperty('BackSyncTimeLimit'))
94
- {
95
- let tmpBackSyncTimeLimit = parseInt(tmpBody.BackSyncTimeLimit, 10);
96
- if (!isNaN(tmpBackSyncTimeLimit) && tmpBackSyncTimeLimit > 0)
97
- {
98
- tmpFable.MeadowSync.BackSyncTimeLimit = tmpBackSyncTimeLimit;
99
- let tmpEntityNames = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
100
- for (let i = 0; i < tmpEntityNames.length; i++)
101
- {
102
- tmpFable.MeadowSync.MeadowSyncEntities[tmpEntityNames[i]].BackSyncTimeLimit = tmpBackSyncTimeLimit;
103
- }
104
- }
105
- }
106
-
107
- // Update TrueUpPageSize on MeadowSync and all sync entities
108
- if (tmpBody.hasOwnProperty('TrueUpPageSize'))
109
- {
110
- let tmpTrueUpPageSize = parseInt(tmpBody.TrueUpPageSize, 10);
111
- if (!isNaN(tmpTrueUpPageSize) && tmpTrueUpPageSize > 0)
112
- {
113
- tmpFable.MeadowSync.TrueUpPageSize = tmpTrueUpPageSize;
114
- let tmpEntityNames = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
115
- for (let i = 0; i < tmpEntityNames.length; i++)
116
- {
117
- tmpFable.MeadowSync.MeadowSyncEntities[tmpEntityNames[i]].TrueUpPageSize = tmpTrueUpPageSize;
118
- }
119
- }
120
- }
150
+ // Apply global + per-entity runtime sync overrides. See
151
+ // applyRuntimeSyncOverrides() at module scope.
152
+ applyRuntimeSyncOverrides(tmpBody, tmpFable.MeadowSync, tmpCloneState, tmpFable.log);
121
153
 
122
154
  // If no tables specified, sync all entities
123
155
  if (tmpSelectedTables.length === 0)
@@ -579,3 +611,7 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
579
611
  return fNext();
580
612
  });
581
613
  };
614
+
615
+ // Exposed for unit tests.
616
+ module.exports._RuntimeSyncProperties = _RuntimeSyncProperties;
617
+ module.exports.applyRuntimeSyncOverrides = applyRuntimeSyncOverrides;