retold-data-service 2.0.18 → 2.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retold-data-service",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "Serve up a whole model!",
5
5
  "main": "source/Retold-Data-Service.js",
6
6
  "bin": {
@@ -72,7 +72,7 @@
72
72
  "meadow": "^2.0.33",
73
73
  "meadow-connection-mysql": "^1.0.14",
74
74
  "meadow-endpoints": "^4.0.14",
75
- "meadow-integration": "^1.0.14",
75
+ "meadow-integration": "^1.0.16",
76
76
  "meadow-migrationmanager": "^0.0.4",
77
77
  "orator": "^6.0.4",
78
78
  "orator-http-proxy": "^1.0.5",
@@ -137,6 +137,8 @@ class RetoldDataServiceMeadowEndpoints extends libFableServiceProviderBase
137
137
  {
138
138
  let tmpDALEntityName = tmpEntityList[i];
139
139
 
140
+ let tmpRoutesAlreadyConnected = this._MeadowEndpoints.hasOwnProperty(tmpDALEntityName);
141
+
140
142
  if (this._DAL.hasOwnProperty(tmpDALEntityName))
141
143
  {
142
144
  this.fable.log.warn(`Entity [${tmpDALEntityName}] already exists in the DAL (from another model); overwriting.`);
@@ -152,8 +154,16 @@ class RetoldDataServiceMeadowEndpoints extends libFableServiceProviderBase
152
154
  this._DAL[tmpDALEntityName].setProvider(tmpStorageProvider);
153
155
  this.fable.log.info(`...initializing the ${tmpDALEntityName} Meadow Endpoints`);
154
156
  this._MeadowEndpoints[tmpDALEntityName] = libMeadowEndpoints.new(this._DAL[tmpDALEntityName]);
155
- this.fable.log.info(`...mapping the ${tmpDALEntityName} Meadow Endpoints to Orator`);
156
- this._MeadowEndpoints[tmpDALEntityName].connectRoutes(this.fable.OratorServiceServer);
157
+
158
+ if (!tmpRoutesAlreadyConnected)
159
+ {
160
+ this.fable.log.info(`...mapping the ${tmpDALEntityName} Meadow Endpoints to Orator`);
161
+ this._MeadowEndpoints[tmpDALEntityName].connectRoutes(this.fable.OratorServiceServer);
162
+ }
163
+ else
164
+ {
165
+ this.fable.log.info(`...routes for ${tmpDALEntityName} already registered; skipping connectRoutes.`);
166
+ }
157
167
  }
158
168
  catch (pError)
159
169
  {
@@ -274,10 +274,11 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
274
274
  // Extract the entity name from the URL for error tracking
275
275
  let tmpEntityHint = pURL.split('/')[0].replace(/s$/, '');
276
276
 
277
- // Use the longer timeout for MAX(column) queries only
278
- let tmpIsMaxRequest = (pURL.indexOf('/Max/') > -1);
277
+ // Use the longer timeout for Max and Count queries (which can be
278
+ // slow on large tables with millions of rows)
279
+ let tmpIsHeavyQuery = (pURL.indexOf('/Max/') > -1) || (pURL.match(/\/Count(\/|$)/) !== null);
279
280
  let tmpPreviousTimeout = libHttps.globalAgent.options.timeout;
280
- libHttps.globalAgent.options.timeout = tmpIsMaxRequest
281
+ libHttps.globalAgent.options.timeout = tmpIsHeavyQuery
281
282
  ? tmpFable.MeadowCloneRestClient.maxRequestTimeout
282
283
  : tmpFable.MeadowCloneRestClient.requestTimeout;
283
284
 
@@ -64,6 +64,16 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
64
64
  }
65
65
  }
66
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
+
67
77
  // Update DateTimePrecisionMS on MeadowSync and all sync entities
68
78
  if (tmpBody.hasOwnProperty('DateTimePrecisionMS'))
69
79
  {
@@ -173,7 +183,7 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
173
183
  let tmpReinitEntities = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
174
184
  tmpFable.log.info(`Data Cloner: Re-created ${tmpReinitEntities.length} sync entities in ${tmpRequestedMode} mode`);
175
185
 
176
- // Update SyncDeletedRecords and MaxRecordsPerEntity on new entities
186
+ // Update SyncDeletedRecords, MaxRecordsPerEntity, and UseAdvancedIDPagination on new entities
177
187
  for (let i = 0; i < tmpReinitEntities.length; i++)
178
188
  {
179
189
  tmpFable.MeadowSync.MeadowSyncEntities[tmpReinitEntities[i]].SyncDeletedRecords = tmpCloneState.SyncDeletedRecords;
@@ -181,6 +191,10 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
181
191
  {
182
192
  tmpFable.MeadowSync.MeadowSyncEntities[tmpReinitEntities[i]].MaxRecordsPerEntity = tmpMaxRecords;
183
193
  }
194
+ if (tmpBody.hasOwnProperty('UseAdvancedIDPagination'))
195
+ {
196
+ tmpFable.MeadowSync.MeadowSyncEntities[tmpReinitEntities[i]].UseAdvancedIDPagination = !!tmpBody.UseAdvancedIDPagination;
197
+ }
184
198
  }
185
199
 
186
200
  return fStartSync();
@@ -207,6 +221,13 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
207
221
  let tmpSnap = { Status: tmpP.Status, Total: tmpP.Total || 0, Synced: tmpP.Synced || 0, Errors: tmpP.Errors || 0 };
208
222
  if (tmpP.ErrorMessage) tmpSnap.ErrorMessage = tmpP.ErrorMessage;
209
223
 
224
+ // Include per-record breakdown fields if available (set after entity sync completes)
225
+ if (tmpP.hasOwnProperty('New')) tmpSnap.New = tmpP.New;
226
+ if (tmpP.hasOwnProperty('Updated')) tmpSnap.Updated = tmpP.Updated;
227
+ if (tmpP.hasOwnProperty('Unchanged')) tmpSnap.Unchanged = tmpP.Unchanged;
228
+ if (tmpP.hasOwnProperty('Deleted')) tmpSnap.Deleted = tmpP.Deleted;
229
+ if (tmpP.hasOwnProperty('ServerTotal')) tmpSnap.ServerTotal = tmpP.ServerTotal;
230
+
210
231
  if ((tmpP.Status === 'Syncing' || tmpP.Status === 'Pending') && tmpFable.MeadowSync && tmpFable.MeadowSync.MeadowSyncEntities)
211
232
  {
212
233
  let tmpSyncEntity = tmpFable.MeadowSync.MeadowSyncEntities[tmpName];
@@ -300,9 +321,15 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
300
321
  // Check for pre-count phase
301
322
  if (tmpCloneState.SyncPhase === 'counting')
302
323
  {
303
- let tmpPC = tmpCloneState.PreCountProgress || { Counted: 0, TotalTables: 0 };
324
+ let tmpPC = tmpCloneState.PreCountProgress || { Counted: 0, TotalTables: 0, Tables: [] };
304
325
  tmpMessage = `Analyzing tables: counted ${tmpPC.Counted} / ${tmpPC.TotalTables}...`;
305
326
 
327
+ let tmpCountElapsed = null;
328
+ if (tmpPC.StartTime)
329
+ {
330
+ tmpCountElapsed = fFormatDuration(Date.now() - tmpPC.StartTime);
331
+ }
332
+
306
333
  pResponse.send(200,
307
334
  {
308
335
  Phase: tmpPhase,
@@ -315,10 +342,10 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
315
342
  TotalTables: tmpPC.TotalTables,
316
343
  TotalSynced: 0,
317
344
  TotalRecords: 0,
318
- Elapsed: null,
345
+ Elapsed: tmpCountElapsed,
319
346
  SyncMode: tmpCloneState.SyncMode,
320
347
  ETA: null,
321
- PreCountGrandTotal: 0,
348
+ PreCountGrandTotal: tmpCloneState.PreCountGrandTotal || 0,
322
349
  PreCountProgress: tmpPC,
323
350
  ThroughputSamples: []
324
351
  });
@@ -10,7 +10,7 @@ const _ProviderRegistry = {
10
10
  SQLite: { moduleName: 'meadow-connection-sqlite', serviceName: 'MeadowSQLiteProvider', configKey: 'SQLite' },
11
11
  MySQL: { moduleName: 'meadow-connection-mysql', serviceName: 'MeadowMySQLProvider', configKey: 'MySQL' },
12
12
  MSSQL: { moduleName: 'meadow-connection-mssql', serviceName: 'MeadowMSSQLProvider', configKey: 'MSSQL' },
13
- PostgreSQL: { moduleName: 'meadow-connection-postgresql', serviceName: 'MeadowConnectionPostgreSQL', configKey: 'PostgreSQL' },
13
+ PostgreSQL: { moduleName: 'meadow-connection-postgresql', serviceName: 'MeadowPostgreSQLProvider', configKey: 'PostgreSQL' },
14
14
  Solr: { moduleName: 'meadow-connection-solr', serviceName: 'MeadowConnectionSolr', configKey: 'Solr' },
15
15
  MongoDB: { moduleName: 'meadow-connection-mongodb', serviceName: 'MeadowConnectionMongoDB', configKey: 'MongoDB' },
16
16
  RocksDB: { moduleName: 'meadow-connection-rocksdb', serviceName: 'MeadowConnectionRocksDB', configKey: 'RocksDB' },
@@ -642,7 +642,12 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
642
642
  preCountTables(pTables, fCallback)
643
643
  {
644
644
  this._cloneState.SyncPhase = 'counting';
645
- this._cloneState.PreCountProgress = { Counted: 0, TotalTables: pTables.length };
645
+ this._cloneState.PreCountProgress = {
646
+ Counted: 0,
647
+ TotalTables: pTables.length,
648
+ StartTime: Date.now(),
649
+ Tables: []
650
+ };
646
651
  this._cloneState.PreCountGrandTotal = 0;
647
652
 
648
653
  this.fable.log.info(`Data Cloner: Pre-counting records for ${pTables.length} tables...`);
@@ -656,6 +661,7 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
656
661
  return fNext();
657
662
  }
658
663
 
664
+ let tmpTableStartTime = Date.now();
659
665
  let tmpCountURL = `${pTableName}s/Count`;
660
666
  this.fable.MeadowCloneRestClient.getJSON(tmpCountURL,
661
667
  (pError, pResponse, pBody) =>
@@ -669,7 +675,14 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
669
675
  {
670
676
  this._cloneState.SyncProgress[pTableName].Total = tmpCount;
671
677
  }
678
+ let tmpElapsedMs = Date.now() - tmpTableStartTime;
672
679
  this._cloneState.PreCountProgress.Counted++;
680
+ this._cloneState.PreCountProgress.Tables.push({
681
+ Name: pTableName,
682
+ Count: tmpCount,
683
+ ElapsedMs: tmpElapsedMs,
684
+ Error: pError ? true : false
685
+ });
673
686
  this._cloneState.PreCountGrandTotal += tmpCount;
674
687
  return fNext();
675
688
  });
@@ -866,15 +879,42 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
866
879
  if (this._cloneState.SyncLogFileLogger)
867
880
  {
868
881
  let tmpLogPath = this._cloneState.SyncLogFilePath || '';
869
- this._cloneState.SyncLogFileLogger.flushBufferToLogFile(() =>
882
+ let tmpLogger = this._cloneState.SyncLogFileLogger;
883
+
884
+ // Log before closing so the message is captured in the file
885
+ this.fable.log.info(`Data Cloner: Log file closing — ${tmpLogPath}`);
886
+
887
+ // Remove the logger from fable.log stream arrays so no writes
888
+ // are dispatched to it after the underlying stream is closed.
889
+ let tmpStreamArrays = [
890
+ 'logStreams', 'logStreamsTrace', 'logStreamsDebug',
891
+ 'logStreamsInfo', 'logStreamsWarn', 'logStreamsError', 'logStreamsFatal'
892
+ ];
893
+ for (let s = 0; s < tmpStreamArrays.length; s++)
870
894
  {
871
- this._cloneState.SyncLogFileLogger.closeWriter(() =>
895
+ let tmpArr = this.fable.log[tmpStreamArrays[s]];
896
+ if (Array.isArray(tmpArr))
872
897
  {
873
- this.fable.log.info(`Data Cloner: Log file closed — ${tmpLogPath}`);
874
- });
875
- });
898
+ let tmpIdx = tmpArr.indexOf(tmpLogger);
899
+ if (tmpIdx > -1) tmpArr.splice(tmpIdx, 1);
900
+ }
901
+ }
902
+
876
903
  this._cloneState.SyncLogFileLogger = null;
877
904
  this._cloneState.SyncLogFilePath = null;
905
+
906
+ // Now flush and close safely — no more writes can reach this stream
907
+ try
908
+ {
909
+ tmpLogger.flushBufferToLogFile(() =>
910
+ {
911
+ tmpLogger.closeWriter(() => {});
912
+ });
913
+ }
914
+ catch (pCloseErr)
915
+ {
916
+ // Ignore close errors
917
+ }
878
918
  }
879
919
 
880
920
  return;
@@ -915,7 +955,7 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
915
955
  {
916
956
  let tmpResults = tmpSyncEntity.syncResults;
917
957
  tmpProgress.New = tmpResults.Created || 0;
918
- tmpProgress.Updated = 0;
958
+ tmpProgress.Updated = tmpResults.Updated || 0;
919
959
  tmpProgress.Unchanged = tmpResults.LocalRecordCount || 0;
920
960
  tmpProgress.Deleted = tmpResults.Deleted || 0;
921
961
  tmpProgress.ServerTotal = tmpResults.ServerRecordCount || 0;
@@ -73,7 +73,8 @@ class DataClonerApplication extends libPictApplication
73
73
  'solrHost', 'solrPort', 'solrCore', 'solrPath',
74
74
  'mongodbHost', 'mongodbPort', 'mongodbUser', 'mongodbPassword', 'mongodbDatabase', 'mongodbConnectionLimit',
75
75
  'rocksdbFolder',
76
- 'bibliographFolder'
76
+ 'bibliographFolder',
77
+ 'syncMaxRecords'
77
78
  ]
78
79
  };
79
80
 
@@ -295,6 +295,12 @@ class DataClonerProvider extends libPictProvider
295
295
  {
296
296
  document.getElementById('solrSecure').checked = tmpSolrSecure === 'true';
297
297
  }
298
+ // Restore advanced ID pagination checkbox
299
+ let tmpAdvancedIDPagination = localStorage.getItem('dataCloner_syncAdvancedIDPagination');
300
+ if (tmpAdvancedIDPagination !== null)
301
+ {
302
+ document.getElementById('syncAdvancedIDPagination').checked = tmpAdvancedIDPagination === 'true';
303
+ }
298
304
  }
299
305
 
300
306
  initPersistence()
@@ -345,6 +351,16 @@ class DataClonerProvider extends libPictProvider
345
351
  });
346
352
  }
347
353
 
354
+ // Persist advanced ID pagination checkbox
355
+ let tmpAdvancedIDPaginationEl = document.getElementById('syncAdvancedIDPagination');
356
+ if (tmpAdvancedIDPaginationEl)
357
+ {
358
+ tmpAdvancedIDPaginationEl.addEventListener('change', function()
359
+ {
360
+ localStorage.setItem('dataCloner_syncAdvancedIDPagination', this.checked);
361
+ });
362
+ }
363
+
348
364
  // Persist auto-process checkboxes
349
365
  let tmpAutoIds = ['auto1', 'auto2', 'auto3', 'auto4', 'auto5'];
350
366
  for (let a = 0; a < tmpAutoIds.length; a++)
@@ -447,7 +463,10 @@ class DataClonerProvider extends libPictProvider
447
463
  }
448
464
  if (pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables)
449
465
  {
450
- tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + '</span>');
466
+ let tmpCountedSoFar = pData.PreCountGrandTotal > 0
467
+ ? ' (' + pData.PreCountGrandTotal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' records found)'
468
+ : '';
469
+ tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + ' tables' + tmpCountedSoFar + '</span>');
451
470
  }
452
471
  if (pData.Errors > 0)
453
472
  {
@@ -466,7 +485,12 @@ class DataClonerProvider extends libPictProvider
466
485
 
467
486
  // Update progress bar
468
487
  let tmpPct = 0;
469
- if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0)
488
+ if (pData.Phase === 'syncing' && pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables)
489
+ {
490
+ // During counting phase, show table counting progress
491
+ tmpPct = Math.min((pData.PreCountProgress.Counted / pData.PreCountProgress.TotalTables) * 100, 99);
492
+ }
493
+ else if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0)
470
494
  {
471
495
  tmpPct = Math.min((pData.TotalSynced / pData.PreCountGrandTotal) * 100, 99.9);
472
496
  }
@@ -489,6 +513,16 @@ class DataClonerProvider extends libPictProvider
489
513
  }
490
514
  tmpProgressFill.style.width = Math.min(100, Math.round(tmpPct)) + '%';
491
515
 
516
+ // Auto-expand the detail view when sync starts so users see counting progress
517
+ if ((pData.Phase === 'syncing' || pData.Phase === 'stopping') && !this.pict.AppData.DataCloner.StatusDetailExpanded)
518
+ {
519
+ let tmpLayoutView = this.pict.views['DataCloner-Layout'];
520
+ if (tmpLayoutView && typeof tmpLayoutView.toggleStatusDetail === 'function')
521
+ {
522
+ tmpLayoutView.toggleStatusDetail();
523
+ }
524
+ }
525
+
492
526
  // If the detail view is expanded, re-render it with fresh data
493
527
  if (this.pict.AppData.DataCloner.StatusDetailExpanded)
494
528
  {
@@ -558,6 +592,59 @@ class DataClonerProvider extends libPictProvider
558
592
  .catch(function() { /* ignore poll errors */ });
559
593
  }
560
594
 
595
+ renderCountingPhaseDetail(pContainer, pPreCountProgress)
596
+ {
597
+ let tmpTables = pPreCountProgress.Tables || [];
598
+ let tmpCounted = pPreCountProgress.Counted || 0;
599
+ let tmpTotal = pPreCountProgress.TotalTables || 0;
600
+ let tmpRunningTotal = 0;
601
+
602
+ let tmpHtml = '<div class="status-detail-section">';
603
+ tmpHtml += '<div class="status-detail-section-title">Counting Records (' + tmpCounted + ' / ' + tmpTotal + ' tables)</div>';
604
+
605
+ if (tmpTables.length > 0)
606
+ {
607
+ tmpHtml += '<table class="precount-table">';
608
+ tmpHtml += '<thead><tr><th>Table</th><th style="text-align:right">Records</th><th style="text-align:right">Time</th></tr></thead>';
609
+ tmpHtml += '<tbody>';
610
+ for (let i = 0; i < tmpTables.length; i++)
611
+ {
612
+ let tmpT = tmpTables[i];
613
+ tmpRunningTotal += tmpT.Count;
614
+ let tmpCountFmt = this.formatNumber(tmpT.Count);
615
+ let tmpTimeFmt = tmpT.ElapsedMs < 1000
616
+ ? tmpT.ElapsedMs + 'ms'
617
+ : (tmpT.ElapsedMs / 1000).toFixed(1) + 's';
618
+ let tmpRowClass = tmpT.Error ? ' class="precount-error"' : '';
619
+ tmpHtml += '<tr' + tmpRowClass + '>';
620
+ tmpHtml += '<td>' + this.escapeHtml(tmpT.Name) + '</td>';
621
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums">' + tmpCountFmt + '</td>';
622
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums; color:#888">' + tmpTimeFmt + '</td>';
623
+ tmpHtml += '</tr>';
624
+ }
625
+ tmpHtml += '</tbody>';
626
+ tmpHtml += '<tfoot><tr>';
627
+ tmpHtml += '<td><strong>Total</strong></td>';
628
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums"><strong>' + this.formatNumber(tmpRunningTotal) + '</strong></td>';
629
+ tmpHtml += '<td></td>';
630
+ tmpHtml += '</tr></tfoot>';
631
+ tmpHtml += '</table>';
632
+ }
633
+
634
+ // Show pending indicator for remaining tables
635
+ let tmpRemaining = tmpTotal - tmpCounted;
636
+ if (tmpRemaining > 0)
637
+ {
638
+ tmpHtml += '<div class="precount-pending">';
639
+ tmpHtml += '<span class="precount-spinner"></span> ';
640
+ tmpHtml += tmpRemaining + ' table' + (tmpRemaining === 1 ? '' : 's') + ' remaining…';
641
+ tmpHtml += '</div>';
642
+ }
643
+
644
+ tmpHtml += '</div>';
645
+ pContainer.innerHTML = tmpHtml;
646
+ }
647
+
561
648
  renderStatusDetail()
562
649
  {
563
650
  let tmpContainer = document.getElementById('DataCloner-StatusDetail-Container');
@@ -568,6 +655,19 @@ class DataClonerProvider extends libPictProvider
568
655
  let tmpStatusData = tmpAppData.StatusDetailData;
569
656
  let tmpReport = tmpAppData.LastReport;
570
657
 
658
+ // During the counting phase, show per-table counts as they arrive
659
+ if (tmpLiveStatus && tmpLiveStatus.PreCountProgress
660
+ && tmpLiveStatus.PreCountProgress.Tables
661
+ && tmpLiveStatus.Phase === 'syncing'
662
+ && tmpLiveStatus.PreCountProgress.Counted < tmpLiveStatus.PreCountProgress.TotalTables)
663
+ {
664
+ this.renderCountingPhaseDetail(tmpContainer, tmpLiveStatus.PreCountProgress);
665
+ // Hide histogram during counting
666
+ let tmpHistContainer = document.getElementById('DataCloner-Throughput-Histogram');
667
+ if (tmpHistContainer) tmpHistContainer.style.display = 'none';
668
+ return;
669
+ }
670
+
571
671
  // Determine data source: live during sync, report after sync
572
672
  let tmpTables = {};
573
673
  let tmpThroughputSamples = [];
@@ -101,6 +101,7 @@ class DataClonerExportView extends libPictView
101
101
  if (!isNaN(tmpPrecision) && tmpPrecision !== 1000) tmpConfig.Sync.DateTimePrecisionMS = tmpPrecision;
102
102
  let tmpMaxRecords = parseInt(document.getElementById('syncMaxRecords').value, 10);
103
103
  if (tmpMaxRecords > 0) tmpConfig.Sync.MaxRecords = tmpMaxRecords;
104
+ if (document.getElementById('syncAdvancedIDPagination').checked) tmpConfig.Sync.UseAdvancedIDPagination = true;
104
105
 
105
106
  return tmpConfig;
106
107
  }
@@ -169,6 +170,7 @@ class DataClonerExportView extends libPictView
169
170
  let tmpSelectedTables = this.pict.views['DataCloner-Schema'].getSelectedTables();
170
171
  tmpConfig.Sync.SyncEntityList = tmpSelectedTables.length > 0 ? tmpSelectedTables : [];
171
172
  tmpConfig.Sync.SyncEntityOptions = {};
173
+ if (document.getElementById('syncAdvancedIDPagination').checked) tmpConfig.Sync.UseAdvancedIDPagination = true;
172
174
 
173
175
  // ---- SessionManager ----
174
176
  tmpConfig.SessionManager = { Sessions: {} };
@@ -339,6 +339,34 @@ select { background: #fff; width: 100%; padding: 8px 12px; border: 1px solid #cc
339
339
  font-size: 0.78em; color: #888; margin-top: 4px; padding-left: 18px;
340
340
  font-family: monospace; max-height: 80px; overflow-y: auto;
341
341
  }
342
+
343
+ /* Pre-count Table */
344
+ .precount-table {
345
+ width: 100%; border-collapse: collapse; font-size: 0.88em;
346
+ }
347
+ .precount-table thead th {
348
+ text-align: left; font-weight: 600; color: #555; font-size: 0.85em;
349
+ text-transform: uppercase; letter-spacing: 0.3px;
350
+ padding: 4px 8px 6px; border-bottom: 2px solid #e9ecef;
351
+ }
352
+ .precount-table tbody td {
353
+ padding: 4px 8px; border-bottom: 1px solid #f0f0f0;
354
+ }
355
+ .precount-table tbody tr:last-child td { border-bottom: 1px solid #e9ecef; }
356
+ .precount-table tfoot td {
357
+ padding: 6px 8px 2px; font-size: 0.95em;
358
+ }
359
+ .precount-error td { color: #dc3545; }
360
+ .precount-pending {
361
+ color: #888; font-size: 0.85em; font-style: italic; padding: 8px 0 2px;
362
+ display: flex; align-items: center; gap: 6px;
363
+ }
364
+ .precount-spinner {
365
+ display: inline-block; width: 12px; height: 12px;
366
+ border: 2px solid #ddd; border-top-color: #4a90d9;
367
+ border-radius: 50%; animation: precount-spin 0.8s linear infinite;
368
+ }
369
+ @keyframes precount-spin { to { transform: rotate(360deg); } }
342
370
  `,
343
371
  Templates:
344
372
  [
@@ -17,6 +17,7 @@ class DataClonerSyncView extends libPictView
17
17
  let tmpSyncMode = document.querySelector('input[name="syncMode"]:checked').value;
18
18
  let tmpMaxRecords = parseInt(document.getElementById('syncMaxRecords').value, 10) || 0;
19
19
  let tmpLogToFile = document.getElementById('syncLogFile').checked;
20
+ let tmpAdvancedIDPagination = document.getElementById('syncAdvancedIDPagination').checked;
20
21
 
21
22
  if (tmpSelectedTables.length === 0)
22
23
  {
@@ -32,6 +33,7 @@ class DataClonerSyncView extends libPictView
32
33
  let tmpPostBody = { Tables: tmpSelectedTables, PageSize: tmpPageSize, DateTimePrecisionMS: tmpDateTimePrecisionMS, SyncDeletedRecords: tmpSyncDeletedRecords, SyncMode: tmpSyncMode };
33
34
  if (tmpMaxRecords > 0) tmpPostBody.MaxRecordsPerEntity = tmpMaxRecords;
34
35
  if (tmpLogToFile) tmpPostBody.LogToFile = true;
36
+ if (tmpAdvancedIDPagination) tmpPostBody.UseAdvancedIDPagination = true;
35
37
  this.pict.providers.DataCloner.api('POST', '/clone/sync/start', tmpPostBody)
36
38
  .then(function(pData)
37
39
  {
@@ -522,6 +524,11 @@ module.exports.default_configuration =
522
524
  <label for="syncDeletedRecords">Sync deleted records (fetch records marked Deleted=1 on source and mirror locally)</label>
523
525
  </div>
524
526
 
527
+ <div class="checkbox-row">
528
+ <input type="checkbox" id="syncAdvancedIDPagination">
529
+ <label for="syncAdvancedIDPagination">Use advanced ID pagination (faster for large tables; uses keyset pagination instead of OFFSET)</label>
530
+ </div>
531
+
525
532
  <div class="inline-group" style="margin-top:8px; margin-bottom:4px">
526
533
  <div style="flex:0 0 200px">
527
534
  <label for="syncMaxRecords">Max Records per Entity</label>