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.
- package/.claude/launch.json +11 -0
- package/.dockerignore +8 -0
- package/.quackage.json +19 -0
- package/Dockerfile +26 -0
- package/bin/retold-facto.js +909 -0
- package/examples/facto-government-data.sqlite +0 -0
- package/examples/government-data-catalog.json +137 -0
- package/examples/government-data-loader.js +1432 -0
- package/package.json +91 -0
- package/scripts/facto-download.js +425 -0
- package/source/Retold-Facto.js +1042 -0
- package/source/services/Retold-Facto-BeaconProvider.js +511 -0
- package/source/services/Retold-Facto-CatalogManager.js +1252 -0
- package/source/services/Retold-Facto-DataLakeService.js +1642 -0
- package/source/services/Retold-Facto-DatasetManager.js +417 -0
- package/source/services/Retold-Facto-IngestEngine.js +1315 -0
- package/source/services/Retold-Facto-ProjectionEngine.js +3960 -0
- package/source/services/Retold-Facto-RecordManager.js +360 -0
- package/source/services/Retold-Facto-SchemaManager.js +1110 -0
- package/source/services/Retold-Facto-SourceFolderScanner.js +2243 -0
- package/source/services/Retold-Facto-SourceManager.js +730 -0
- package/source/services/Retold-Facto-StoreConnectionManager.js +441 -0
- package/source/services/Retold-Facto-ThroughputMonitor.js +478 -0
- package/source/services/web-app/codemirror-entry.js +7 -0
- package/source/services/web-app/pict-app/Pict-Application-Facto-Configuration.json +9 -0
- package/source/services/web-app/pict-app/Pict-Application-Facto.js +70 -0
- package/source/services/web-app/pict-app/Pict-Facto-Bundle.js +11 -0
- package/source/services/web-app/pict-app/providers/Pict-Provider-Facto-UI.js +66 -0
- package/source/services/web-app/pict-app/providers/Pict-Provider-Facto.js +69 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Catalog.js +93 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Connections.js +42 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Datasets.js +605 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Projections.js +188 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Scanner.js +80 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Schema.js +116 -0
- package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Sources.js +104 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Catalog.js +526 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Datasets.js +173 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Ingest.js +259 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Layout.js +191 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Projections.js +231 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Records.js +326 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Scanner.js +624 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Sources.js +201 -0
- package/source/services/web-app/pict-app/views/PictView-Facto-Throughput.js +456 -0
- package/source/services/web-app/pict-app-full/Pict-Application-Facto-Full-Configuration.json +14 -0
- package/source/services/web-app/pict-app-full/Pict-Application-Facto-Full.js +391 -0
- package/source/services/web-app/pict-app-full/providers/PictRouter-Facto-Configuration.json +56 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-BottomBar.js +68 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Connections.js +340 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Dashboard.js +149 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Dashboards.js +819 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Datasets.js +178 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-IngestJobs.js +99 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Layout.js +62 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-MappingEditor.js +158 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-ProjectionDetail.js +1120 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Projections.js +172 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-QueryPanel.js +119 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-RecordViewer.js +663 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Records.js +648 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Scanner.js +1017 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaDetail.js +1404 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaDocEditor.js +1036 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaEditor.js +636 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaResearch.js +357 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceDetail.js +822 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceEditor.js +1036 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceResearch.js +487 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Sources.js +165 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Throughput.js +439 -0
- package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-TopBar.js +335 -0
- package/source/services/web-app/pict-app-full/views/projections/Facto-Projections-Constants.js +71 -0
- package/source/services/web-app/web/chart.min.js +20 -0
- package/source/services/web-app/web/codemirror-bundle.js +30099 -0
- package/source/services/web-app/web/css/facto-themes.css +467 -0
- package/source/services/web-app/web/css/facto.css +502 -0
- package/source/services/web-app/web/index.html +28 -0
- package/source/services/web-app/web/retold-facto.js +12138 -0
- package/source/services/web-app/web/retold-facto.js.map +1 -0
- package/source/services/web-app/web/retold-facto.min.js +2 -0
- package/source/services/web-app/web/retold-facto.min.js.map +1 -0
- package/source/services/web-app/web/simple/index.html +17 -0
- package/test/Facto_Browser_Integration_tests.js +798 -0
- package/test/RetoldFacto_tests.js +4117 -0
- package/test/fixtures/weather-readings.csv +17 -0
- package/test/fixtures/weather-stations.csv +9 -0
- package/test/model/MeadowModel-Extended.json +8497 -0
- package/test/model/MeadowModel-PICT.json +1 -0
- package/test/model/MeadowModel.json +1355 -0
- package/test/model/ddl/Facto.ddl +225 -0
- 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;
|