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.
- package/.claude/launch.json +11 -0
- package/bin/retold-data-service-clone.js +286 -0
- package/package.json +18 -9
- package/source/Retold-Data-Service.js +275 -73
- package/source/services/Retold-Data-Service-ConnectionManager.js +277 -0
- package/source/services/Retold-Data-Service-MeadowEndpoints.js +217 -0
- package/source/services/Retold-Data-Service-ModelManager.js +335 -0
- package/source/services/data-cloner/DataCloner-Command-Connection.js +138 -0
- package/source/services/data-cloner/DataCloner-Command-Headless.js +357 -0
- package/source/services/data-cloner/DataCloner-Command-Schema.js +367 -0
- package/source/services/data-cloner/DataCloner-Command-Session.js +229 -0
- package/source/services/data-cloner/DataCloner-Command-Sync.js +491 -0
- package/source/services/data-cloner/DataCloner-Command-WebUI.js +40 -0
- package/source/services/data-cloner/DataCloner-ProviderRegistry.js +20 -0
- package/source/services/data-cloner/Retold-Data-Service-DataCloner.js +751 -0
- package/source/services/data-cloner/data-cloner-web.html +2706 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-Command-Dashboard.js +60 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-Command-Integrations.js +132 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-Command-Runs.js +93 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-StorageProvider-Base.js +116 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-StorageProvider-Bibliograph.js +495 -0
- package/source/services/integration-telemetry/Retold-Data-Service-IntegrationTelemetry.js +224 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-CSVCheck.js +85 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-CSVTransform.js +180 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionIntersect.js +153 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionPush.js +190 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionToArray.js +113 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-ComprehensionToCSV.js +211 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-EntityFromTabularFolder.js +244 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-JSONArrayTransform.js +213 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-TSVCheck.js +80 -0
- package/source/services/meadow-integration/MeadowIntegration-Command-TSVTransform.js +166 -0
- package/source/services/meadow-integration/Retold-Data-Service-MeadowIntegration.js +113 -0
- package/source/services/migration-manager/MigrationManager-Command-Connections.js +220 -0
- package/source/services/migration-manager/MigrationManager-Command-DiffMigrate.js +169 -0
- package/source/services/migration-manager/MigrationManager-Command-Schemas.js +532 -0
- package/source/services/migration-manager/MigrationManager-Command-WebUI.js +123 -0
- package/source/services/migration-manager/Retold-Data-Service-MigrationManager.js +357 -0
- package/source/services/stricture/Retold-Data-Service-Stricture.js +303 -0
- package/source/services/stricture/Stricture-Command-Compile.js +39 -0
- package/source/services/stricture/Stricture-Command-Generate-AuthorizationChart.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-DictionaryCSV.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-LaTeX.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-Markdown.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-Meadow.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-ModelGraph.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-MySQL.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-MySQLMigrate.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-Pict.js +14 -0
- package/source/services/stricture/Stricture-Command-Generate-TestObjectContainers.js +14 -0
- package/test/RetoldDataService_tests.js +161 -1
- 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;
|