retold-data-service 2.0.13 → 2.0.16

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 (52) hide show
  1. package/.claude/launch.json +11 -0
  2. package/bin/retold-data-service-clone.js +286 -0
  3. package/package.json +18 -9
  4. package/source/Retold-Data-Service.js +275 -73
  5. package/source/services/Retold-Data-Service-ConnectionManager.js +277 -0
  6. package/source/services/Retold-Data-Service-MeadowEndpoints.js +217 -0
  7. package/source/services/Retold-Data-Service-ModelManager.js +335 -0
  8. package/source/services/data-cloner/DataCloner-Command-Connection.js +138 -0
  9. package/source/services/data-cloner/DataCloner-Command-Headless.js +357 -0
  10. package/source/services/data-cloner/DataCloner-Command-Schema.js +367 -0
  11. package/source/services/data-cloner/DataCloner-Command-Session.js +229 -0
  12. package/source/services/data-cloner/DataCloner-Command-Sync.js +491 -0
  13. package/source/services/data-cloner/DataCloner-Command-WebUI.js +40 -0
  14. package/source/services/data-cloner/DataCloner-ProviderRegistry.js +20 -0
  15. package/source/services/data-cloner/Retold-Data-Service-DataCloner.js +751 -0
  16. package/source/services/data-cloner/data-cloner-web.html +2706 -0
  17. package/source/services/integration-telemetry/IntegrationTelemetry-Command-Dashboard.js +60 -0
  18. package/source/services/integration-telemetry/IntegrationTelemetry-Command-Integrations.js +132 -0
  19. package/source/services/integration-telemetry/IntegrationTelemetry-Command-Runs.js +93 -0
  20. package/source/services/integration-telemetry/IntegrationTelemetry-StorageProvider-Base.js +116 -0
  21. package/source/services/integration-telemetry/IntegrationTelemetry-StorageProvider-Bibliograph.js +495 -0
  22. package/source/services/integration-telemetry/Retold-Data-Service-IntegrationTelemetry.js +224 -0
  23. package/source/services/meadow-integration/MeadowIntegration-Command-CSVCheck.js +85 -0
  24. package/source/services/meadow-integration/MeadowIntegration-Command-CSVTransform.js +180 -0
  25. package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionIntersect.js +153 -0
  26. package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionPush.js +190 -0
  27. package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionToArray.js +113 -0
  28. package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionToCSV.js +211 -0
  29. package/source/services/meadow-integration/MeadowIntegration-Command-EntityFromTabularFolder.js +244 -0
  30. package/source/services/meadow-integration/MeadowIntegration-Command-JSONArrayTransform.js +213 -0
  31. package/source/services/meadow-integration/MeadowIntegration-Command-TSVCheck.js +80 -0
  32. package/source/services/meadow-integration/MeadowIntegration-Command-TSVTransform.js +166 -0
  33. package/source/services/meadow-integration/Retold-Data-Service-MeadowIntegration.js +113 -0
  34. package/source/services/migration-manager/MigrationManager-Command-Connections.js +220 -0
  35. package/source/services/migration-manager/MigrationManager-Command-DiffMigrate.js +169 -0
  36. package/source/services/migration-manager/MigrationManager-Command-Schemas.js +532 -0
  37. package/source/services/migration-manager/MigrationManager-Command-WebUI.js +123 -0
  38. package/source/services/migration-manager/Retold-Data-Service-MigrationManager.js +357 -0
  39. package/source/services/stricture/Retold-Data-Service-Stricture.js +303 -0
  40. package/source/services/stricture/Stricture-Command-Compile.js +39 -0
  41. package/source/services/stricture/Stricture-Command-Generate-AuthorizationChart.js +14 -0
  42. package/source/services/stricture/Stricture-Command-Generate-DictionaryCSV.js +14 -0
  43. package/source/services/stricture/Stricture-Command-Generate-LaTeX.js +14 -0
  44. package/source/services/stricture/Stricture-Command-Generate-Markdown.js +14 -0
  45. package/source/services/stricture/Stricture-Command-Generate-Meadow.js +14 -0
  46. package/source/services/stricture/Stricture-Command-Generate-ModelGraph.js +14 -0
  47. package/source/services/stricture/Stricture-Command-Generate-MySQL.js +14 -0
  48. package/source/services/stricture/Stricture-Command-Generate-MySQLMigrate.js +14 -0
  49. package/source/services/stricture/Stricture-Command-Generate-Pict.js +14 -0
  50. package/source/services/stricture/Stricture-Command-Generate-TestObjectContainers.js +14 -0
  51. package/test/RetoldDataService_tests.js +161 -1
  52. package/debug/data/books.csv +0 -10001
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Retold Data Service - Data Cloner Service
3
+ *
4
+ * Fable service that clones a remote retold-based database to a local database.
5
+ * Provides REST endpoints for connection management, session management, schema
6
+ * fetch/deploy, and data synchronization via meadow-integration.
7
+ *
8
+ * Two route groups:
9
+ * connectRoutes() — JSON API endpoints under /clone/*
10
+ * connectWebUIRoutes() — Web UI HTML serving at /clone/
11
+ *
12
+ * @author Steven Velozo <steven@velozo.com>
13
+ */
14
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
15
+
16
+ const libFs = require('fs');
17
+ const libPath = require('path');
18
+
19
+ const libPict = require('pict');
20
+ const libPictSessionManager = require('pict-sessionmanager');
21
+
22
+ const _ProviderRegistry = require('./DataCloner-ProviderRegistry.js');
23
+
24
+ const defaultDataClonerOptions = (
25
+ {
26
+ // Route prefix for all data cloner endpoints
27
+ RoutePrefix: '/clone'
28
+ });
29
+
30
+ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
31
+ {
32
+ constructor(pFable, pOptions, pServiceHash)
33
+ {
34
+ let tmpOptions = Object.assign({}, defaultDataClonerOptions, pOptions);
35
+ super(pFable, tmpOptions, pServiceHash);
36
+
37
+ this.serviceType = 'RetoldDataServiceDataCloner';
38
+
39
+ // Clone state — tracks connection, session, schema, and sync progress
40
+ this._cloneState = (
41
+ {
42
+ // Tenant identifier for telemetry
43
+ TenantID: null,
44
+
45
+ // Local database connection
46
+ ConnectionProvider: 'SQLite',
47
+ ConnectionConnected: false,
48
+ ConnectionConfig: {},
49
+
50
+ // Remote session configuration
51
+ SessionConfigured: false,
52
+ SessionAuthenticated: false,
53
+ RemoteServerURL: '',
54
+
55
+ // Fetched remote schema
56
+ RemoteSchema: false,
57
+ RemoteModelObject: false,
58
+ DeployedModelObject: false,
59
+
60
+ // Sync progress
61
+ SyncRunning: false,
62
+ SyncStopping: false,
63
+ SyncPhase: null,
64
+ SyncProgress: {},
65
+ SyncDeletedRecords: false,
66
+ SyncMode: 'Initial',
67
+
68
+ // Pre-count phase
69
+ PreCountProgress: null,
70
+ PreCountGrandTotal: 0,
71
+
72
+ // Per-table REST error counters
73
+ SyncRESTErrors: {},
74
+
75
+ // Sync report
76
+ SyncRunID: null,
77
+ SyncStartTime: null,
78
+ SyncEndTime: null,
79
+ SyncEventLog: [],
80
+ SyncReport: null
81
+ });
82
+
83
+ // Create an isolated Pict instance for remote session management
84
+ this._Pict = new libPict(
85
+ {
86
+ Product: 'DataClonerSession',
87
+ TraceLog: true,
88
+ LogStreams:
89
+ [
90
+ {
91
+ streamtype: 'console'
92
+ }
93
+ ]
94
+ });
95
+
96
+ this._Pict.serviceManager.addServiceType('SessionManager', libPictSessionManager);
97
+ this._Pict.serviceManager.instantiateServiceProvider('SessionManager');
98
+ }
99
+
100
+ /**
101
+ * The route prefix for all data cloner endpoints.
102
+ */
103
+ get routePrefix()
104
+ {
105
+ let tmpConfig = this.fable.RetoldDataService.options.DataCloner || {};
106
+ return tmpConfig.RoutePrefix || this.options.RoutePrefix || '/clone';
107
+ }
108
+
109
+ /**
110
+ * The clone state object.
111
+ */
112
+ get cloneState()
113
+ {
114
+ return this._cloneState;
115
+ }
116
+
117
+ /**
118
+ * The isolated Pict instance for session management.
119
+ */
120
+ get pict()
121
+ {
122
+ return this._Pict;
123
+ }
124
+
125
+ /**
126
+ * The provider registry.
127
+ */
128
+ get providerRegistry()
129
+ {
130
+ return _ProviderRegistry;
131
+ }
132
+
133
+ /**
134
+ * Connect a meadow-connection provider to fable.
135
+ * Registers the service type, sets the configuration, instantiates the provider, and calls connectAsync.
136
+ *
137
+ * @param {string} pProviderName - Provider name (e.g. 'SQLite', 'MySQL', 'MSSQL')
138
+ * @param {object} pConfig - Provider configuration
139
+ * @param {function} fCallback - (pError)
140
+ */
141
+ connectProvider(pProviderName, pConfig, fCallback)
142
+ {
143
+ let tmpRegistryEntry = _ProviderRegistry[pProviderName];
144
+ if (!tmpRegistryEntry)
145
+ {
146
+ return fCallback(new Error(`Unknown provider: ${pProviderName}. Supported providers: ${Object.keys(_ProviderRegistry).join(', ')}`));
147
+ }
148
+
149
+ let tmpModule;
150
+ try
151
+ {
152
+ tmpModule = require(tmpRegistryEntry.moduleName);
153
+ }
154
+ catch (pRequireError)
155
+ {
156
+ return fCallback(new Error(`Could not load module "${tmpRegistryEntry.moduleName}": ${pRequireError.message}. Run: npm install ${tmpRegistryEntry.moduleName}`));
157
+ }
158
+
159
+ // Set the provider configuration on fable settings
160
+ this.fable.settings[tmpRegistryEntry.configKey] = pConfig;
161
+
162
+ // Register and instantiate the provider if not already present
163
+ if (!this.fable[tmpRegistryEntry.serviceName])
164
+ {
165
+ this.fable.serviceManager.addServiceType(tmpRegistryEntry.serviceName, tmpModule);
166
+ this.fable.serviceManager.instantiateServiceProvider(tmpRegistryEntry.serviceName);
167
+ }
168
+
169
+ this.fable[tmpRegistryEntry.serviceName].connectAsync(
170
+ (pError) =>
171
+ {
172
+ if (pError)
173
+ {
174
+ return fCallback(pError);
175
+ }
176
+ this.fable.settings.MeadowProvider = pProviderName;
177
+ this._cloneState.ConnectionProvider = pProviderName;
178
+ this._cloneState.ConnectionConnected = true;
179
+ this._cloneState.ConnectionConfig = pConfig;
180
+ return fCallback();
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Normalize a config object from meadow-integration format to data-cloner format.
186
+ *
187
+ * @param {object} pConfig - Config object (possibly in meadow-integration format)
188
+ * @return {object} Normalized config
189
+ */
190
+ normalizeConfig(pConfig)
191
+ {
192
+ // Support meadow-integration config format (Source/Destination/SessionManager)
193
+ // by mapping to data-cloner format (RemoteSession/Credentials/LocalDatabase/Tables).
194
+ if (pConfig.Source && pConfig.Source.ServerURL && !pConfig.RemoteSession)
195
+ {
196
+ this.fable.log.info('Data Cloner: Detected meadow-integration config format; normalizing...');
197
+
198
+ // Map Source → RemoteSession
199
+ let tmpSession = pConfig.SessionManager
200
+ && pConfig.SessionManager.Sessions
201
+ && pConfig.SessionManager.Sessions.SourceAPI;
202
+
203
+ pConfig.RemoteSession = {};
204
+ pConfig.RemoteSession.ServerURL = pConfig.Source.ServerURL;
205
+
206
+ if (tmpSession)
207
+ {
208
+ pConfig.RemoteSession.AuthenticationMethod = tmpSession.AuthenticationMethod;
209
+ pConfig.RemoteSession.AuthenticationURITemplate = tmpSession.AuthenticationURITemplate;
210
+ pConfig.RemoteSession.CheckSessionURITemplate = tmpSession.CheckSessionURITemplate;
211
+ pConfig.RemoteSession.CheckSessionLoginMarkerType = tmpSession.CheckSessionLoginMarkerType;
212
+ pConfig.RemoteSession.CheckSessionLoginMarker = tmpSession.CheckSessionLoginMarker;
213
+ pConfig.RemoteSession.DomainMatch = tmpSession.DomainMatch;
214
+ pConfig.RemoteSession.CookieName = tmpSession.CookieName;
215
+ pConfig.RemoteSession.CookieValueAddress = tmpSession.CookieValueAddress;
216
+ pConfig.RemoteSession.CookieValueTemplate = tmpSession.CookieValueTemplate;
217
+
218
+ // Map SessionManager credentials → Credentials
219
+ if (tmpSession.Credentials && !pConfig.Credentials)
220
+ {
221
+ pConfig.Credentials = tmpSession.Credentials;
222
+ }
223
+ }
224
+ else if (pConfig.Source.UserID && pConfig.Source.Password)
225
+ {
226
+ // Fallback: legacy Source.UserID / Source.Password
227
+ if (!pConfig.Credentials)
228
+ {
229
+ pConfig.Credentials = { UserName: pConfig.Source.UserID, Password: pConfig.Source.Password };
230
+ }
231
+ }
232
+
233
+ // Map Destination → LocalDatabase
234
+ if (pConfig.Destination && !pConfig.LocalDatabase)
235
+ {
236
+ pConfig.LocalDatabase = { Provider: pConfig.Destination.Provider };
237
+ if (pConfig.Destination.Provider === 'MySQL' && pConfig.Destination.MySQL)
238
+ {
239
+ pConfig.LocalDatabase.Config = pConfig.Destination.MySQL;
240
+ }
241
+ else if (pConfig.Destination.Provider === 'MSSQL' && pConfig.Destination.MSSQL)
242
+ {
243
+ pConfig.LocalDatabase.Config = pConfig.Destination.MSSQL;
244
+ }
245
+ }
246
+
247
+ // Map Sync.SyncEntityList → Tables
248
+ if (pConfig.Sync && pConfig.Sync.SyncEntityList && !pConfig.Tables)
249
+ {
250
+ pConfig.Tables = pConfig.Sync.SyncEntityList;
251
+ }
252
+
253
+ // Map Sync.DefaultSyncMode → Sync.Mode
254
+ if (pConfig.Sync && pConfig.Sync.DefaultSyncMode && !pConfig.Sync.Mode)
255
+ {
256
+ pConfig.Sync.Mode = pConfig.Sync.DefaultSyncMode;
257
+ }
258
+ }
259
+
260
+ return pConfig;
261
+ }
262
+
263
+ // ================================================================
264
+ // Sync Report
265
+ // ================================================================
266
+
267
+ /**
268
+ * Append a timestamped event to the sync event log.
269
+ *
270
+ * @param {string} pType - Event type (RunStart, TableStart, TableComplete, etc.)
271
+ * @param {string} pMessage - Human-readable message
272
+ * @param {object} [pData] - Optional structured data for this event
273
+ */
274
+ logSyncEvent(pType, pMessage, pData)
275
+ {
276
+ this._cloneState.SyncEventLog.push(
277
+ {
278
+ Timestamp: new Date().toJSON(),
279
+ Type: pType,
280
+ Message: pMessage,
281
+ Data: pData || undefined
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Generate a structured report object from current clone state.
287
+ *
288
+ * @return {object} The sync report
289
+ */
290
+ generateSyncReport()
291
+ {
292
+ let tmpState = this._cloneState;
293
+ let tmpTableNames = Object.keys(tmpState.SyncProgress);
294
+
295
+ // Build per-table entries with duration
296
+ let tmpTables = [];
297
+ let tmpTotalRecords = 0;
298
+ let tmpTotalSynced = 0;
299
+ let tmpTotalSkipped = 0;
300
+ let tmpTotalErrors = 0;
301
+ let tmpComplete = 0;
302
+ let tmpPartial = 0;
303
+ let tmpErrors = 0;
304
+ let tmpPending = 0;
305
+
306
+ for (let i = 0; i < tmpTableNames.length; i++)
307
+ {
308
+ let tmpName = tmpTableNames[i];
309
+ let tmpP = tmpState.SyncProgress[tmpName];
310
+
311
+ let tmpDuration = 0;
312
+ if (tmpP.StartTime && tmpP.EndTime)
313
+ {
314
+ tmpDuration = Math.round((new Date(tmpP.EndTime).getTime() - new Date(tmpP.StartTime).getTime()) / 1000);
315
+ }
316
+
317
+ tmpTables.push(
318
+ {
319
+ Name: tmpName,
320
+ Status: tmpP.Status,
321
+ Total: tmpP.Total || 0,
322
+ Synced: tmpP.Synced || 0,
323
+ Skipped: tmpP.Skipped || 0,
324
+ Errors: tmpP.Errors || 0,
325
+ ErrorMessage: tmpP.ErrorMessage || null,
326
+ StartTime: tmpP.StartTime || null,
327
+ EndTime: tmpP.EndTime || null,
328
+ DurationSeconds: tmpDuration
329
+ });
330
+
331
+ tmpTotalRecords += (tmpP.Total || 0);
332
+ tmpTotalSynced += (tmpP.Synced || 0);
333
+ tmpTotalSkipped += (tmpP.Skipped || 0);
334
+ tmpTotalErrors += (tmpP.Errors || 0);
335
+
336
+ if (tmpP.Status === 'Complete') tmpComplete++;
337
+ else if (tmpP.Status === 'Partial') tmpPartial++;
338
+ else if (tmpP.Status === 'Error') tmpErrors++;
339
+ else tmpPending++;
340
+ }
341
+
342
+ // Sort tables by duration descending
343
+ tmpTables.sort((a, b) => b.DurationSeconds - a.DurationSeconds);
344
+
345
+ // Determine overall outcome
346
+ let tmpOutcome = 'Success';
347
+ if (tmpState.SyncStopping || (!tmpState.SyncRunning && tmpPending > 0))
348
+ {
349
+ tmpOutcome = 'Stopped';
350
+ }
351
+ else if (tmpErrors > 0)
352
+ {
353
+ tmpOutcome = 'Error';
354
+ }
355
+ else if (tmpPartial > 0)
356
+ {
357
+ tmpOutcome = 'Partial';
358
+ }
359
+
360
+ // Build anomalies — tables that are not Complete
361
+ let tmpAnomalies = [];
362
+ for (let i = 0; i < tmpTables.length; i++)
363
+ {
364
+ let tmpT = tmpTables[i];
365
+ if (tmpT.Status === 'Error')
366
+ {
367
+ tmpAnomalies.push(
368
+ {
369
+ Table: tmpT.Name,
370
+ Type: 'Error',
371
+ Message: tmpT.ErrorMessage || 'Unknown error',
372
+ Details: { Total: tmpT.Total, Synced: tmpT.Synced, Errors: tmpT.Errors }
373
+ });
374
+ }
375
+ else if (tmpT.Status === 'Partial')
376
+ {
377
+ tmpAnomalies.push(
378
+ {
379
+ Table: tmpT.Name,
380
+ Type: 'Partial',
381
+ Message: `${tmpT.Skipped} record(s) skipped`,
382
+ Details: { Total: tmpT.Total, Synced: tmpT.Synced, Skipped: tmpT.Skipped }
383
+ });
384
+ }
385
+ else if (tmpT.Status === 'Pending')
386
+ {
387
+ tmpAnomalies.push(
388
+ {
389
+ Table: tmpT.Name,
390
+ Type: 'Skipped',
391
+ Message: 'Sync was stopped before this table was processed',
392
+ Details: {}
393
+ });
394
+ }
395
+ }
396
+
397
+ // Calculate run duration
398
+ let tmpRunDuration = 0;
399
+ if (tmpState.SyncStartTime && tmpState.SyncEndTime)
400
+ {
401
+ tmpRunDuration = Math.round((new Date(tmpState.SyncEndTime).getTime() - new Date(tmpState.SyncStartTime).getTime()) / 1000);
402
+ }
403
+
404
+ let tmpReport = (
405
+ {
406
+ ReportVersion: '1.0.0',
407
+ RunID: tmpState.SyncRunID,
408
+ Outcome: tmpOutcome,
409
+ RunTimestamps:
410
+ {
411
+ Start: tmpState.SyncStartTime,
412
+ End: tmpState.SyncEndTime,
413
+ DurationSeconds: tmpRunDuration
414
+ },
415
+ Config:
416
+ {
417
+ SyncMode: tmpState.SyncMode,
418
+ RemoteServerURL: tmpState.RemoteServerURL,
419
+ Provider: tmpState.ConnectionProvider,
420
+ SyncDeletedRecords: tmpState.SyncDeletedRecords,
421
+ TableCount: tmpTableNames.length
422
+ },
423
+ Summary:
424
+ {
425
+ TotalTables: tmpTableNames.length,
426
+ Complete: tmpComplete,
427
+ Partial: tmpPartial,
428
+ Errors: tmpErrors,
429
+ Pending: tmpPending,
430
+ TotalRecords: tmpTotalRecords,
431
+ TotalSynced: tmpTotalSynced,
432
+ TotalSkipped: tmpTotalSkipped,
433
+ TotalErrors: tmpTotalErrors
434
+ },
435
+ Tables: tmpTables,
436
+ Anomalies: tmpAnomalies,
437
+ EventLog: tmpState.SyncEventLog
438
+ });
439
+
440
+ tmpState.SyncReport = tmpReport;
441
+ return tmpReport;
442
+ }
443
+
444
+ /**
445
+ * Print a terminal-friendly summary of the last sync run.
446
+ */
447
+ logSyncSummary()
448
+ {
449
+ let tmpReport = this._cloneState.SyncReport;
450
+ if (!tmpReport)
451
+ {
452
+ tmpReport = this.generateSyncReport();
453
+ }
454
+
455
+ let tmpBar = '═══════════════════════════════════════════════════';
456
+
457
+ // Format duration as Xm Ys
458
+ let fFormatDuration = (pSeconds) =>
459
+ {
460
+ if (pSeconds < 60) return `${pSeconds}s`;
461
+ let tmpMin = Math.floor(pSeconds / 60);
462
+ let tmpSec = pSeconds % 60;
463
+ return `${tmpMin}m ${tmpSec}s`;
464
+ };
465
+
466
+ // Format number with commas
467
+ let fFormatNumber = (pNum) =>
468
+ {
469
+ return pNum.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
470
+ };
471
+
472
+ let tmpLines = [];
473
+ tmpLines.push(tmpBar);
474
+ tmpLines.push(' DATA CLONER — SYNC REPORT');
475
+ tmpLines.push(tmpBar);
476
+ tmpLines.push(` Outcome: ${tmpReport.Outcome}`);
477
+ tmpLines.push(` Mode: ${tmpReport.Config.SyncMode}`);
478
+ tmpLines.push(` Duration: ${fFormatDuration(tmpReport.RunTimestamps.DurationSeconds)}`);
479
+ tmpLines.push(` Tables: ${tmpReport.Summary.Complete} complete, ${tmpReport.Summary.Partial} partial, ${tmpReport.Summary.Errors} errors`);
480
+ tmpLines.push(` Records: ${fFormatNumber(tmpReport.Summary.TotalSynced)} synced` + (tmpReport.Summary.TotalRecords > 0 ? ` of ${fFormatNumber(tmpReport.Summary.TotalRecords)}` : ''));
481
+ tmpLines.push(tmpBar);
482
+
483
+ if (tmpReport.Anomalies.length === 0)
484
+ {
485
+ tmpLines.push(' ANOMALIES: None');
486
+ }
487
+ else
488
+ {
489
+ tmpLines.push(` ANOMALIES: ${tmpReport.Anomalies.length}`);
490
+ for (let i = 0; i < tmpReport.Anomalies.length; i++)
491
+ {
492
+ let tmpA = tmpReport.Anomalies[i];
493
+ tmpLines.push(` [${tmpA.Type}] ${tmpA.Table}: ${tmpA.Message}`);
494
+ }
495
+ }
496
+ tmpLines.push(tmpBar);
497
+
498
+ // Top 5 tables by duration
499
+ let tmpTopCount = Math.min(5, tmpReport.Tables.length);
500
+ if (tmpTopCount > 0)
501
+ {
502
+ tmpLines.push(' TOP TABLES BY DURATION:');
503
+ for (let i = 0; i < tmpTopCount; i++)
504
+ {
505
+ let tmpT = tmpReport.Tables[i];
506
+ let tmpDur = fFormatDuration(tmpT.DurationSeconds).padEnd(8);
507
+ let tmpRecs = fFormatNumber(tmpT.Total).padStart(10);
508
+ tmpLines.push(` ${tmpT.Name.padEnd(30)} ${tmpDur} ${tmpRecs} records`);
509
+ }
510
+ tmpLines.push(tmpBar);
511
+ }
512
+
513
+ this.fable.log.info(`\n${tmpLines.join('\n')}`);
514
+ }
515
+
516
+ /**
517
+ * Pre-count phase — fetch record counts for all tables in parallel
518
+ * before starting the actual sync. Populates SyncProgress[table].Total
519
+ * and PreCountGrandTotal so the UI can show accurate overall progress.
520
+ *
521
+ * @param {Array<string>} pTables - Table names to count
522
+ * @param {Function} fCallback - Called when counting is complete
523
+ */
524
+ preCountTables(pTables, fCallback)
525
+ {
526
+ this._cloneState.SyncPhase = 'counting';
527
+ this._cloneState.PreCountProgress = { Counted: 0, TotalTables: pTables.length };
528
+ this._cloneState.PreCountGrandTotal = 0;
529
+
530
+ this.fable.log.info(`Data Cloner: Pre-counting records for ${pTables.length} tables...`);
531
+ this.logSyncEvent('PreCountStart', `Pre-counting ${pTables.length} tables`);
532
+
533
+ this.fable.Utility.eachLimit(pTables, 5,
534
+ (pTableName, fNext) =>
535
+ {
536
+ if (this._cloneState.SyncStopping)
537
+ {
538
+ return fNext();
539
+ }
540
+
541
+ let tmpCountURL = `${pTableName}s/Count`;
542
+ this.fable.MeadowCloneRestClient.getJSON(tmpCountURL,
543
+ (pError, pResponse, pBody) =>
544
+ {
545
+ let tmpCount = 0;
546
+ if (!pError && pBody && pBody.Count)
547
+ {
548
+ tmpCount = pBody.Count;
549
+ }
550
+ if (this._cloneState.SyncProgress[pTableName])
551
+ {
552
+ this._cloneState.SyncProgress[pTableName].Total = tmpCount;
553
+ }
554
+ this._cloneState.PreCountProgress.Counted++;
555
+ this._cloneState.PreCountGrandTotal += tmpCount;
556
+ return fNext();
557
+ });
558
+ },
559
+ (pError) =>
560
+ {
561
+ this._cloneState.SyncPhase = 'syncing';
562
+ this.fable.log.info(`Data Cloner: Pre-count complete — ${this._cloneState.PreCountGrandTotal} records across ${pTables.length} tables`);
563
+ this.logSyncEvent('PreCountComplete', `Pre-count: ${this._cloneState.PreCountGrandTotal} records across ${pTables.length} tables`);
564
+ return fCallback();
565
+ });
566
+ }
567
+
568
+ /**
569
+ * The sync engine — synchronize data for a list of tables sequentially.
570
+ *
571
+ * @param {Array<string>} pTables - Table names to sync
572
+ */
573
+ syncTables(pTables)
574
+ {
575
+ let tmpTableIndex = 0;
576
+
577
+ // Initialize run tracking
578
+ this._cloneState.SyncRunID = this.fable.getUUID();
579
+ this._cloneState.SyncStartTime = new Date().toJSON();
580
+ this._cloneState.SyncEndTime = null;
581
+ this._cloneState.SyncEventLog = [];
582
+ this._cloneState.SyncReport = null;
583
+
584
+ this.logSyncEvent('RunStart', `Sync started: ${pTables.length} tables, mode ${this._cloneState.SyncMode}`);
585
+ this.logSyncEvent('RunConfig', 'Sync configuration',
586
+ {
587
+ SyncMode: this._cloneState.SyncMode,
588
+ Tables: pTables,
589
+ RemoteServerURL: this._cloneState.RemoteServerURL,
590
+ Provider: this._cloneState.ConnectionProvider,
591
+ SyncDeletedRecords: this._cloneState.SyncDeletedRecords
592
+ });
593
+
594
+ let fSyncNextTable = () =>
595
+ {
596
+ if (this._cloneState.SyncStopping || tmpTableIndex >= pTables.length)
597
+ {
598
+ this._cloneState.SyncRunning = false;
599
+ this._cloneState.SyncEndTime = new Date().toJSON();
600
+
601
+ if (this._cloneState.SyncStopping)
602
+ {
603
+ this.logSyncEvent('RunStopped', 'Sync was stopped by user request.');
604
+ }
605
+ else
606
+ {
607
+ this.logSyncEvent('RunComplete', 'Sync finished.');
608
+ }
609
+
610
+ this._cloneState.SyncStopping = false;
611
+ let tmpReport = this.generateSyncReport();
612
+ this.logSyncSummary();
613
+
614
+ // Persist telemetry if the IntegrationTelemetry service is available
615
+ if (this.fable.RetoldDataServiceIntegrationTelemetry)
616
+ {
617
+ let tmpTenantID = this._cloneState.TenantID || undefined;
618
+ this.fable.RetoldDataServiceIntegrationTelemetry.recordRun(tmpReport, tmpTenantID);
619
+ }
620
+
621
+ this.fable.log.info('Data Cloner: Sync complete.');
622
+ return;
623
+ }
624
+
625
+ let tmpTableName = pTables[tmpTableIndex];
626
+ tmpTableIndex++;
627
+
628
+ let tmpProgress = this._cloneState.SyncProgress[tmpTableName];
629
+ if (!tmpProgress)
630
+ {
631
+ fSyncNextTable();
632
+ return;
633
+ }
634
+
635
+ tmpProgress.Status = 'Syncing';
636
+ tmpProgress.StartTime = new Date().toJSON();
637
+
638
+ this.logSyncEvent('TableStart', `Sync [${tmpTableName}] — starting.`, { Table: tmpTableName });
639
+ this.fable.log.info(`Data Cloner: Sync [${tmpTableName}] — starting via meadow-integration...`);
640
+
641
+ this.fable.MeadowSync.syncEntity(tmpTableName,
642
+ (pError) =>
643
+ {
644
+ let tmpSyncEntity = this.fable.MeadowSync.MeadowSyncEntities[tmpTableName];
645
+ if (tmpSyncEntity && tmpSyncEntity.operation)
646
+ {
647
+ let tmpTracker = tmpSyncEntity.operation.progressTrackers[`FullSync-${tmpTableName}`];
648
+ if (tmpTracker)
649
+ {
650
+ tmpProgress.Total = tmpTracker.TotalCount || 0;
651
+ tmpProgress.Synced = Math.max(tmpTracker.CurrentCount || 0, 0);
652
+ }
653
+ }
654
+
655
+ let tmpRESTErrors = this._cloneState.SyncRESTErrors[tmpTableName] || 0;
656
+ tmpProgress.Errors = tmpRESTErrors;
657
+
658
+ let tmpMissing = tmpProgress.Total - tmpProgress.Synced;
659
+
660
+ if (pError)
661
+ {
662
+ this.fable.log.error(`Data Cloner: Error syncing [${tmpTableName}]: ${pError}`);
663
+ tmpProgress.Status = 'Error';
664
+ tmpProgress.ErrorMessage = `${pError}`;
665
+ this.logSyncEvent('TableError', `Sync [${tmpTableName}] — error: ${pError}`,
666
+ { Table: tmpTableName, Total: tmpProgress.Total, Synced: tmpProgress.Synced, Error: `${pError}` });
667
+ }
668
+ else if (tmpRESTErrors > 0)
669
+ {
670
+ tmpProgress.Status = 'Error';
671
+ tmpProgress.ErrorMessage = `${tmpRESTErrors} REST error(s) during sync`;
672
+ this.fable.log.warn(`Data Cloner: Sync [${tmpTableName}] — completed with ${tmpRESTErrors} REST error(s). ${tmpProgress.Synced}/${tmpProgress.Total} records synced.`);
673
+ this.logSyncEvent('TableError', `Sync [${tmpTableName}] — ${tmpRESTErrors} REST error(s).`,
674
+ { Table: tmpTableName, Total: tmpProgress.Total, Synced: tmpProgress.Synced, RESTErrors: tmpRESTErrors });
675
+ }
676
+ else if (tmpProgress.Total > 0 && tmpMissing > 0)
677
+ {
678
+ tmpProgress.Status = 'Partial';
679
+ tmpProgress.Skipped = tmpMissing;
680
+ this.fable.log.warn(`Data Cloner: Sync [${tmpTableName}] — partial. ${tmpProgress.Synced}/${tmpProgress.Total} records synced, ${tmpMissing} skipped (GUID conflicts or other errors).`);
681
+ this.logSyncEvent('TablePartial', `Sync [${tmpTableName}] — partial. ${tmpMissing} skipped.`,
682
+ { Table: tmpTableName, Total: tmpProgress.Total, Synced: tmpProgress.Synced, Skipped: tmpMissing });
683
+ }
684
+ else
685
+ {
686
+ tmpProgress.Status = 'Complete';
687
+ this.fable.log.info(`Data Cloner: Sync [${tmpTableName}] — complete. ${tmpProgress.Synced}/${tmpProgress.Total} records synced.`);
688
+ this.logSyncEvent('TableComplete', `Sync [${tmpTableName}] — complete. ${tmpProgress.Synced}/${tmpProgress.Total} records.`,
689
+ { Table: tmpTableName, Total: tmpProgress.Total, Synced: tmpProgress.Synced });
690
+ }
691
+ tmpProgress.EndTime = new Date().toJSON();
692
+
693
+ fSyncNextTable();
694
+ });
695
+ };
696
+
697
+ // Pre-count all tables in parallel, then begin sequential sync
698
+ this.preCountTables(pTables,
699
+ () =>
700
+ {
701
+ fSyncNextTable();
702
+ });
703
+ }
704
+
705
+ // ================================================================
706
+ // Route registration
707
+ // ================================================================
708
+
709
+ /**
710
+ * Register all data cloner API routes on the Orator service server.
711
+ *
712
+ * @param {Object} pOratorServiceServer - The Orator ServiceServer instance
713
+ */
714
+ connectRoutes(pOratorServiceServer)
715
+ {
716
+ require('./DataCloner-Command-Connection.js')(this, pOratorServiceServer);
717
+ require('./DataCloner-Command-Session.js')(this, pOratorServiceServer);
718
+ require('./DataCloner-Command-Schema.js')(this, pOratorServiceServer);
719
+ require('./DataCloner-Command-Sync.js')(this, pOratorServiceServer);
720
+
721
+ this.fable.log.info('Retold Data Service DataCloner API routes registered.');
722
+ }
723
+
724
+ /**
725
+ * Register the web UI routes on the Orator service server.
726
+ *
727
+ * @param {Object} pOratorServiceServer - The Orator ServiceServer instance
728
+ */
729
+ connectWebUIRoutes(pOratorServiceServer)
730
+ {
731
+ require('./DataCloner-Command-WebUI.js')(this, pOratorServiceServer);
732
+
733
+ this.fable.log.info('Retold Data Service DataCloner Web UI routes registered.');
734
+ }
735
+
736
+ /**
737
+ * Run the full clone pipeline non-interactively from a config object.
738
+ *
739
+ * @param {object} pConfig - Parsed config object
740
+ * @param {object} pCLIOptions - CLI options { logPath, maxRecords, schemaPath }
741
+ * @param {function} fCallback - (pError)
742
+ */
743
+ runHeadlessPipeline(pConfig, pCLIOptions, fCallback)
744
+ {
745
+ require('./DataCloner-Command-Headless.js')(this, pConfig, pCLIOptions, fCallback);
746
+ }
747
+ }
748
+
749
+ module.exports = RetoldDataServiceDataCloner;
750
+ module.exports.serviceType = 'RetoldDataServiceDataCloner';
751
+ module.exports.default_configuration = defaultDataClonerOptions;