retold-facto 0.0.4

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 (92) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.dockerignore +8 -0
  3. package/.quackage.json +19 -0
  4. package/Dockerfile +26 -0
  5. package/bin/retold-facto.js +909 -0
  6. package/examples/facto-government-data.sqlite +0 -0
  7. package/examples/government-data-catalog.json +137 -0
  8. package/examples/government-data-loader.js +1432 -0
  9. package/package.json +91 -0
  10. package/scripts/facto-download.js +425 -0
  11. package/source/Retold-Facto.js +1042 -0
  12. package/source/services/Retold-Facto-BeaconProvider.js +511 -0
  13. package/source/services/Retold-Facto-CatalogManager.js +1252 -0
  14. package/source/services/Retold-Facto-DataLakeService.js +1642 -0
  15. package/source/services/Retold-Facto-DatasetManager.js +417 -0
  16. package/source/services/Retold-Facto-IngestEngine.js +1315 -0
  17. package/source/services/Retold-Facto-ProjectionEngine.js +3960 -0
  18. package/source/services/Retold-Facto-RecordManager.js +360 -0
  19. package/source/services/Retold-Facto-SchemaManager.js +1110 -0
  20. package/source/services/Retold-Facto-SourceFolderScanner.js +2243 -0
  21. package/source/services/Retold-Facto-SourceManager.js +730 -0
  22. package/source/services/Retold-Facto-StoreConnectionManager.js +441 -0
  23. package/source/services/Retold-Facto-ThroughputMonitor.js +478 -0
  24. package/source/services/web-app/codemirror-entry.js +7 -0
  25. package/source/services/web-app/pict-app/Pict-Application-Facto-Configuration.json +9 -0
  26. package/source/services/web-app/pict-app/Pict-Application-Facto.js +70 -0
  27. package/source/services/web-app/pict-app/Pict-Facto-Bundle.js +11 -0
  28. package/source/services/web-app/pict-app/providers/Pict-Provider-Facto-UI.js +66 -0
  29. package/source/services/web-app/pict-app/providers/Pict-Provider-Facto.js +69 -0
  30. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Catalog.js +93 -0
  31. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Connections.js +42 -0
  32. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Datasets.js +605 -0
  33. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Projections.js +188 -0
  34. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Scanner.js +80 -0
  35. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Schema.js +116 -0
  36. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Sources.js +104 -0
  37. package/source/services/web-app/pict-app/views/PictView-Facto-Catalog.js +526 -0
  38. package/source/services/web-app/pict-app/views/PictView-Facto-Datasets.js +173 -0
  39. package/source/services/web-app/pict-app/views/PictView-Facto-Ingest.js +259 -0
  40. package/source/services/web-app/pict-app/views/PictView-Facto-Layout.js +191 -0
  41. package/source/services/web-app/pict-app/views/PictView-Facto-Projections.js +231 -0
  42. package/source/services/web-app/pict-app/views/PictView-Facto-Records.js +326 -0
  43. package/source/services/web-app/pict-app/views/PictView-Facto-Scanner.js +624 -0
  44. package/source/services/web-app/pict-app/views/PictView-Facto-Sources.js +201 -0
  45. package/source/services/web-app/pict-app/views/PictView-Facto-Throughput.js +456 -0
  46. package/source/services/web-app/pict-app-full/Pict-Application-Facto-Full-Configuration.json +14 -0
  47. package/source/services/web-app/pict-app-full/Pict-Application-Facto-Full.js +391 -0
  48. package/source/services/web-app/pict-app-full/providers/PictRouter-Facto-Configuration.json +56 -0
  49. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-BottomBar.js +68 -0
  50. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Connections.js +340 -0
  51. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Dashboard.js +149 -0
  52. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Dashboards.js +819 -0
  53. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Datasets.js +178 -0
  54. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-IngestJobs.js +99 -0
  55. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Layout.js +62 -0
  56. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-MappingEditor.js +158 -0
  57. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-ProjectionDetail.js +1120 -0
  58. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Projections.js +172 -0
  59. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-QueryPanel.js +119 -0
  60. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-RecordViewer.js +663 -0
  61. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Records.js +648 -0
  62. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Scanner.js +1017 -0
  63. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaDetail.js +1404 -0
  64. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaDocEditor.js +1036 -0
  65. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaEditor.js +636 -0
  66. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaResearch.js +357 -0
  67. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceDetail.js +822 -0
  68. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceEditor.js +1036 -0
  69. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceResearch.js +487 -0
  70. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Sources.js +165 -0
  71. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Throughput.js +439 -0
  72. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-TopBar.js +335 -0
  73. package/source/services/web-app/pict-app-full/views/projections/Facto-Projections-Constants.js +71 -0
  74. package/source/services/web-app/web/chart.min.js +20 -0
  75. package/source/services/web-app/web/codemirror-bundle.js +30099 -0
  76. package/source/services/web-app/web/css/facto-themes.css +467 -0
  77. package/source/services/web-app/web/css/facto.css +502 -0
  78. package/source/services/web-app/web/index.html +28 -0
  79. package/source/services/web-app/web/retold-facto.js +12138 -0
  80. package/source/services/web-app/web/retold-facto.js.map +1 -0
  81. package/source/services/web-app/web/retold-facto.min.js +2 -0
  82. package/source/services/web-app/web/retold-facto.min.js.map +1 -0
  83. package/source/services/web-app/web/simple/index.html +17 -0
  84. package/test/Facto_Browser_Integration_tests.js +798 -0
  85. package/test/RetoldFacto_tests.js +4117 -0
  86. package/test/fixtures/weather-readings.csv +17 -0
  87. package/test/fixtures/weather-stations.csv +9 -0
  88. package/test/model/MeadowModel-Extended.json +8497 -0
  89. package/test/model/MeadowModel-PICT.json +1 -0
  90. package/test/model/MeadowModel.json +1355 -0
  91. package/test/model/ddl/Facto.ddl +225 -0
  92. package/test/model/fable-configuration.json +14 -0
@@ -0,0 +1,1315 @@
1
+ /**
2
+ * Retold Facto - Ingest Engine Service
3
+ *
4
+ * Orchestrates the download and ingest of external datasets.
5
+ * Manages IngestJob lifecycle, download from configured sources,
6
+ * parsing, and record creation.
7
+ *
8
+ * @author Steven Velozo <steven@velozo.com>
9
+ */
10
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
11
+ const libMeadowIntegration = require('meadow-integration');
12
+ const libCrypto = require('crypto');
13
+ const libFs = require('fs');
14
+ const libPath = require('path');
15
+ const libHttp = require('http');
16
+ const libHttps = require('https');
17
+
18
+ const defaultIngestEngineOptions = (
19
+ {
20
+ RoutePrefix: '/facto',
21
+ DefaultCertaintyValue: 0.5
22
+ });
23
+
24
+ const VALID_JOB_STATUSES = ['Pending', 'Running', 'Completed', 'Failed', 'Cancelled'];
25
+
26
+ class RetoldFactoIngestEngine extends libFableServiceProviderBase
27
+ {
28
+ constructor(pFable, pOptions, pServiceHash)
29
+ {
30
+ let tmpOptions = Object.assign({}, defaultIngestEngineOptions, pOptions);
31
+ super(pFable, tmpOptions, pServiceHash);
32
+
33
+ this.serviceType = 'RetoldFactoIngestEngine';
34
+
35
+ this.fable.addAndInstantiateServiceTypeIfNotExists('MeadowIntegrationFileParser', libMeadowIntegration.FileParser);
36
+ }
37
+
38
+ /**
39
+ * Append a message to an ingest job's log.
40
+ */
41
+ appendJobLog(pIDIngestJob, pMessage, fCallback)
42
+ {
43
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
44
+ {
45
+ return fCallback(new Error('IngestJob DAL not initialized'));
46
+ }
47
+
48
+ // Read current log, append, update
49
+ let tmpReadQuery = this.fable.DAL.IngestJob.query.clone()
50
+ .addFilter('IDIngestJob', pIDIngestJob);
51
+
52
+ this.fable.DAL.IngestJob.doRead(tmpReadQuery,
53
+ (pError, pQuery, pRecord) =>
54
+ {
55
+ if (pError || !pRecord)
56
+ {
57
+ return fCallback(pError || new Error('Job not found'));
58
+ }
59
+
60
+ let tmpTimestamp = new Date().toISOString();
61
+ let tmpCurrentLog = pRecord.Log || '';
62
+ let tmpNewLog = tmpCurrentLog + `[${tmpTimestamp}] ${pMessage}\n`;
63
+
64
+ let tmpUpdateQuery = this.fable.DAL.IngestJob.query.clone()
65
+ .addRecord({ IDIngestJob: pIDIngestJob, Log: tmpNewLog });
66
+
67
+ this.fable.DAL.IngestJob.doUpdate(tmpUpdateQuery,
68
+ (pUpdateError) =>
69
+ {
70
+ return fCallback(pUpdateError);
71
+ });
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Compute a SHA-256 content signature for a piece of raw content.
77
+ *
78
+ * @param {string|Buffer} pContent - Raw content to hash
79
+ * @returns {string} Hex-encoded SHA-256 hash
80
+ */
81
+ computeContentSignature(pContent)
82
+ {
83
+ return libCrypto.createHash('sha256').update(pContent).digest('hex');
84
+ }
85
+
86
+ /**
87
+ * Get the next dataset version number for a given dataset.
88
+ * Queries IngestJob for the max DatasetVersion and returns max + 1.
89
+ *
90
+ * @param {number} pIDDataset - Dataset ID
91
+ * @param {function} fCallback - Callback(pError, pNextVersion)
92
+ */
93
+ getNextDatasetVersion(pIDDataset, fCallback)
94
+ {
95
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
96
+ {
97
+ return fCallback(null, 1);
98
+ }
99
+
100
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
101
+ .addFilter('IDDataset', pIDDataset)
102
+ .addFilter('Deleted', 0);
103
+
104
+ this.fable.DAL.IngestJob.doReads(tmpQuery,
105
+ (pError, pQuery, pRecords) =>
106
+ {
107
+ if (pError || !pRecords || pRecords.length === 0)
108
+ {
109
+ return fCallback(null, 1);
110
+ }
111
+
112
+ let tmpMaxVersion = 0;
113
+ for (let i = 0; i < pRecords.length; i++)
114
+ {
115
+ let tmpVersion = parseInt(pRecords[i].DatasetVersion, 10) || 0;
116
+ if (tmpVersion > tmpMaxVersion)
117
+ {
118
+ tmpMaxVersion = tmpVersion;
119
+ }
120
+ }
121
+ return fCallback(null, tmpMaxVersion + 1);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Check if an identical content signature already exists for a dataset.
127
+ *
128
+ * @param {number} pIDDataset - Dataset ID
129
+ * @param {string} pSignature - SHA-256 hex hash
130
+ * @param {function} fCallback - Callback(pError, pResult) where pResult = { isDuplicate, matchingVersion }
131
+ */
132
+ checkDuplicateSignature(pIDDataset, pSignature, fCallback)
133
+ {
134
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
135
+ {
136
+ return fCallback(null, { isDuplicate: false, matchingVersion: null });
137
+ }
138
+
139
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
140
+ .addFilter('IDDataset', pIDDataset)
141
+ .addFilter('ContentSignature', pSignature)
142
+ .addFilter('Deleted', 0);
143
+
144
+ this.fable.DAL.IngestJob.doReads(tmpQuery,
145
+ (pError, pQuery, pRecords) =>
146
+ {
147
+ if (pError || !pRecords || pRecords.length === 0)
148
+ {
149
+ return fCallback(null, { isDuplicate: false, matchingVersion: null });
150
+ }
151
+
152
+ return fCallback(null,
153
+ {
154
+ isDuplicate: true,
155
+ matchingVersion: parseInt(pRecords[0].DatasetVersion, 10) || 0
156
+ });
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Enforce the dataset's VersionPolicy before a new import.
162
+ * If 'Replace': soft-delete all existing Records for the dataset.
163
+ * If 'Append': no-op.
164
+ *
165
+ * @param {number} pIDDataset - Dataset ID
166
+ * @param {function} fCallback - Callback(pError)
167
+ */
168
+ enforceVersionPolicy(pIDDataset, fCallback)
169
+ {
170
+ if (!this.fable.DAL || !this.fable.DAL.Dataset || !this.fable.DAL.Record)
171
+ {
172
+ return fCallback();
173
+ }
174
+
175
+ // Read the Dataset to get its VersionPolicy
176
+ let tmpDatasetQuery = this.fable.DAL.Dataset.query.clone()
177
+ .addFilter('IDDataset', pIDDataset);
178
+
179
+ this.fable.DAL.Dataset.doRead(tmpDatasetQuery,
180
+ (pError, pQuery, pDataset) =>
181
+ {
182
+ if (pError || !pDataset)
183
+ {
184
+ return fCallback();
185
+ }
186
+
187
+ let tmpPolicy = pDataset.VersionPolicy || 'Append';
188
+
189
+ if (tmpPolicy !== 'Replace')
190
+ {
191
+ return fCallback();
192
+ }
193
+
194
+ // Soft-delete all existing non-deleted records for this dataset
195
+ let tmpRecordQuery = this.fable.DAL.Record.query.clone()
196
+ .addFilter('IDDataset', pIDDataset)
197
+ .addFilter('Deleted', 0);
198
+
199
+ this.fable.DAL.Record.doReads(tmpRecordQuery,
200
+ (pReadError, pReadQuery, pRecords) =>
201
+ {
202
+ if (pReadError || !pRecords || pRecords.length === 0)
203
+ {
204
+ return fCallback();
205
+ }
206
+
207
+ let tmpAnticipate = this.fable.newAnticipate();
208
+
209
+ for (let i = 0; i < pRecords.length; i++)
210
+ {
211
+ let tmpRecord = pRecords[i];
212
+ tmpAnticipate.anticipate(
213
+ (fStep) =>
214
+ {
215
+ let tmpDeleteQuery = this.fable.DAL.Record.query.clone()
216
+ .addRecord(
217
+ {
218
+ IDRecord: tmpRecord.IDRecord,
219
+ Deleted: 1,
220
+ DeleteDate: new Date().toISOString()
221
+ });
222
+
223
+ this.fable.DAL.Record.doUpdate(tmpDeleteQuery,
224
+ (pDelError) =>
225
+ {
226
+ return fStep();
227
+ });
228
+ });
229
+ }
230
+
231
+ tmpAnticipate.wait(
232
+ (pWaitError) =>
233
+ {
234
+ this.fable.log.info(`VersionPolicy Replace: soft-deleted ${pRecords.length} existing records for dataset ${pIDDataset}`);
235
+ return fCallback();
236
+ });
237
+ });
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Create an IngestJob record for a content import.
243
+ *
244
+ * @param {object} pJobData - { IDDataset, IDSource, DatasetVersion, ContentSignature, RecordCount, Format }
245
+ * @param {function} fCallback - Callback(pError, pIngestJob)
246
+ */
247
+ createIngestJob(pJobData, fCallback)
248
+ {
249
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
250
+ {
251
+ return fCallback(null, null);
252
+ }
253
+
254
+ let tmpJobRecord = {
255
+ IDDataset: pJobData.IDDataset || 0,
256
+ IDSource: pJobData.IDSource || 0,
257
+ Status: 'Completed',
258
+ StartDate: new Date().toISOString(),
259
+ EndDate: new Date().toISOString(),
260
+ RecordsProcessed: pJobData.RecordCount || 0,
261
+ RecordsCreated: pJobData.RecordCount || 0,
262
+ RecordsUpdated: 0,
263
+ RecordsErrored: pJobData.ErrorCount || 0,
264
+ DatasetVersion: pJobData.DatasetVersion || 0,
265
+ ContentSignature: pJobData.ContentSignature || '',
266
+ Configuration: JSON.stringify({ Format: pJobData.Format || 'unknown' }),
267
+ Log: `[${new Date().toISOString()}] Auto-created by ingest (v${pJobData.DatasetVersion || 0})\n`
268
+ };
269
+
270
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
271
+ .addRecord(tmpJobRecord);
272
+
273
+ this.fable.DAL.IngestJob.doCreate(tmpQuery,
274
+ (pError, pQuery, pQueryRead, pRecord) =>
275
+ {
276
+ if (pError)
277
+ {
278
+ this.fable.log.error(`Error creating IngestJob: ${pError}`);
279
+ return fCallback(pError);
280
+ }
281
+ return fCallback(null, pRecord);
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Navigate a nested object using a dot-separated path with optional
287
+ * array index notation (e.g. "Results.series[0].data").
288
+ * Delegates to MeadowIntegrationFileParserJSON.
289
+ *
290
+ * @param {object} pObject - The object to navigate
291
+ * @param {string} pPath - Dot-separated path, segments may include [n]
292
+ * @returns {*} The resolved value, or null if the path is invalid
293
+ */
294
+ _resolveDataPath(pObject, pPath)
295
+ {
296
+ return this.fable.MeadowIntegrationFileParserJSON._resolveDataPath(pObject, pPath);
297
+ }
298
+
299
+ /**
300
+ * Parse a CSV string into an array of objects.
301
+ * Delegates to MeadowIntegrationFileParser.
302
+ *
303
+ * @param {string} pCSVContent - Raw CSV text
304
+ * @param {object} [pOptions] - Options: delimiter, stripCommentLines (boolean)
305
+ * @param {function} fCallback - Callback(pError, pRecords)
306
+ */
307
+ parseCSV(pCSVContent, pOptions, fCallback)
308
+ {
309
+ if (typeof pOptions === 'function')
310
+ {
311
+ fCallback = pOptions;
312
+ pOptions = {};
313
+ }
314
+
315
+ let tmpParseOptions = { format: 'csv' };
316
+ if (pOptions && pOptions.delimiter)
317
+ {
318
+ tmpParseOptions.delimiter = pOptions.delimiter;
319
+ }
320
+ if (pOptions && pOptions.stripCommentLines)
321
+ {
322
+ tmpParseOptions.commentPrefix = '#';
323
+ }
324
+
325
+ return this.fable.MeadowIntegrationFileParser.parseContent(pCSVContent, tmpParseOptions, fCallback);
326
+ }
327
+
328
+ /**
329
+ * Parse an XML string into an array of objects.
330
+ * Delegates to MeadowIntegrationFileParser.
331
+ *
332
+ * @param {string} pXMLContent - Raw XML text
333
+ * @param {object} [pOptions] - Options: recordPath (dot-separated path to records array)
334
+ * @param {function} fCallback - Callback(pError, pRecords)
335
+ */
336
+ parseXML(pXMLContent, pOptions, fCallback)
337
+ {
338
+ if (typeof pOptions === 'function')
339
+ {
340
+ fCallback = pOptions;
341
+ pOptions = {};
342
+ }
343
+
344
+ let tmpParseOptions = Object.assign({ format: 'xml' }, pOptions);
345
+ return this.fable.MeadowIntegrationFileParser.parseContent(pXMLContent, tmpParseOptions, fCallback);
346
+ }
347
+
348
+ /**
349
+ * Parse an Excel buffer into an array of objects.
350
+ * Delegates to MeadowIntegrationFileParser.
351
+ *
352
+ * @param {Buffer} pExcelBuffer - Excel file data as a Buffer
353
+ * @param {object} [pOptions] - Options: sheet (sheet name or index)
354
+ * @param {function} fCallback - Callback(pError, pRecords)
355
+ */
356
+ parseExcel(pExcelBuffer, pOptions, fCallback)
357
+ {
358
+ if (typeof pOptions === 'function')
359
+ {
360
+ fCallback = pOptions;
361
+ pOptions = {};
362
+ }
363
+
364
+ let tmpParseOptions = { format: 'xlsx' };
365
+ if (pOptions && pOptions.sheet !== undefined)
366
+ {
367
+ if (typeof pOptions.sheet === 'number')
368
+ {
369
+ tmpParseOptions.sheetIndex = pOptions.sheet;
370
+ }
371
+ else
372
+ {
373
+ tmpParseOptions.sheetName = pOptions.sheet;
374
+ }
375
+ }
376
+
377
+ return this.fable.MeadowIntegrationFileParser.parseContent(pExcelBuffer, tmpParseOptions, fCallback);
378
+ }
379
+
380
+ /**
381
+ * Parse fixed-width text into an array of objects.
382
+ * Delegates to MeadowIntegrationFileParser.
383
+ *
384
+ * @param {string} pContent - Fixed-width text content
385
+ * @param {object} pOptions - Required: columns (array of {name, start, width}), optional: skipLines
386
+ * @param {function} fCallback - Callback(pError, pRecords)
387
+ */
388
+ parseFixedWidth(pContent, pOptions, fCallback)
389
+ {
390
+ if (typeof pOptions === 'function')
391
+ {
392
+ fCallback = pOptions;
393
+ pOptions = {};
394
+ }
395
+
396
+ let tmpParseOptions = Object.assign({ format: 'fixedwidth' }, pOptions);
397
+ return this.fable.MeadowIntegrationFileParser.parseContent(pContent, tmpParseOptions, fCallback);
398
+ }
399
+
400
+ /**
401
+ * Parse a JSON string into an array of objects.
402
+ * Delegates to MeadowIntegrationFileParser.
403
+ *
404
+ * @param {string} pJSONContent - Raw JSON text
405
+ * @param {object} [pOptions] - Options: dataPath (dot-separated path to records array)
406
+ * @param {function} fCallback - Callback(pError, pRecords)
407
+ */
408
+ parseJSON(pJSONContent, pOptions, fCallback)
409
+ {
410
+ if (typeof pOptions === 'function')
411
+ {
412
+ fCallback = pOptions;
413
+ pOptions = {};
414
+ }
415
+
416
+ let tmpParseOptions = { format: 'json' };
417
+ if (pOptions && pOptions.dataPath)
418
+ {
419
+ tmpParseOptions.rootPath = pOptions.dataPath;
420
+ }
421
+
422
+ return this.fable.MeadowIntegrationFileParser.parseContent(pJSONContent, tmpParseOptions, fCallback);
423
+ }
424
+
425
+ /**
426
+ * Download content from a URL using Node's built-in http/https.
427
+ * Follows 3xx redirects (up to 5 hops), has a 30-second timeout.
428
+ *
429
+ * @param {string} pURL - The URL to download
430
+ * @param {function} fCallback - Callback(pError, pBuffer)
431
+ */
432
+ downloadURL(pURL, fCallback)
433
+ {
434
+ let tmpMaxRedirects = 5;
435
+
436
+ let tmpDoRequest = (pRequestURL, pRedirectCount) =>
437
+ {
438
+ if (pRedirectCount > tmpMaxRedirects)
439
+ {
440
+ return fCallback(new Error('Too many redirects (max ' + tmpMaxRedirects + ')'));
441
+ }
442
+
443
+ let tmpLib = pRequestURL.startsWith('https') ? libHttps : libHttp;
444
+
445
+ let tmpRequest = tmpLib.get(pRequestURL,
446
+ (pResponse) =>
447
+ {
448
+ // Follow redirects
449
+ if (pResponse.statusCode >= 300 && pResponse.statusCode < 400 && pResponse.headers.location)
450
+ {
451
+ let tmpRedirectURL = pResponse.headers.location;
452
+ // Handle relative redirect URLs
453
+ if (tmpRedirectURL.startsWith('/'))
454
+ {
455
+ let tmpParsed = new URL(pRequestURL);
456
+ tmpRedirectURL = tmpParsed.protocol + '//' + tmpParsed.host + tmpRedirectURL;
457
+ }
458
+ pResponse.resume();
459
+ return tmpDoRequest(tmpRedirectURL, pRedirectCount + 1);
460
+ }
461
+
462
+ if (pResponse.statusCode !== 200)
463
+ {
464
+ pResponse.resume();
465
+ return fCallback(new Error('HTTP ' + pResponse.statusCode + ' from ' + pRequestURL));
466
+ }
467
+
468
+ let tmpChunks = [];
469
+ pResponse.on('data',
470
+ (pChunk) =>
471
+ {
472
+ tmpChunks.push(pChunk);
473
+ });
474
+ pResponse.on('end',
475
+ () =>
476
+ {
477
+ return fCallback(null, Buffer.concat(tmpChunks));
478
+ });
479
+ pResponse.on('error',
480
+ (pStreamError) =>
481
+ {
482
+ return fCallback(pStreamError);
483
+ });
484
+ });
485
+
486
+ tmpRequest.on('error',
487
+ (pRequestError) =>
488
+ {
489
+ return fCallback(pRequestError);
490
+ });
491
+
492
+ tmpRequest.setTimeout(30000,
493
+ () =>
494
+ {
495
+ tmpRequest.destroy();
496
+ return fCallback(new Error('Request timeout after 30 seconds'));
497
+ });
498
+ };
499
+
500
+ tmpDoRequest(pURL, 0);
501
+ }
502
+
503
+ /**
504
+ * Ingest raw content (string or Buffer) into a dataset from a source.
505
+ * Parses the content, runs the version/signature pipeline, and creates
506
+ * Record rows with CertaintyIndex entries.
507
+ *
508
+ * @param {string|Buffer} pContent - Raw content to ingest
509
+ * @param {number} pIDDataset - Target dataset ID
510
+ * @param {number} pIDSource - Source ID
511
+ * @param {object} [pOptions] - Options: format, type, delimiter, stripCommentLines, dataPath, recordPath, columns
512
+ * @param {function} fCallback - Callback(pError, pResult)
513
+ */
514
+ ingestContent(pContent, pIDDataset, pIDSource, pOptions, fCallback)
515
+ {
516
+ if (typeof pOptions === 'function')
517
+ {
518
+ fCallback = pOptions;
519
+ pOptions = {};
520
+ }
521
+
522
+ let tmpFormat = (pOptions && pOptions.format) ? pOptions.format.toLowerCase() : '';
523
+ let tmpRecordType = (pOptions && pOptions.type) ? pOptions.type : 'ingest';
524
+ let tmpDelimiter = (pOptions && pOptions.delimiter) ? pOptions.delimiter : ',';
525
+ let tmpStripComments = (pOptions && pOptions.stripCommentLines) ? true : false;
526
+ let tmpDataPath = (pOptions && pOptions.dataPath) ? pOptions.dataPath : '';
527
+ let tmpRecordPath = (pOptions && pOptions.recordPath) ? pOptions.recordPath : '';
528
+ let tmpColumns = (pOptions && pOptions.columns) ? pOptions.columns : null;
529
+
530
+ let tmpContentString = (Buffer.isBuffer(pContent)) ? pContent.toString('utf8') : pContent;
531
+
532
+ // Auto-detect format if not specified
533
+ if (!tmpFormat)
534
+ {
535
+ let tmpTrimmed = tmpContentString.trim();
536
+ if (tmpTrimmed.startsWith('[') || tmpTrimmed.startsWith('{'))
537
+ {
538
+ tmpFormat = 'json';
539
+ }
540
+ else if (tmpTrimmed.startsWith('<?xml') || tmpTrimmed.startsWith('<'))
541
+ {
542
+ tmpFormat = 'xml';
543
+ }
544
+ else
545
+ {
546
+ tmpFormat = 'csv';
547
+ }
548
+ }
549
+
550
+ // Compute content signature
551
+ let tmpSignature = this.computeContentSignature(tmpContentString);
552
+
553
+ let tmpParseCallback = (pParseError, pParsedRecords) =>
554
+ {
555
+ if (pParseError)
556
+ {
557
+ return fCallback(new Error('Parse error: ' + pParseError.message));
558
+ }
559
+
560
+ if (!pParsedRecords || pParsedRecords.length === 0)
561
+ {
562
+ return fCallback(null, { Ingested: 0, Errors: 0, Total: 0, Format: tmpFormat });
563
+ }
564
+
565
+ // Version/signature pipeline
566
+ let tmpVersionAnticipate = this.fable.newAnticipate();
567
+ let tmpDatasetVersion = 1;
568
+ let tmpDuplicateResult = { isDuplicate: false, matchingVersion: null };
569
+ let tmpIngestJob = null;
570
+
571
+ tmpVersionAnticipate.anticipate(
572
+ (fStep) =>
573
+ {
574
+ this.getNextDatasetVersion(pIDDataset,
575
+ (pError, pNextVersion) =>
576
+ {
577
+ tmpDatasetVersion = pNextVersion || 1;
578
+ return fStep();
579
+ });
580
+ });
581
+
582
+ tmpVersionAnticipate.anticipate(
583
+ (fStep) =>
584
+ {
585
+ this.checkDuplicateSignature(pIDDataset, tmpSignature,
586
+ (pError, pResult) =>
587
+ {
588
+ if (pResult)
589
+ {
590
+ tmpDuplicateResult = pResult;
591
+ if (pResult.isDuplicate)
592
+ {
593
+ this.fable.log.warn(`Duplicate content detected for dataset ${pIDDataset} (matches version ${pResult.matchingVersion})`);
594
+ }
595
+ }
596
+ return fStep();
597
+ });
598
+ });
599
+
600
+ tmpVersionAnticipate.anticipate(
601
+ (fStep) =>
602
+ {
603
+ this.enforceVersionPolicy(pIDDataset, fStep);
604
+ });
605
+
606
+ tmpVersionAnticipate.anticipate(
607
+ (fStep) =>
608
+ {
609
+ this.createIngestJob(
610
+ {
611
+ IDDataset: pIDDataset,
612
+ IDSource: pIDSource,
613
+ DatasetVersion: tmpDatasetVersion,
614
+ ContentSignature: tmpSignature,
615
+ RecordCount: pParsedRecords.length,
616
+ Format: tmpFormat
617
+ },
618
+ (pError, pJob) =>
619
+ {
620
+ tmpIngestJob = pJob;
621
+ return fStep();
622
+ });
623
+ });
624
+
625
+ tmpVersionAnticipate.wait(
626
+ (pVersionError) =>
627
+ {
628
+ let tmpAnticipate = this.fable.newAnticipate();
629
+ let tmpIngested = 0;
630
+ let tmpErrors = 0;
631
+ let tmpIngestedRecords = [];
632
+ let tmpIDIngestJob = (tmpIngestJob) ? tmpIngestJob.IDIngestJob : 0;
633
+
634
+ for (let i = 0; i < pParsedRecords.length; i++)
635
+ {
636
+ let tmpRowData = pParsedRecords[i];
637
+
638
+ tmpAnticipate.anticipate(
639
+ (fStepCallback) =>
640
+ {
641
+ let tmpRecordData = {
642
+ IDDataset: pIDDataset,
643
+ IDSource: pIDSource,
644
+ Type: tmpRecordType,
645
+ Version: tmpDatasetVersion,
646
+ IDIngestJob: tmpIDIngestJob,
647
+ IngestDate: new Date().toISOString(),
648
+ Content: (typeof tmpRowData === 'string') ? tmpRowData : JSON.stringify(tmpRowData)
649
+ };
650
+
651
+ let tmpQuery = this.fable.DAL.Record.query.clone()
652
+ .addRecord(tmpRecordData);
653
+
654
+ this.fable.DAL.Record.doCreate(tmpQuery,
655
+ (pCreateError, pQuery, pQueryRead, pRecord) =>
656
+ {
657
+ if (pCreateError)
658
+ {
659
+ tmpErrors++;
660
+ return fStepCallback();
661
+ }
662
+
663
+ tmpIngested++;
664
+ tmpIngestedRecords.push(pRecord);
665
+
666
+ let tmpCertaintyValue = this.options.DefaultCertaintyValue || 0.5;
667
+ let tmpCIQuery = this.fable.DAL.CertaintyIndex.query.clone()
668
+ .addRecord(
669
+ {
670
+ IDRecord: pRecord.IDRecord,
671
+ CertaintyValue: tmpCertaintyValue,
672
+ Dimension: 'overall',
673
+ Justification: 'Default ingest certainty'
674
+ });
675
+
676
+ this.fable.DAL.CertaintyIndex.doCreate(tmpCIQuery,
677
+ (pCIError) =>
678
+ {
679
+ return fStepCallback();
680
+ });
681
+ });
682
+ });
683
+ }
684
+
685
+ tmpAnticipate.wait(
686
+ (pWaitError) =>
687
+ {
688
+ return fCallback(null,
689
+ {
690
+ Ingested: tmpIngested,
691
+ Errors: tmpErrors,
692
+ Total: pParsedRecords.length,
693
+ Records: tmpIngestedRecords,
694
+ Format: tmpFormat,
695
+ DatasetVersion: tmpDatasetVersion,
696
+ ContentSignature: tmpSignature,
697
+ IsDuplicate: tmpDuplicateResult.isDuplicate,
698
+ IngestJob: tmpIngestJob
699
+ });
700
+ });
701
+ });
702
+ };
703
+
704
+ let tmpParserOptions = { format: tmpFormat };
705
+
706
+ if (tmpFormat === 'csv')
707
+ {
708
+ tmpParserOptions.delimiter = tmpDelimiter;
709
+ if (tmpStripComments) tmpParserOptions.commentPrefix = '#';
710
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContentString, tmpParserOptions, tmpParseCallback);
711
+ }
712
+ else if (tmpFormat === 'json')
713
+ {
714
+ if (tmpDataPath) tmpParserOptions.rootPath = tmpDataPath;
715
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContentString, tmpParserOptions, tmpParseCallback);
716
+ }
717
+ else if (tmpFormat === 'xml')
718
+ {
719
+ if (tmpRecordPath) tmpParserOptions.recordPath = tmpRecordPath;
720
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContentString, tmpParserOptions, tmpParseCallback);
721
+ }
722
+ else if (tmpFormat === 'excel')
723
+ {
724
+ tmpParserOptions.format = 'xlsx';
725
+ let tmpBuffer = Buffer.isBuffer(pContent) ? pContent : Buffer.from(pContent, 'base64');
726
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpBuffer, tmpParserOptions, tmpParseCallback);
727
+ }
728
+ else if (tmpFormat === 'fixed-width' || tmpFormat === 'other')
729
+ {
730
+ if (!tmpColumns)
731
+ {
732
+ return fCallback(new Error('Columns specification required for fixed-width format'));
733
+ }
734
+ tmpParserOptions.format = 'fixedwidth';
735
+ tmpParserOptions.columns = tmpColumns;
736
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContentString, tmpParserOptions, tmpParseCallback);
737
+ }
738
+ else
739
+ {
740
+ return fCallback(new Error('Unsupported format: ' + tmpFormat));
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Ingest a file (CSV or JSON) into a dataset from a source.
746
+ *
747
+ * @param {string} pFilePath - Absolute path to the file
748
+ * @param {number} pIDDataset - Target dataset ID
749
+ * @param {number} pIDSource - Source ID
750
+ * @param {object} [pOptions] - Options: format (csv/json), type, delimiter
751
+ * @param {function} fCallback - Callback(pError, pResult)
752
+ */
753
+ ingestFile(pFilePath, pIDDataset, pIDSource, pOptions, fCallback)
754
+ {
755
+ if (typeof pOptions === 'function')
756
+ {
757
+ fCallback = pOptions;
758
+ pOptions = {};
759
+ }
760
+
761
+ let tmpFormat = (pOptions && pOptions.format) ? pOptions.format.toLowerCase() : '';
762
+ let tmpRecordType = (pOptions && pOptions.type) ? pOptions.type : 'file-ingest';
763
+ let tmpDelimiter = (pOptions && pOptions.delimiter) ? pOptions.delimiter : ',';
764
+
765
+ let tmpStripComments = (pOptions && pOptions.stripCommentLines) ? true : false;
766
+
767
+ // Auto-detect format from extension if not specified
768
+ if (!tmpFormat)
769
+ {
770
+ let tmpExt = libPath.extname(pFilePath).toLowerCase();
771
+ if (tmpExt === '.csv' || tmpExt === '.tsv')
772
+ {
773
+ tmpFormat = 'csv';
774
+ if (tmpExt === '.tsv') tmpDelimiter = '\t';
775
+ }
776
+ else if (tmpExt === '.json')
777
+ {
778
+ tmpFormat = 'json';
779
+ }
780
+ else if (tmpExt === '.xml')
781
+ {
782
+ tmpFormat = 'xml';
783
+ }
784
+ else if (tmpExt === '.xlsx' || tmpExt === '.xls')
785
+ {
786
+ tmpFormat = 'excel';
787
+ }
788
+ else if (tmpExt === '.fw' || tmpExt === '.dat')
789
+ {
790
+ tmpFormat = 'fixed-width';
791
+ }
792
+ else if (tmpExt === '.rdb')
793
+ {
794
+ tmpFormat = 'csv';
795
+ tmpDelimiter = '\t';
796
+ tmpStripComments = true;
797
+ }
798
+ else
799
+ {
800
+ return fCallback(new Error(`Cannot determine format for file extension: ${tmpExt}`));
801
+ }
802
+ }
803
+
804
+ // Read the file (binary for Excel, utf8 for everything else)
805
+ let tmpContent;
806
+ try
807
+ {
808
+ if (tmpFormat === 'excel')
809
+ {
810
+ tmpContent = libFs.readFileSync(pFilePath);
811
+ }
812
+ else
813
+ {
814
+ tmpContent = libFs.readFileSync(pFilePath, 'utf8');
815
+ }
816
+ }
817
+ catch (pReadError)
818
+ {
819
+ return fCallback(pReadError);
820
+ }
821
+
822
+ // Compute content signature from raw content
823
+ let tmpContentForSignature = (tmpFormat === 'excel') ? tmpContent : tmpContent;
824
+ let tmpSignature = this.computeContentSignature(tmpContentForSignature);
825
+
826
+ // Parse based on format
827
+ let tmpParseCallback = (pParseError, pParsedRecords) =>
828
+ {
829
+ if (pParseError)
830
+ {
831
+ return fCallback(pParseError);
832
+ }
833
+
834
+ if (!pParsedRecords || pParsedRecords.length === 0)
835
+ {
836
+ return fCallback(null, { Ingested: 0, Errors: 0, Total: 0 });
837
+ }
838
+
839
+ // Version/signature pipeline
840
+ let tmpVersionAnticipate = this.fable.newAnticipate();
841
+ let tmpDatasetVersion = 1;
842
+ let tmpDuplicateResult = { isDuplicate: false, matchingVersion: null };
843
+ let tmpIngestJob = null;
844
+
845
+ // Step 1: Get next version
846
+ tmpVersionAnticipate.anticipate(
847
+ (fStep) =>
848
+ {
849
+ this.getNextDatasetVersion(pIDDataset,
850
+ (pError, pNextVersion) =>
851
+ {
852
+ tmpDatasetVersion = pNextVersion || 1;
853
+ return fStep();
854
+ });
855
+ });
856
+
857
+ // Step 2: Check for duplicate signature
858
+ tmpVersionAnticipate.anticipate(
859
+ (fStep) =>
860
+ {
861
+ this.checkDuplicateSignature(pIDDataset, tmpSignature,
862
+ (pError, pResult) =>
863
+ {
864
+ if (pResult)
865
+ {
866
+ tmpDuplicateResult = pResult;
867
+ if (pResult.isDuplicate)
868
+ {
869
+ this.fable.log.warn(`Duplicate content detected for dataset ${pIDDataset} (matches version ${pResult.matchingVersion})`);
870
+ }
871
+ }
872
+ return fStep();
873
+ });
874
+ });
875
+
876
+ // Step 3: Enforce version policy
877
+ tmpVersionAnticipate.anticipate(
878
+ (fStep) =>
879
+ {
880
+ this.enforceVersionPolicy(pIDDataset, fStep);
881
+ });
882
+
883
+ // Step 4: Create IngestJob, then create records
884
+ tmpVersionAnticipate.anticipate(
885
+ (fStep) =>
886
+ {
887
+ this.createIngestJob(
888
+ {
889
+ IDDataset: pIDDataset,
890
+ IDSource: pIDSource,
891
+ DatasetVersion: tmpDatasetVersion,
892
+ ContentSignature: tmpSignature,
893
+ RecordCount: pParsedRecords.length,
894
+ Format: tmpFormat
895
+ },
896
+ (pError, pJob) =>
897
+ {
898
+ tmpIngestJob = pJob;
899
+ return fStep();
900
+ });
901
+ });
902
+
903
+ tmpVersionAnticipate.wait(
904
+ (pVersionError) =>
905
+ {
906
+ // Now create the records with version info
907
+ let tmpAnticipate = this.fable.newAnticipate();
908
+ let tmpIngested = 0;
909
+ let tmpErrors = 0;
910
+ let tmpIngestedRecords = [];
911
+ let tmpIDIngestJob = (tmpIngestJob) ? tmpIngestJob.IDIngestJob : 0;
912
+
913
+ for (let i = 0; i < pParsedRecords.length; i++)
914
+ {
915
+ let tmpRowData = pParsedRecords[i];
916
+
917
+ tmpAnticipate.anticipate(
918
+ (fStepCallback) =>
919
+ {
920
+ let tmpRecordData = {
921
+ IDDataset: pIDDataset,
922
+ IDSource: pIDSource,
923
+ Type: tmpRecordType,
924
+ Version: tmpDatasetVersion,
925
+ IDIngestJob: tmpIDIngestJob,
926
+ IngestDate: new Date().toISOString(),
927
+ Content: (typeof tmpRowData === 'string') ? tmpRowData : JSON.stringify(tmpRowData)
928
+ };
929
+
930
+ let tmpQuery = this.fable.DAL.Record.query.clone()
931
+ .addRecord(tmpRecordData);
932
+
933
+ this.fable.DAL.Record.doCreate(tmpQuery,
934
+ (pCreateError, pQuery, pQueryRead, pRecord) =>
935
+ {
936
+ if (pCreateError)
937
+ {
938
+ tmpErrors++;
939
+ return fStepCallback();
940
+ }
941
+
942
+ tmpIngested++;
943
+ tmpIngestedRecords.push(pRecord);
944
+
945
+ // Auto-create certainty index
946
+ let tmpCertaintyValue = this.options.DefaultCertaintyValue || 0.5;
947
+ let tmpCIQuery = this.fable.DAL.CertaintyIndex.query.clone()
948
+ .addRecord(
949
+ {
950
+ IDRecord: pRecord.IDRecord,
951
+ CertaintyValue: tmpCertaintyValue,
952
+ Dimension: 'overall',
953
+ Justification: 'Default file-ingest certainty'
954
+ });
955
+
956
+ this.fable.DAL.CertaintyIndex.doCreate(tmpCIQuery,
957
+ (pCIError) =>
958
+ {
959
+ return fStepCallback();
960
+ });
961
+ });
962
+ });
963
+ }
964
+
965
+ tmpAnticipate.wait(
966
+ (pWaitError) =>
967
+ {
968
+ return fCallback(null,
969
+ {
970
+ Ingested: tmpIngested,
971
+ Errors: tmpErrors,
972
+ Total: pParsedRecords.length,
973
+ Records: tmpIngestedRecords,
974
+ Format: tmpFormat,
975
+ FilePath: pFilePath,
976
+ DatasetVersion: tmpDatasetVersion,
977
+ ContentSignature: tmpSignature,
978
+ IsDuplicate: tmpDuplicateResult.isDuplicate,
979
+ IngestJob: tmpIngestJob
980
+ });
981
+ });
982
+ });
983
+ };
984
+
985
+ let tmpParserOptions = { format: tmpFormat };
986
+
987
+ if (tmpFormat === 'csv')
988
+ {
989
+ tmpParserOptions.delimiter = tmpDelimiter;
990
+ if (tmpStripComments) tmpParserOptions.commentPrefix = '#';
991
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContent, tmpParserOptions, tmpParseCallback);
992
+ }
993
+ else if (tmpFormat === 'json')
994
+ {
995
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContent, tmpParserOptions, tmpParseCallback);
996
+ }
997
+ else if (tmpFormat === 'xml')
998
+ {
999
+ if (pOptions && pOptions.recordPath) tmpParserOptions.recordPath = pOptions.recordPath;
1000
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContent, tmpParserOptions, tmpParseCallback);
1001
+ }
1002
+ else if (tmpFormat === 'excel')
1003
+ {
1004
+ tmpParserOptions.format = 'xlsx';
1005
+ if (pOptions && pOptions.sheet !== undefined)
1006
+ {
1007
+ if (typeof pOptions.sheet === 'number') tmpParserOptions.sheetIndex = pOptions.sheet;
1008
+ else tmpParserOptions.sheetName = pOptions.sheet;
1009
+ }
1010
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContent, tmpParserOptions, tmpParseCallback);
1011
+ }
1012
+ else if (tmpFormat === 'fixed-width')
1013
+ {
1014
+ tmpParserOptions.format = 'fixedwidth';
1015
+ if (pOptions && pOptions.columns) tmpParserOptions.columns = pOptions.columns;
1016
+ if (pOptions && pOptions.skipLines !== undefined) tmpParserOptions.skipLines = pOptions.skipLines;
1017
+ this.fable.MeadowIntegrationFileParser.parseContent(tmpContent, tmpParserOptions, tmpParseCallback);
1018
+ }
1019
+ else
1020
+ {
1021
+ return fCallback(new Error(`Unsupported format: ${tmpFormat}`));
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Connect REST API routes for ingest operations.
1027
+ *
1028
+ * @param {object} pOratorServiceServer - The Orator service server instance
1029
+ */
1030
+ connectRoutes(pOratorServiceServer)
1031
+ {
1032
+ let tmpRoutePrefix = this.options.RoutePrefix;
1033
+
1034
+ // GET /facto/ingest/jobs -- list all ingest jobs
1035
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/ingest/jobs`,
1036
+ (pRequest, pResponse, fNext) =>
1037
+ {
1038
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
1039
+ {
1040
+ pResponse.send({ Jobs: [] });
1041
+ return fNext();
1042
+ }
1043
+
1044
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
1045
+ .addFilter('Deleted', 0)
1046
+ .setCap(100);
1047
+
1048
+ this.fable.DAL.IngestJob.doReads(tmpQuery,
1049
+ (pError, pQuery, pRecords) =>
1050
+ {
1051
+ if (pError)
1052
+ {
1053
+ this.fable.log.error(`IngestEngine error listing jobs: ${pError}`);
1054
+ pResponse.send({ Error: pError.message || pError, Jobs: [] });
1055
+ return fNext();
1056
+ }
1057
+ pResponse.send({ Count: pRecords.length, Jobs: pRecords });
1058
+ return fNext();
1059
+ });
1060
+ });
1061
+
1062
+ // GET /facto/ingest/job/:IDIngestJob -- get a single ingest job with details
1063
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/ingest/job/:IDIngestJob`,
1064
+ (pRequest, pResponse, fNext) =>
1065
+ {
1066
+ let tmpIDIngestJob = parseInt(pRequest.params.IDIngestJob, 10);
1067
+ if (isNaN(tmpIDIngestJob) || tmpIDIngestJob < 1)
1068
+ {
1069
+ pResponse.send({ Error: 'Invalid IDIngestJob parameter' });
1070
+ return fNext();
1071
+ }
1072
+
1073
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
1074
+ {
1075
+ pResponse.send({ Error: 'IngestJob DAL not initialized' });
1076
+ return fNext();
1077
+ }
1078
+
1079
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
1080
+ .addFilter('IDIngestJob', tmpIDIngestJob);
1081
+
1082
+ this.fable.DAL.IngestJob.doRead(tmpQuery,
1083
+ (pError, pQuery, pRecord) =>
1084
+ {
1085
+ if (pError || !pRecord)
1086
+ {
1087
+ pResponse.send({ Error: pError ? (pError.message || pError) : 'Job not found' });
1088
+ return fNext();
1089
+ }
1090
+ pResponse.send({ Job: pRecord });
1091
+ return fNext();
1092
+ });
1093
+ });
1094
+
1095
+ // POST /facto/ingest/job -- create a new ingest job
1096
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/ingest/job`,
1097
+ (pRequest, pResponse, fNext) =>
1098
+ {
1099
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
1100
+ {
1101
+ pResponse.send({ Error: 'IngestJob DAL not initialized' });
1102
+ return fNext();
1103
+ }
1104
+
1105
+ let tmpBody = pRequest.body || {};
1106
+ let tmpIDSource = parseInt(tmpBody.IDSource, 10) || 0;
1107
+ let tmpIDDataset = parseInt(tmpBody.IDDataset, 10) || 0;
1108
+ let tmpConfiguration = tmpBody.Configuration || '';
1109
+
1110
+ if (typeof tmpConfiguration === 'object')
1111
+ {
1112
+ tmpConfiguration = JSON.stringify(tmpConfiguration);
1113
+ }
1114
+
1115
+ let tmpJobData = {
1116
+ IDSource: tmpIDSource,
1117
+ IDDataset: tmpIDDataset,
1118
+ Status: 'Pending',
1119
+ RecordsProcessed: 0,
1120
+ RecordsCreated: 0,
1121
+ RecordsUpdated: 0,
1122
+ RecordsErrored: 0,
1123
+ Configuration: tmpConfiguration,
1124
+ Log: `[${new Date().toISOString()}] Job created\n`
1125
+ };
1126
+
1127
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
1128
+ .addRecord(tmpJobData);
1129
+
1130
+ this.fable.DAL.IngestJob.doCreate(tmpQuery,
1131
+ (pError, pQuery, pQueryRead, pRecord) =>
1132
+ {
1133
+ if (pError)
1134
+ {
1135
+ this.fable.log.error(`IngestEngine error creating job: ${pError}`);
1136
+ pResponse.send({ Error: pError.message || pError });
1137
+ return fNext();
1138
+ }
1139
+ pResponse.send({ Success: true, Job: pRecord });
1140
+ return fNext();
1141
+ });
1142
+ });
1143
+
1144
+ // PUT /facto/ingest/job/:IDIngestJob/start -- mark a job as running
1145
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/ingest/job/:IDIngestJob/start`,
1146
+ (pRequest, pResponse, fNext) =>
1147
+ {
1148
+ let tmpIDIngestJob = parseInt(pRequest.params.IDIngestJob, 10);
1149
+ if (isNaN(tmpIDIngestJob) || tmpIDIngestJob < 1)
1150
+ {
1151
+ pResponse.send({ Error: 'Invalid IDIngestJob parameter' });
1152
+ return fNext();
1153
+ }
1154
+
1155
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
1156
+ {
1157
+ pResponse.send({ Error: 'IngestJob DAL not initialized' });
1158
+ return fNext();
1159
+ }
1160
+
1161
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
1162
+ .addRecord(
1163
+ {
1164
+ IDIngestJob: tmpIDIngestJob,
1165
+ Status: 'Running',
1166
+ StartDate: new Date().toISOString()
1167
+ });
1168
+
1169
+ this.fable.DAL.IngestJob.doUpdate(tmpQuery,
1170
+ (pError, pQuery, pQueryRead, pRecord) =>
1171
+ {
1172
+ if (pError)
1173
+ {
1174
+ pResponse.send({ Error: pError.message || pError });
1175
+ return fNext();
1176
+ }
1177
+
1178
+ this.appendJobLog(tmpIDIngestJob, 'Job started',
1179
+ () =>
1180
+ {
1181
+ pResponse.send({ Success: true, Job: pRecord });
1182
+ return fNext();
1183
+ });
1184
+ });
1185
+ });
1186
+
1187
+ // PUT /facto/ingest/job/:IDIngestJob/complete -- mark a job as completed
1188
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/ingest/job/:IDIngestJob/complete`,
1189
+ (pRequest, pResponse, fNext) =>
1190
+ {
1191
+ let tmpIDIngestJob = parseInt(pRequest.params.IDIngestJob, 10);
1192
+ if (isNaN(tmpIDIngestJob) || tmpIDIngestJob < 1)
1193
+ {
1194
+ pResponse.send({ Error: 'Invalid IDIngestJob parameter' });
1195
+ return fNext();
1196
+ }
1197
+
1198
+ if (!this.fable.DAL || !this.fable.DAL.IngestJob)
1199
+ {
1200
+ pResponse.send({ Error: 'IngestJob DAL not initialized' });
1201
+ return fNext();
1202
+ }
1203
+
1204
+ let tmpBody = pRequest.body || {};
1205
+
1206
+ let tmpUpdateData = {
1207
+ IDIngestJob: tmpIDIngestJob,
1208
+ Status: tmpBody.Status || 'Completed',
1209
+ EndDate: new Date().toISOString()
1210
+ };
1211
+
1212
+ // Allow updating counters
1213
+ if (tmpBody.RecordsProcessed !== undefined)
1214
+ {
1215
+ tmpUpdateData.RecordsProcessed = parseInt(tmpBody.RecordsProcessed, 10) || 0;
1216
+ }
1217
+ if (tmpBody.RecordsCreated !== undefined)
1218
+ {
1219
+ tmpUpdateData.RecordsCreated = parseInt(tmpBody.RecordsCreated, 10) || 0;
1220
+ }
1221
+ if (tmpBody.RecordsUpdated !== undefined)
1222
+ {
1223
+ tmpUpdateData.RecordsUpdated = parseInt(tmpBody.RecordsUpdated, 10) || 0;
1224
+ }
1225
+ if (tmpBody.RecordsErrored !== undefined)
1226
+ {
1227
+ tmpUpdateData.RecordsErrored = parseInt(tmpBody.RecordsErrored, 10) || 0;
1228
+ }
1229
+
1230
+ // Validate status
1231
+ if (VALID_JOB_STATUSES.indexOf(tmpUpdateData.Status) < 0)
1232
+ {
1233
+ tmpUpdateData.Status = 'Completed';
1234
+ }
1235
+
1236
+ let tmpQuery = this.fable.DAL.IngestJob.query.clone()
1237
+ .addRecord(tmpUpdateData);
1238
+
1239
+ this.fable.DAL.IngestJob.doUpdate(tmpQuery,
1240
+ (pError, pQuery, pQueryRead, pRecord) =>
1241
+ {
1242
+ if (pError)
1243
+ {
1244
+ pResponse.send({ Error: pError.message || pError });
1245
+ return fNext();
1246
+ }
1247
+
1248
+ this.appendJobLog(tmpIDIngestJob, `Job ${tmpUpdateData.Status.toLowerCase()}`,
1249
+ () =>
1250
+ {
1251
+ pResponse.send({ Success: true, Job: pRecord });
1252
+ return fNext();
1253
+ });
1254
+ });
1255
+ });
1256
+
1257
+ // POST /facto/ingest/file -- ingest CSV or JSON data from request body
1258
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/ingest/file`,
1259
+ (pRequest, pResponse, fNext) =>
1260
+ {
1261
+ if (!this.fable.DAL || !this.fable.DAL.Record)
1262
+ {
1263
+ pResponse.send({ Error: 'Record DAL not initialized' });
1264
+ return fNext();
1265
+ }
1266
+
1267
+ let tmpBody = pRequest.body || {};
1268
+ let tmpContent = tmpBody.Content || '';
1269
+
1270
+ if (!tmpContent)
1271
+ {
1272
+ pResponse.send({ Error: 'Content is required (raw CSV, JSON, XML, or fixed-width string)', Ingested: 0 });
1273
+ return fNext();
1274
+ }
1275
+
1276
+ let tmpIDDataset = parseInt(tmpBody.IDDataset, 10) || 0;
1277
+ let tmpIDSource = parseInt(tmpBody.IDSource, 10) || 0;
1278
+
1279
+ let tmpOptions = {
1280
+ format: (tmpBody.Format || '').toLowerCase(),
1281
+ type: tmpBody.Type || 'file-ingest',
1282
+ delimiter: tmpBody.Delimiter || ',',
1283
+ stripCommentLines: tmpBody.StripCommentLines || false,
1284
+ columns: tmpBody.Columns || null,
1285
+ dataPath: tmpBody.DataPath || ''
1286
+ };
1287
+
1288
+ this.ingestContent(tmpContent, tmpIDDataset, tmpIDSource, tmpOptions,
1289
+ (pError, pResult) =>
1290
+ {
1291
+ if (pError)
1292
+ {
1293
+ pResponse.send({ Error: pError.message, Ingested: 0 });
1294
+ return fNext();
1295
+ }
1296
+ pResponse.send(pResult);
1297
+ return fNext();
1298
+ });
1299
+ });
1300
+
1301
+ // GET /facto/ingest/statuses -- list valid job statuses
1302
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/ingest/statuses`,
1303
+ (pRequest, pResponse, fNext) =>
1304
+ {
1305
+ pResponse.send({ Statuses: VALID_JOB_STATUSES });
1306
+ return fNext();
1307
+ });
1308
+
1309
+ this.fable.log.info(`IngestEngine routes connected at ${tmpRoutePrefix}/ingest/*`);
1310
+ }
1311
+ }
1312
+
1313
+ module.exports = RetoldFactoIngestEngine;
1314
+ module.exports.serviceType = 'RetoldFactoIngestEngine';
1315
+ module.exports.default_configuration = defaultIngestEngineOptions;