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,1252 @@
1
+ /**
2
+ * Retold Facto - Catalog Manager Service
3
+ *
4
+ * Manages a research catalog of known data sources and their available
5
+ * datasets ("database of databases"). Catalog entries describe agencies,
6
+ * URLs, categories, and per-dataset ingest hints (format, parseOptions,
7
+ * auth requirements). A "provision" action bridges catalog entries into
8
+ * runtime Source + Dataset + DatasetSource records.
9
+ *
10
+ * @author Steven Velozo <steven@velozo.com>
11
+ */
12
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
13
+
14
+ const defaultCatalogManagerOptions = (
15
+ {
16
+ RoutePrefix: '/facto'
17
+ });
18
+
19
+ class RetoldFactoCatalogManager extends libFableServiceProviderBase
20
+ {
21
+ constructor(pFable, pOptions, pServiceHash)
22
+ {
23
+ let tmpOptions = Object.assign({}, defaultCatalogManagerOptions, pOptions);
24
+ super(pFable, tmpOptions, pServiceHash);
25
+
26
+ this.serviceType = 'RetoldFactoCatalogManager';
27
+ }
28
+
29
+ /**
30
+ * Find a Source by name, or create one if it doesn't exist.
31
+ *
32
+ * @param {string} pName - Source name to find or create
33
+ * @param {object} pDefaults - Default fields for creation (Type, URL, Protocol, Description)
34
+ * @param {function} fCallback - Callback(pError, pSource)
35
+ */
36
+ findOrCreateSource(pName, pDefaults, fCallback)
37
+ {
38
+ if (!this.fable.DAL || !this.fable.DAL.Source)
39
+ {
40
+ return fCallback(new Error('Source DAL not initialized'));
41
+ }
42
+
43
+ let tmpQuery = this.fable.DAL.Source.query.clone()
44
+ .addFilter('Name', pName)
45
+ .addFilter('Deleted', 0);
46
+
47
+ this.fable.DAL.Source.doReads(tmpQuery,
48
+ (pError, pQuery, pRecords) =>
49
+ {
50
+ if (!pError && pRecords && pRecords.length > 0)
51
+ {
52
+ return fCallback(null, pRecords[0]);
53
+ }
54
+
55
+ // Create new source
56
+ let tmpSourceData = Object.assign(
57
+ {
58
+ Name: pName,
59
+ Hash: this.fable.RetoldFacto.generateHash(pName),
60
+ Active: 1
61
+ }, pDefaults || {});
62
+
63
+ let tmpCreateQuery = this.fable.DAL.Source.query.clone()
64
+ .addRecord(tmpSourceData);
65
+
66
+ this.fable.DAL.Source.doCreate(tmpCreateQuery,
67
+ (pCreateError, pCreateQuery, pCreateQueryRead, pRecord) =>
68
+ {
69
+ if (pCreateError)
70
+ {
71
+ return fCallback(pCreateError);
72
+ }
73
+ return fCallback(null, pRecord);
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Find a Dataset by name, or create one if it doesn't exist.
80
+ *
81
+ * @param {string} pName - Dataset name to find or create
82
+ * @param {object} pDefaults - Default fields for creation (Type, Description, VersionPolicy)
83
+ * @param {function} fCallback - Callback(pError, pDataset)
84
+ */
85
+ findOrCreateDataset(pName, pDefaults, fCallback)
86
+ {
87
+ if (!this.fable.DAL || !this.fable.DAL.Dataset)
88
+ {
89
+ return fCallback(new Error('Dataset DAL not initialized'));
90
+ }
91
+
92
+ let tmpQuery = this.fable.DAL.Dataset.query.clone()
93
+ .addFilter('Name', pName)
94
+ .addFilter('Deleted', 0);
95
+
96
+ this.fable.DAL.Dataset.doReads(tmpQuery,
97
+ (pError, pQuery, pRecords) =>
98
+ {
99
+ if (!pError && pRecords && pRecords.length > 0)
100
+ {
101
+ return fCallback(null, pRecords[0]);
102
+ }
103
+
104
+ // Create new dataset
105
+ let tmpDatasetData = Object.assign(
106
+ {
107
+ Name: pName,
108
+ Hash: this.fable.RetoldFacto.generateHash(pName),
109
+ Type: 'Raw'
110
+ }, pDefaults || {});
111
+
112
+ let tmpCreateQuery = this.fable.DAL.Dataset.query.clone()
113
+ .addRecord(tmpDatasetData);
114
+
115
+ this.fable.DAL.Dataset.doCreate(tmpCreateQuery,
116
+ (pCreateError, pCreateQuery, pCreateQueryRead, pRecord) =>
117
+ {
118
+ if (pCreateError)
119
+ {
120
+ return fCallback(pCreateError);
121
+ }
122
+ return fCallback(null, pRecord);
123
+ });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Ensure a DatasetSource link exists between a dataset and a source.
129
+ *
130
+ * @param {number} pIDDataset - Dataset ID
131
+ * @param {number} pIDSource - Source ID
132
+ * @param {function} fCallback - Callback(pError, pDatasetSource)
133
+ */
134
+ ensureDatasetSourceLink(pIDDataset, pIDSource, fCallback)
135
+ {
136
+ if (!this.fable.DAL || !this.fable.DAL.DatasetSource)
137
+ {
138
+ return fCallback(new Error('DatasetSource DAL not initialized'));
139
+ }
140
+
141
+ let tmpQuery = this.fable.DAL.DatasetSource.query.clone()
142
+ .addFilter('IDDataset', pIDDataset)
143
+ .addFilter('IDSource', pIDSource)
144
+ .addFilter('Deleted', 0);
145
+
146
+ this.fable.DAL.DatasetSource.doReads(tmpQuery,
147
+ (pError, pQuery, pRecords) =>
148
+ {
149
+ if (!pError && pRecords && pRecords.length > 0)
150
+ {
151
+ return fCallback(null, pRecords[0]);
152
+ }
153
+
154
+ // Create link
155
+ let tmpCreateQuery = this.fable.DAL.DatasetSource.query.clone()
156
+ .addRecord(
157
+ {
158
+ IDDataset: pIDDataset,
159
+ IDSource: pIDSource,
160
+ ReliabilityWeight: 1.0
161
+ });
162
+
163
+ this.fable.DAL.DatasetSource.doCreate(tmpCreateQuery,
164
+ (pCreateError, pCreateQuery, pCreateQueryRead, pRecord) =>
165
+ {
166
+ if (pCreateError)
167
+ {
168
+ return fCallback(pCreateError);
169
+ }
170
+ return fCallback(null, pRecord);
171
+ });
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Connect REST API routes for catalog management.
177
+ *
178
+ * @param {object} pOratorServiceServer - The Orator service server instance
179
+ */
180
+ connectRoutes(pOratorServiceServer)
181
+ {
182
+ let tmpRoutePrefix = this.options.RoutePrefix;
183
+
184
+ // ================================================================
185
+ // Catalog Entry CRUD
186
+ // ================================================================
187
+
188
+ // GET /facto/catalog/entries -- list all catalog entries (non-deleted)
189
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/catalog/entries`,
190
+ (pRequest, pResponse, fNext) =>
191
+ {
192
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry)
193
+ {
194
+ pResponse.send({ Error: 'SourceCatalogEntry DAL not initialized', Entries: [] });
195
+ return fNext();
196
+ }
197
+
198
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
199
+ .addFilter('Deleted', 0)
200
+ .setCap(500);
201
+
202
+ this.fable.DAL.SourceCatalogEntry.doReads(tmpQuery,
203
+ (pError, pQuery, pRecords) =>
204
+ {
205
+ if (pError)
206
+ {
207
+ this.fable.log.error(`CatalogManager error listing entries: ${pError}`);
208
+ pResponse.send({ Error: pError.message || pError, Entries: [] });
209
+ return fNext();
210
+ }
211
+ pResponse.send({ Count: pRecords.length, Entries: pRecords });
212
+ return fNext();
213
+ });
214
+ });
215
+
216
+ // GET /facto/catalog/entry/:IDSourceCatalogEntry -- get single entry with dataset definitions
217
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/catalog/entry/:IDSourceCatalogEntry`,
218
+ (pRequest, pResponse, fNext) =>
219
+ {
220
+ let tmpID = parseInt(pRequest.params.IDSourceCatalogEntry, 10);
221
+ if (isNaN(tmpID) || tmpID < 1)
222
+ {
223
+ pResponse.send({ Error: 'Invalid IDSourceCatalogEntry parameter' });
224
+ return fNext();
225
+ }
226
+
227
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry)
228
+ {
229
+ pResponse.send({ Error: 'SourceCatalogEntry DAL not initialized' });
230
+ return fNext();
231
+ }
232
+
233
+ let tmpAnticipate = this.fable.newAnticipate();
234
+ let tmpResult = { Entry: null, Datasets: [] };
235
+
236
+ // Load the entry
237
+ tmpAnticipate.anticipate(
238
+ (fStep) =>
239
+ {
240
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
241
+ .addFilter('IDSourceCatalogEntry', tmpID);
242
+
243
+ this.fable.DAL.SourceCatalogEntry.doRead(tmpQuery,
244
+ (pError, pQuery, pRecord) =>
245
+ {
246
+ if (!pError && pRecord)
247
+ {
248
+ tmpResult.Entry = pRecord;
249
+ }
250
+ return fStep();
251
+ });
252
+ });
253
+
254
+ // Load dataset definitions
255
+ tmpAnticipate.anticipate(
256
+ (fStep) =>
257
+ {
258
+ if (!this.fable.DAL.CatalogDatasetDefinition)
259
+ {
260
+ return fStep();
261
+ }
262
+
263
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
264
+ .addFilter('IDSourceCatalogEntry', tmpID)
265
+ .addFilter('Deleted', 0);
266
+
267
+ this.fable.DAL.CatalogDatasetDefinition.doReads(tmpQuery,
268
+ (pError, pQuery, pRecords) =>
269
+ {
270
+ if (!pError && pRecords)
271
+ {
272
+ tmpResult.Datasets = pRecords;
273
+ }
274
+ return fStep();
275
+ });
276
+ });
277
+
278
+ tmpAnticipate.wait(
279
+ (pError) =>
280
+ {
281
+ if (pError)
282
+ {
283
+ pResponse.send({ Error: pError.message || pError });
284
+ return fNext();
285
+ }
286
+ if (!tmpResult.Entry)
287
+ {
288
+ pResponse.send({ Error: 'Catalog entry not found' });
289
+ return fNext();
290
+ }
291
+ pResponse.send(tmpResult);
292
+ return fNext();
293
+ });
294
+ });
295
+
296
+ // POST /facto/catalog/entry -- create a catalog entry
297
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/catalog/entry`,
298
+ (pRequest, pResponse, fNext) =>
299
+ {
300
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry)
301
+ {
302
+ pResponse.send({ Error: 'SourceCatalogEntry DAL not initialized' });
303
+ return fNext();
304
+ }
305
+
306
+ let tmpBody = pRequest.body || {};
307
+
308
+ let tmpEntryData = {
309
+ Agency: tmpBody.Agency || '',
310
+ Name: tmpBody.Name || '',
311
+ Type: tmpBody.Type || '',
312
+ URL: tmpBody.URL || '',
313
+ Protocol: tmpBody.Protocol || '',
314
+ Category: tmpBody.Category || '',
315
+ Region: tmpBody.Region || '',
316
+ UpdateFrequency: tmpBody.UpdateFrequency || '',
317
+ Description: tmpBody.Description || '',
318
+ Notes: tmpBody.Notes || '',
319
+ Verified: tmpBody.Verified ? 1 : 0
320
+ };
321
+
322
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
323
+ .addRecord(tmpEntryData);
324
+
325
+ this.fable.DAL.SourceCatalogEntry.doCreate(tmpQuery,
326
+ (pError, pQuery, pQueryRead, pRecord) =>
327
+ {
328
+ if (pError)
329
+ {
330
+ this.fable.log.error(`CatalogManager error creating entry: ${pError}`);
331
+ pResponse.send({ Error: pError.message || pError });
332
+ return fNext();
333
+ }
334
+ pResponse.send({ Success: true, Entry: pRecord });
335
+ return fNext();
336
+ });
337
+ });
338
+
339
+ // PUT /facto/catalog/entry/:IDSourceCatalogEntry -- update a catalog entry
340
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/catalog/entry/:IDSourceCatalogEntry`,
341
+ (pRequest, pResponse, fNext) =>
342
+ {
343
+ let tmpID = parseInt(pRequest.params.IDSourceCatalogEntry, 10);
344
+ if (isNaN(tmpID) || tmpID < 1)
345
+ {
346
+ pResponse.send({ Error: 'Invalid IDSourceCatalogEntry parameter' });
347
+ return fNext();
348
+ }
349
+
350
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry)
351
+ {
352
+ pResponse.send({ Error: 'SourceCatalogEntry DAL not initialized' });
353
+ return fNext();
354
+ }
355
+
356
+ let tmpBody = pRequest.body || {};
357
+ let tmpUpdateData = { IDSourceCatalogEntry: tmpID };
358
+
359
+ // Only update fields that are present in the body
360
+ let tmpFields = ['Agency', 'Name', 'Type', 'URL', 'Protocol', 'Category', 'Region', 'UpdateFrequency', 'Description', 'Notes'];
361
+ for (let i = 0; i < tmpFields.length; i++)
362
+ {
363
+ if (tmpBody.hasOwnProperty(tmpFields[i]))
364
+ {
365
+ tmpUpdateData[tmpFields[i]] = tmpBody[tmpFields[i]];
366
+ }
367
+ }
368
+ if (tmpBody.hasOwnProperty('Verified'))
369
+ {
370
+ tmpUpdateData.Verified = tmpBody.Verified ? 1 : 0;
371
+ }
372
+
373
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
374
+ .addRecord(tmpUpdateData);
375
+
376
+ this.fable.DAL.SourceCatalogEntry.doUpdate(tmpQuery,
377
+ (pError, pQuery, pQueryRead, pRecord) =>
378
+ {
379
+ if (pError)
380
+ {
381
+ this.fable.log.error(`CatalogManager error updating entry ${tmpID}: ${pError}`);
382
+ pResponse.send({ Error: pError.message || pError });
383
+ return fNext();
384
+ }
385
+ pResponse.send({ Success: true, Entry: pRecord });
386
+ return fNext();
387
+ });
388
+ });
389
+
390
+ // DELETE /facto/catalog/entry/:IDSourceCatalogEntry -- soft-delete a catalog entry
391
+ pOratorServiceServer.doDel(`${tmpRoutePrefix}/catalog/entry/:IDSourceCatalogEntry`,
392
+ (pRequest, pResponse, fNext) =>
393
+ {
394
+ let tmpID = parseInt(pRequest.params.IDSourceCatalogEntry, 10);
395
+ if (isNaN(tmpID) || tmpID < 1)
396
+ {
397
+ pResponse.send({ Error: 'Invalid IDSourceCatalogEntry parameter' });
398
+ return fNext();
399
+ }
400
+
401
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry)
402
+ {
403
+ pResponse.send({ Error: 'SourceCatalogEntry DAL not initialized' });
404
+ return fNext();
405
+ }
406
+
407
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
408
+ .addFilter('IDSourceCatalogEntry', tmpID);
409
+
410
+ this.fable.DAL.SourceCatalogEntry.doDelete(tmpQuery,
411
+ (pError) =>
412
+ {
413
+ if (pError)
414
+ {
415
+ pResponse.send({ Error: pError.message || pError });
416
+ return fNext();
417
+ }
418
+ pResponse.send({ Success: true, Deleted: tmpID });
419
+ return fNext();
420
+ });
421
+ });
422
+
423
+ // ================================================================
424
+ // Catalog Dataset Definition CRUD
425
+ // ================================================================
426
+
427
+ // GET /facto/catalog/entry/:IDSourceCatalogEntry/datasets -- list dataset definitions
428
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/catalog/entry/:IDSourceCatalogEntry/datasets`,
429
+ (pRequest, pResponse, fNext) =>
430
+ {
431
+ let tmpID = parseInt(pRequest.params.IDSourceCatalogEntry, 10);
432
+ if (isNaN(tmpID) || tmpID < 1)
433
+ {
434
+ pResponse.send({ Error: 'Invalid IDSourceCatalogEntry parameter', Datasets: [] });
435
+ return fNext();
436
+ }
437
+
438
+ if (!this.fable.DAL || !this.fable.DAL.CatalogDatasetDefinition)
439
+ {
440
+ pResponse.send({ Error: 'CatalogDatasetDefinition DAL not initialized', Datasets: [] });
441
+ return fNext();
442
+ }
443
+
444
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
445
+ .addFilter('IDSourceCatalogEntry', tmpID)
446
+ .addFilter('Deleted', 0);
447
+
448
+ this.fable.DAL.CatalogDatasetDefinition.doReads(tmpQuery,
449
+ (pError, pQuery, pRecords) =>
450
+ {
451
+ if (pError)
452
+ {
453
+ pResponse.send({ Error: pError.message || pError, Datasets: [] });
454
+ return fNext();
455
+ }
456
+ pResponse.send({ IDSourceCatalogEntry: tmpID, Count: pRecords.length, Datasets: pRecords });
457
+ return fNext();
458
+ });
459
+ });
460
+
461
+ // POST /facto/catalog/entry/:IDSourceCatalogEntry/dataset -- add dataset definition
462
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/catalog/entry/:IDSourceCatalogEntry/dataset`,
463
+ (pRequest, pResponse, fNext) =>
464
+ {
465
+ let tmpID = parseInt(pRequest.params.IDSourceCatalogEntry, 10);
466
+ if (isNaN(tmpID) || tmpID < 1)
467
+ {
468
+ pResponse.send({ Error: 'Invalid IDSourceCatalogEntry parameter' });
469
+ return fNext();
470
+ }
471
+
472
+ if (!this.fable.DAL || !this.fable.DAL.CatalogDatasetDefinition)
473
+ {
474
+ pResponse.send({ Error: 'CatalogDatasetDefinition DAL not initialized' });
475
+ return fNext();
476
+ }
477
+
478
+ let tmpBody = pRequest.body || {};
479
+
480
+ let tmpDefData = {
481
+ IDSourceCatalogEntry: tmpID,
482
+ Name: tmpBody.Name || '',
483
+ Format: tmpBody.Format || '',
484
+ MimeType: tmpBody.MimeType || '',
485
+ EndpointURL: tmpBody.EndpointURL || '',
486
+ Description: tmpBody.Description || '',
487
+ ParseOptions: (typeof tmpBody.ParseOptions === 'object') ? JSON.stringify(tmpBody.ParseOptions) : (tmpBody.ParseOptions || ''),
488
+ AuthRequirements: (typeof tmpBody.AuthRequirements === 'object') ? JSON.stringify(tmpBody.AuthRequirements) : (tmpBody.AuthRequirements || ''),
489
+ VersionPolicy: tmpBody.VersionPolicy || 'Append',
490
+ Provisioned: 0,
491
+ IDSource: 0,
492
+ IDDataset: 0
493
+ };
494
+
495
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
496
+ .addRecord(tmpDefData);
497
+
498
+ this.fable.DAL.CatalogDatasetDefinition.doCreate(tmpQuery,
499
+ (pError, pQuery, pQueryRead, pRecord) =>
500
+ {
501
+ if (pError)
502
+ {
503
+ this.fable.log.error(`CatalogManager error creating dataset definition: ${pError}`);
504
+ pResponse.send({ Error: pError.message || pError });
505
+ return fNext();
506
+ }
507
+ pResponse.send({ Success: true, DatasetDefinition: pRecord });
508
+ return fNext();
509
+ });
510
+ });
511
+
512
+ // PUT /facto/catalog/dataset/:IDCatalogDatasetDefinition -- update a dataset definition
513
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/catalog/dataset/:IDCatalogDatasetDefinition`,
514
+ (pRequest, pResponse, fNext) =>
515
+ {
516
+ let tmpID = parseInt(pRequest.params.IDCatalogDatasetDefinition, 10);
517
+ if (isNaN(tmpID) || tmpID < 1)
518
+ {
519
+ pResponse.send({ Error: 'Invalid IDCatalogDatasetDefinition parameter' });
520
+ return fNext();
521
+ }
522
+
523
+ if (!this.fable.DAL || !this.fable.DAL.CatalogDatasetDefinition)
524
+ {
525
+ pResponse.send({ Error: 'CatalogDatasetDefinition DAL not initialized' });
526
+ return fNext();
527
+ }
528
+
529
+ let tmpBody = pRequest.body || {};
530
+ let tmpUpdateData = { IDCatalogDatasetDefinition: tmpID };
531
+
532
+ let tmpFields = ['Name', 'Format', 'MimeType', 'EndpointURL', 'Description', 'VersionPolicy'];
533
+ for (let i = 0; i < tmpFields.length; i++)
534
+ {
535
+ if (tmpBody.hasOwnProperty(tmpFields[i]))
536
+ {
537
+ tmpUpdateData[tmpFields[i]] = tmpBody[tmpFields[i]];
538
+ }
539
+ }
540
+
541
+ if (tmpBody.hasOwnProperty('ParseOptions'))
542
+ {
543
+ tmpUpdateData.ParseOptions = (typeof tmpBody.ParseOptions === 'object') ? JSON.stringify(tmpBody.ParseOptions) : tmpBody.ParseOptions;
544
+ }
545
+ if (tmpBody.hasOwnProperty('AuthRequirements'))
546
+ {
547
+ tmpUpdateData.AuthRequirements = (typeof tmpBody.AuthRequirements === 'object') ? JSON.stringify(tmpBody.AuthRequirements) : tmpBody.AuthRequirements;
548
+ }
549
+
550
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
551
+ .addRecord(tmpUpdateData);
552
+
553
+ this.fable.DAL.CatalogDatasetDefinition.doUpdate(tmpQuery,
554
+ (pError, pQuery, pQueryRead, pRecord) =>
555
+ {
556
+ if (pError)
557
+ {
558
+ pResponse.send({ Error: pError.message || pError });
559
+ return fNext();
560
+ }
561
+ pResponse.send({ Success: true, DatasetDefinition: pRecord });
562
+ return fNext();
563
+ });
564
+ });
565
+
566
+ // DELETE /facto/catalog/dataset/:IDCatalogDatasetDefinition -- soft-delete dataset definition
567
+ pOratorServiceServer.doDel(`${tmpRoutePrefix}/catalog/dataset/:IDCatalogDatasetDefinition`,
568
+ (pRequest, pResponse, fNext) =>
569
+ {
570
+ let tmpID = parseInt(pRequest.params.IDCatalogDatasetDefinition, 10);
571
+ if (isNaN(tmpID) || tmpID < 1)
572
+ {
573
+ pResponse.send({ Error: 'Invalid IDCatalogDatasetDefinition parameter' });
574
+ return fNext();
575
+ }
576
+
577
+ if (!this.fable.DAL || !this.fable.DAL.CatalogDatasetDefinition)
578
+ {
579
+ pResponse.send({ Error: 'CatalogDatasetDefinition DAL not initialized' });
580
+ return fNext();
581
+ }
582
+
583
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
584
+ .addFilter('IDCatalogDatasetDefinition', tmpID);
585
+
586
+ this.fable.DAL.CatalogDatasetDefinition.doDelete(tmpQuery,
587
+ (pError) =>
588
+ {
589
+ if (pError)
590
+ {
591
+ pResponse.send({ Error: pError.message || pError });
592
+ return fNext();
593
+ }
594
+ pResponse.send({ Success: true, Deleted: tmpID });
595
+ return fNext();
596
+ });
597
+ });
598
+
599
+ // ================================================================
600
+ // Search
601
+ // ================================================================
602
+
603
+ // GET /facto/catalog/search?q=term -- search catalog entries
604
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/catalog/search`,
605
+ (pRequest, pResponse, fNext) =>
606
+ {
607
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry)
608
+ {
609
+ pResponse.send({ Error: 'SourceCatalogEntry DAL not initialized', Entries: [] });
610
+ return fNext();
611
+ }
612
+
613
+ let tmpSearchTerm = '';
614
+ if (pRequest.query && pRequest.query.q)
615
+ {
616
+ tmpSearchTerm = pRequest.query.q.toLowerCase();
617
+ }
618
+ else if (pRequest.params && pRequest.params.q)
619
+ {
620
+ tmpSearchTerm = pRequest.params.q.toLowerCase();
621
+ }
622
+
623
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
624
+ .addFilter('Deleted', 0)
625
+ .setCap(500);
626
+
627
+ this.fable.DAL.SourceCatalogEntry.doReads(tmpQuery,
628
+ (pError, pQuery, pRecords) =>
629
+ {
630
+ if (pError)
631
+ {
632
+ pResponse.send({ Error: pError.message || pError, Entries: [] });
633
+ return fNext();
634
+ }
635
+
636
+ if (!tmpSearchTerm)
637
+ {
638
+ pResponse.send({ Query: '', Count: pRecords.length, Entries: pRecords });
639
+ return fNext();
640
+ }
641
+
642
+ // Client-side filter across Agency, Name, Category, Description
643
+ let tmpFiltered = pRecords.filter(
644
+ (pEntry) =>
645
+ {
646
+ let tmpSearchable = [
647
+ pEntry.Agency || '',
648
+ pEntry.Name || '',
649
+ pEntry.Category || '',
650
+ pEntry.Description || ''
651
+ ].join(' ').toLowerCase();
652
+ return tmpSearchable.indexOf(tmpSearchTerm) >= 0;
653
+ });
654
+
655
+ pResponse.send({ Query: tmpSearchTerm, Count: tmpFiltered.length, Entries: tmpFiltered });
656
+ return fNext();
657
+ });
658
+ });
659
+
660
+ // ================================================================
661
+ // Provision
662
+ // ================================================================
663
+
664
+ // POST /facto/catalog/dataset/:IDCatalogDatasetDefinition/provision
665
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/catalog/dataset/:IDCatalogDatasetDefinition/provision`,
666
+ (pRequest, pResponse, fNext) =>
667
+ {
668
+ let tmpID = parseInt(pRequest.params.IDCatalogDatasetDefinition, 10);
669
+ if (isNaN(tmpID) || tmpID < 1)
670
+ {
671
+ pResponse.send({ Error: 'Invalid IDCatalogDatasetDefinition parameter' });
672
+ return fNext();
673
+ }
674
+
675
+ if (!this.fable.DAL || !this.fable.DAL.CatalogDatasetDefinition || !this.fable.DAL.SourceCatalogEntry)
676
+ {
677
+ pResponse.send({ Error: 'Catalog DAL not initialized' });
678
+ return fNext();
679
+ }
680
+
681
+ let tmpAnticipate = this.fable.newAnticipate();
682
+ let tmpCatalogDef = null;
683
+ let tmpCatalogEntry = null;
684
+ let tmpSource = null;
685
+ let tmpDataset = null;
686
+ let tmpDatasetSource = null;
687
+
688
+ // Step 1: Load the catalog dataset definition
689
+ tmpAnticipate.anticipate(
690
+ (fStep) =>
691
+ {
692
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
693
+ .addFilter('IDCatalogDatasetDefinition', tmpID);
694
+
695
+ this.fable.DAL.CatalogDatasetDefinition.doRead(tmpQuery,
696
+ (pError, pQuery, pRecord) =>
697
+ {
698
+ if (pError || !pRecord)
699
+ {
700
+ return fStep(pError || new Error('CatalogDatasetDefinition not found'));
701
+ }
702
+ tmpCatalogDef = pRecord;
703
+ return fStep();
704
+ });
705
+ });
706
+
707
+ // Step 2: Load the parent catalog entry
708
+ tmpAnticipate.anticipate(
709
+ (fStep) =>
710
+ {
711
+ if (!tmpCatalogDef)
712
+ {
713
+ return fStep();
714
+ }
715
+
716
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
717
+ .addFilter('IDSourceCatalogEntry', tmpCatalogDef.IDSourceCatalogEntry);
718
+
719
+ this.fable.DAL.SourceCatalogEntry.doRead(tmpQuery,
720
+ (pError, pQuery, pRecord) =>
721
+ {
722
+ if (pError || !pRecord)
723
+ {
724
+ return fStep(pError || new Error('Parent SourceCatalogEntry not found'));
725
+ }
726
+ tmpCatalogEntry = pRecord;
727
+ return fStep();
728
+ });
729
+ });
730
+
731
+ // Step 3: Find-or-create runtime Source (by Agency name)
732
+ tmpAnticipate.anticipate(
733
+ (fStep) =>
734
+ {
735
+ if (!tmpCatalogEntry)
736
+ {
737
+ return fStep();
738
+ }
739
+
740
+ let tmpSourceName = tmpCatalogEntry.Agency || tmpCatalogEntry.Name;
741
+ this.findOrCreateSource(tmpSourceName,
742
+ {
743
+ Type: tmpCatalogEntry.Type || 'API',
744
+ URL: tmpCatalogEntry.URL || '',
745
+ Protocol: tmpCatalogEntry.Protocol || 'HTTPS',
746
+ Description: tmpCatalogEntry.Description || ''
747
+ },
748
+ (pError, pSource) =>
749
+ {
750
+ if (pError)
751
+ {
752
+ return fStep(pError);
753
+ }
754
+ tmpSource = pSource;
755
+ return fStep();
756
+ });
757
+ });
758
+
759
+ // Step 4: Find-or-create runtime Dataset
760
+ tmpAnticipate.anticipate(
761
+ (fStep) =>
762
+ {
763
+ if (!tmpCatalogDef)
764
+ {
765
+ return fStep();
766
+ }
767
+
768
+ this.findOrCreateDataset(tmpCatalogDef.Name,
769
+ {
770
+ Type: 'Raw',
771
+ Description: tmpCatalogDef.Description || '',
772
+ VersionPolicy: tmpCatalogDef.VersionPolicy || 'Append'
773
+ },
774
+ (pError, pDataset) =>
775
+ {
776
+ if (pError)
777
+ {
778
+ return fStep(pError);
779
+ }
780
+ tmpDataset = pDataset;
781
+ return fStep();
782
+ });
783
+ });
784
+
785
+ // Step 5: Ensure DatasetSource link + set VersionPolicy
786
+ tmpAnticipate.anticipate(
787
+ (fStep) =>
788
+ {
789
+ if (!tmpSource || !tmpDataset)
790
+ {
791
+ return fStep();
792
+ }
793
+
794
+ this.ensureDatasetSourceLink(tmpDataset.IDDataset, tmpSource.IDSource,
795
+ (pError, pLink) =>
796
+ {
797
+ if (pError)
798
+ {
799
+ return fStep(pError);
800
+ }
801
+ tmpDatasetSource = pLink;
802
+
803
+ // Update VersionPolicy on the dataset
804
+ let tmpPolicy = tmpCatalogDef.VersionPolicy || 'Append';
805
+ if (tmpPolicy === 'Append' || tmpPolicy === 'Replace')
806
+ {
807
+ let tmpUpdateQuery = this.fable.DAL.Dataset.query.clone()
808
+ .addRecord({ IDDataset: tmpDataset.IDDataset, VersionPolicy: tmpPolicy });
809
+
810
+ this.fable.DAL.Dataset.doUpdate(tmpUpdateQuery,
811
+ (pUpdateError) =>
812
+ {
813
+ return fStep();
814
+ });
815
+ }
816
+ else
817
+ {
818
+ return fStep();
819
+ }
820
+ });
821
+ });
822
+
823
+ // Step 6: Mark catalog definition as provisioned
824
+ tmpAnticipate.anticipate(
825
+ (fStep) =>
826
+ {
827
+ if (!tmpSource || !tmpDataset)
828
+ {
829
+ return fStep();
830
+ }
831
+
832
+ let tmpUpdateQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
833
+ .addRecord(
834
+ {
835
+ IDCatalogDatasetDefinition: tmpID,
836
+ Provisioned: 1,
837
+ IDSource: tmpSource.IDSource,
838
+ IDDataset: tmpDataset.IDDataset
839
+ });
840
+
841
+ this.fable.DAL.CatalogDatasetDefinition.doUpdate(tmpUpdateQuery,
842
+ (pError) =>
843
+ {
844
+ return fStep();
845
+ });
846
+ });
847
+
848
+ tmpAnticipate.wait(
849
+ (pError) =>
850
+ {
851
+ if (pError)
852
+ {
853
+ pResponse.send({ Error: pError.message || pError });
854
+ return fNext();
855
+ }
856
+
857
+ pResponse.send(
858
+ {
859
+ Success: true,
860
+ Source: tmpSource,
861
+ Dataset: tmpDataset,
862
+ DatasetSource: tmpDatasetSource,
863
+ CatalogDatasetDefinition: tmpCatalogDef
864
+ });
865
+ return fNext();
866
+ });
867
+ });
868
+
869
+ // ================================================================
870
+ // Import / Export
871
+ // ================================================================
872
+
873
+ // POST /facto/catalog/import -- bulk import catalog entries from JSON array
874
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/catalog/import`,
875
+ (pRequest, pResponse, fNext) =>
876
+ {
877
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry || !this.fable.DAL.CatalogDatasetDefinition)
878
+ {
879
+ pResponse.send({ Error: 'Catalog DAL not initialized' });
880
+ return fNext();
881
+ }
882
+
883
+ let tmpEntries = pRequest.body;
884
+ this.fable.log.info(`CatalogManager import: body type=${typeof tmpEntries}, isArray=${Array.isArray(tmpEntries)}`);
885
+ if (!Array.isArray(tmpEntries))
886
+ {
887
+ // Allow { Entries: [...] } wrapper
888
+ if (tmpEntries && Array.isArray(tmpEntries.Entries))
889
+ {
890
+ tmpEntries = tmpEntries.Entries;
891
+ }
892
+ else
893
+ {
894
+ pResponse.send({ Error: 'Request body must be a JSON array of catalog entries (received ' + (typeof tmpEntries) + ')' });
895
+ return fNext();
896
+ }
897
+ }
898
+
899
+ let tmpAnticipate = this.fable.newAnticipate();
900
+ let tmpCreatedEntries = 0;
901
+ let tmpCreatedDatasets = 0;
902
+ let tmpErrors = 0;
903
+
904
+ for (let i = 0; i < tmpEntries.length; i++)
905
+ {
906
+ let tmpEntryData = tmpEntries[i];
907
+
908
+ tmpAnticipate.anticipate(
909
+ (fStep) =>
910
+ {
911
+ let tmpRecord = {
912
+ Agency: tmpEntryData.Agency || '',
913
+ Name: tmpEntryData.Name || '',
914
+ Type: tmpEntryData.Type || '',
915
+ URL: tmpEntryData.URL || '',
916
+ Protocol: tmpEntryData.Protocol || '',
917
+ Category: tmpEntryData.Category || '',
918
+ Region: tmpEntryData.Region || '',
919
+ UpdateFrequency: tmpEntryData.UpdateFrequency || '',
920
+ Description: tmpEntryData.Description || '',
921
+ Notes: tmpEntryData.Notes || '',
922
+ Verified: tmpEntryData.Verified ? 1 : 0
923
+ };
924
+
925
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
926
+ .addRecord(tmpRecord);
927
+
928
+ this.fable.DAL.SourceCatalogEntry.doCreate(tmpQuery,
929
+ (pError, pQuery, pQueryRead, pCreatedEntry) =>
930
+ {
931
+ if (pError || !pCreatedEntry)
932
+ {
933
+ tmpErrors++;
934
+ return fStep();
935
+ }
936
+
937
+ tmpCreatedEntries++;
938
+
939
+ // Create dataset definitions if provided
940
+ let tmpDatasets = tmpEntryData.Datasets || [];
941
+ if (tmpDatasets.length === 0)
942
+ {
943
+ return fStep();
944
+ }
945
+
946
+ let tmpDatasetAnticipate = this.fable.newAnticipate();
947
+
948
+ for (let j = 0; j < tmpDatasets.length; j++)
949
+ {
950
+ let tmpDsData = tmpDatasets[j];
951
+
952
+ tmpDatasetAnticipate.anticipate(
953
+ (fDsStep) =>
954
+ {
955
+ let tmpDefRecord = {
956
+ IDSourceCatalogEntry: pCreatedEntry.IDSourceCatalogEntry,
957
+ Name: tmpDsData.Name || '',
958
+ Format: tmpDsData.Format || '',
959
+ MimeType: tmpDsData.MimeType || '',
960
+ EndpointURL: tmpDsData.EndpointURL || '',
961
+ Description: tmpDsData.Description || '',
962
+ ParseOptions: (typeof tmpDsData.ParseOptions === 'object') ? JSON.stringify(tmpDsData.ParseOptions) : (tmpDsData.ParseOptions || ''),
963
+ AuthRequirements: (typeof tmpDsData.AuthRequirements === 'object') ? JSON.stringify(tmpDsData.AuthRequirements) : (tmpDsData.AuthRequirements || ''),
964
+ VersionPolicy: tmpDsData.VersionPolicy || 'Append',
965
+ Provisioned: 0,
966
+ IDSource: 0,
967
+ IDDataset: 0
968
+ };
969
+
970
+ let tmpDefQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
971
+ .addRecord(tmpDefRecord);
972
+
973
+ this.fable.DAL.CatalogDatasetDefinition.doCreate(tmpDefQuery,
974
+ (pDefError) =>
975
+ {
976
+ if (!pDefError)
977
+ {
978
+ tmpCreatedDatasets++;
979
+ }
980
+ else
981
+ {
982
+ tmpErrors++;
983
+ }
984
+ return fDsStep();
985
+ });
986
+ });
987
+ }
988
+
989
+ tmpDatasetAnticipate.wait(
990
+ () =>
991
+ {
992
+ return fStep();
993
+ });
994
+ });
995
+ });
996
+ }
997
+
998
+ tmpAnticipate.wait(
999
+ (pError) =>
1000
+ {
1001
+ pResponse.send(
1002
+ {
1003
+ Success: true,
1004
+ EntriesCreated: tmpCreatedEntries,
1005
+ DatasetsCreated: tmpCreatedDatasets,
1006
+ Errors: tmpErrors
1007
+ });
1008
+ return fNext();
1009
+ });
1010
+ });
1011
+
1012
+ // GET /facto/catalog/export -- export full catalog as JSON
1013
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/catalog/export`,
1014
+ (pRequest, pResponse, fNext) =>
1015
+ {
1016
+ if (!this.fable.DAL || !this.fable.DAL.SourceCatalogEntry || !this.fable.DAL.CatalogDatasetDefinition)
1017
+ {
1018
+ pResponse.send({ Error: 'Catalog DAL not initialized' });
1019
+ return fNext();
1020
+ }
1021
+
1022
+ let tmpAnticipate = this.fable.newAnticipate();
1023
+ let tmpEntries = [];
1024
+ let tmpDatasets = [];
1025
+
1026
+ // Load all entries
1027
+ tmpAnticipate.anticipate(
1028
+ (fStep) =>
1029
+ {
1030
+ let tmpQuery = this.fable.DAL.SourceCatalogEntry.query.clone()
1031
+ .addFilter('Deleted', 0)
1032
+ .setCap(500);
1033
+
1034
+ this.fable.DAL.SourceCatalogEntry.doReads(tmpQuery,
1035
+ (pError, pQuery, pRecords) =>
1036
+ {
1037
+ if (!pError && pRecords)
1038
+ {
1039
+ tmpEntries = pRecords;
1040
+ }
1041
+ return fStep();
1042
+ });
1043
+ });
1044
+
1045
+ // Load all dataset definitions
1046
+ tmpAnticipate.anticipate(
1047
+ (fStep) =>
1048
+ {
1049
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
1050
+ .addFilter('Deleted', 0)
1051
+ .setCap(2000);
1052
+
1053
+ this.fable.DAL.CatalogDatasetDefinition.doReads(tmpQuery,
1054
+ (pError, pQuery, pRecords) =>
1055
+ {
1056
+ if (!pError && pRecords)
1057
+ {
1058
+ tmpDatasets = pRecords;
1059
+ }
1060
+ return fStep();
1061
+ });
1062
+ });
1063
+
1064
+ tmpAnticipate.wait(
1065
+ (pError) =>
1066
+ {
1067
+ // Group datasets by parent entry
1068
+ let tmpDatasetsByEntry = {};
1069
+ for (let i = 0; i < tmpDatasets.length; i++)
1070
+ {
1071
+ let tmpEntryID = tmpDatasets[i].IDSourceCatalogEntry;
1072
+ if (!tmpDatasetsByEntry[tmpEntryID])
1073
+ {
1074
+ tmpDatasetsByEntry[tmpEntryID] = [];
1075
+ }
1076
+ tmpDatasetsByEntry[tmpEntryID].push(
1077
+ {
1078
+ Name: tmpDatasets[i].Name,
1079
+ Format: tmpDatasets[i].Format,
1080
+ MimeType: tmpDatasets[i].MimeType,
1081
+ EndpointURL: tmpDatasets[i].EndpointURL,
1082
+ Description: tmpDatasets[i].Description,
1083
+ ParseOptions: tmpDatasets[i].ParseOptions,
1084
+ AuthRequirements: tmpDatasets[i].AuthRequirements,
1085
+ VersionPolicy: tmpDatasets[i].VersionPolicy,
1086
+ Provisioned: tmpDatasets[i].Provisioned,
1087
+ IDSource: tmpDatasets[i].IDSource,
1088
+ IDDataset: tmpDatasets[i].IDDataset
1089
+ });
1090
+ }
1091
+
1092
+ // Build export structure
1093
+ let tmpExport = [];
1094
+ for (let i = 0; i < tmpEntries.length; i++)
1095
+ {
1096
+ let tmpEntry = tmpEntries[i];
1097
+ tmpExport.push(
1098
+ {
1099
+ Agency: tmpEntry.Agency,
1100
+ Name: tmpEntry.Name,
1101
+ Type: tmpEntry.Type,
1102
+ URL: tmpEntry.URL,
1103
+ Protocol: tmpEntry.Protocol,
1104
+ Category: tmpEntry.Category,
1105
+ Region: tmpEntry.Region,
1106
+ UpdateFrequency: tmpEntry.UpdateFrequency,
1107
+ Description: tmpEntry.Description,
1108
+ Notes: tmpEntry.Notes,
1109
+ Verified: tmpEntry.Verified,
1110
+ Datasets: tmpDatasetsByEntry[tmpEntry.IDSourceCatalogEntry] || []
1111
+ });
1112
+ }
1113
+
1114
+ pResponse.send({ Count: tmpExport.length, Entries: tmpExport });
1115
+ return fNext();
1116
+ });
1117
+ });
1118
+
1119
+ // ================================================================
1120
+ // Fetch and Ingest from Catalog
1121
+ // ================================================================
1122
+
1123
+ // POST /facto/catalog/dataset/:IDCatalogDatasetDefinition/fetch
1124
+ // Downloads data from the catalog entry's EndpointURL, parses it
1125
+ // using the entry's Format and ParseOptions, and ingests it into
1126
+ // the provisioned Dataset/Source.
1127
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/catalog/dataset/:IDCatalogDatasetDefinition/fetch`,
1128
+ (pRequest, pResponse, fNext) =>
1129
+ {
1130
+ let tmpID = parseInt(pRequest.params.IDCatalogDatasetDefinition, 10);
1131
+ if (isNaN(tmpID) || tmpID < 1)
1132
+ {
1133
+ pResponse.send({ Error: 'Invalid IDCatalogDatasetDefinition parameter' });
1134
+ return fNext();
1135
+ }
1136
+
1137
+ if (!this.fable.DAL || !this.fable.DAL.CatalogDatasetDefinition)
1138
+ {
1139
+ pResponse.send({ Error: 'Catalog DAL not initialized' });
1140
+ return fNext();
1141
+ }
1142
+
1143
+ // Step 1: Load the catalog dataset definition
1144
+ let tmpQuery = this.fable.DAL.CatalogDatasetDefinition.query.clone()
1145
+ .addFilter('IDCatalogDatasetDefinition', tmpID);
1146
+
1147
+ this.fable.DAL.CatalogDatasetDefinition.doRead(tmpQuery,
1148
+ (pError, pQuery, pRecord) =>
1149
+ {
1150
+ if (pError || !pRecord)
1151
+ {
1152
+ pResponse.send({ Error: 'CatalogDatasetDefinition not found' });
1153
+ return fNext();
1154
+ }
1155
+
1156
+ // Step 2: Validate
1157
+ if (!pRecord.Provisioned)
1158
+ {
1159
+ pResponse.send({ Error: 'Dataset definition must be provisioned before fetching. Call provision first.' });
1160
+ return fNext();
1161
+ }
1162
+
1163
+ if (!pRecord.EndpointURL)
1164
+ {
1165
+ pResponse.send({ Error: 'No EndpointURL configured for this dataset definition' });
1166
+ return fNext();
1167
+ }
1168
+
1169
+ // Parse ParseOptions from JSON string
1170
+ let tmpParseOptions = {};
1171
+ if (pRecord.ParseOptions)
1172
+ {
1173
+ try
1174
+ {
1175
+ tmpParseOptions = JSON.parse(pRecord.ParseOptions);
1176
+ }
1177
+ catch (pParseError)
1178
+ {
1179
+ this.fable.log.warn(`CatalogManager: could not parse ParseOptions for definition ${tmpID}: ${pParseError.message}`);
1180
+ }
1181
+ }
1182
+
1183
+ // Step 3: Download
1184
+ this.fable.log.info(`CatalogManager: fetching ${pRecord.EndpointURL} for definition ${tmpID}`);
1185
+
1186
+ this.fable.RetoldFactoIngestEngine.downloadURL(pRecord.EndpointURL,
1187
+ (pDownloadError, pBuffer) =>
1188
+ {
1189
+ if (pDownloadError)
1190
+ {
1191
+ pResponse.send({ Error: 'Download failed: ' + pDownloadError.message });
1192
+ return fNext();
1193
+ }
1194
+
1195
+ this.fable.log.info(`CatalogManager: downloaded ${pBuffer.length} bytes, ingesting as ${pRecord.Format || 'auto'}`);
1196
+
1197
+ // Step 4: Build ingest options from catalog metadata and ingest
1198
+ let tmpFormat = (pRecord.Format || '').toLowerCase();
1199
+ let tmpIngestOptions = {
1200
+ format: tmpFormat,
1201
+ type: 'catalog-fetch',
1202
+ delimiter: tmpParseOptions.delimiter || ',',
1203
+ stripCommentLines: tmpParseOptions.stripCommentLines || false,
1204
+ dataPath: tmpParseOptions.dataPath || '',
1205
+ recordPath: tmpParseOptions.recordPath || '',
1206
+ columns: tmpParseOptions.columns || null
1207
+ };
1208
+
1209
+ let tmpContent = (tmpFormat === 'excel') ? pBuffer : pBuffer.toString('utf8');
1210
+
1211
+ this.fable.RetoldFactoIngestEngine.ingestContent(
1212
+ tmpContent,
1213
+ pRecord.IDDataset,
1214
+ pRecord.IDSource,
1215
+ tmpIngestOptions,
1216
+ (pIngestError, pResult) =>
1217
+ {
1218
+ if (pIngestError)
1219
+ {
1220
+ pResponse.send({ Error: 'Ingest failed: ' + pIngestError.message });
1221
+ return fNext();
1222
+ }
1223
+
1224
+ pResponse.send(
1225
+ {
1226
+ Success: true,
1227
+ IDCatalogDatasetDefinition: tmpID,
1228
+ IDDataset: pRecord.IDDataset,
1229
+ IDSource: pRecord.IDSource,
1230
+ EndpointURL: pRecord.EndpointURL,
1231
+ Ingested: pResult.Ingested,
1232
+ Errors: pResult.Errors,
1233
+ Total: pResult.Total,
1234
+ DatasetVersion: pResult.DatasetVersion,
1235
+ ContentSignature: pResult.ContentSignature,
1236
+ IsDuplicate: pResult.IsDuplicate,
1237
+ Format: pResult.Format,
1238
+ IngestJob: pResult.IngestJob
1239
+ });
1240
+ return fNext();
1241
+ });
1242
+ });
1243
+ });
1244
+ });
1245
+
1246
+ this.fable.log.info(`CatalogManager routes connected at ${tmpRoutePrefix}/catalog/*`);
1247
+ }
1248
+ }
1249
+
1250
+ module.exports = RetoldFactoCatalogManager;
1251
+ module.exports.serviceType = 'RetoldFactoCatalogManager';
1252
+ module.exports.default_configuration = defaultCatalogManagerOptions;