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,1110 @@
1
+ /**
2
+ * Retold Facto - Schema Manager Service
3
+ *
4
+ * Manages schema definitions, versioning, documentation, and dataset linking.
5
+ * Provides endpoints for schema CRUD beyond the auto-generated Meadow endpoints,
6
+ * including documentation management, versioning, and MicroDDL compilation.
7
+ *
8
+ * @author Steven Velozo <steven@velozo.com>
9
+ */
10
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
11
+
12
+ const defaultSchemaManagerOptions = (
13
+ {
14
+ RoutePrefix: '/facto'
15
+ });
16
+
17
+ class RetoldFactoSchemaManager extends libFableServiceProviderBase
18
+ {
19
+ constructor(pFable, pOptions, pServiceHash)
20
+ {
21
+ let tmpOptions = Object.assign({}, defaultSchemaManagerOptions, pOptions);
22
+ super(pFable, tmpOptions, pServiceHash);
23
+
24
+ this.serviceType = 'RetoldFactoSchemaManager';
25
+ }
26
+
27
+ /**
28
+ * Compute a simple hash of a DDL/schema definition string.
29
+ *
30
+ * Uses bitwise shift operations to produce a deterministic hash,
31
+ * prefixed with 'ddl-' and rendered as a hex string.
32
+ *
33
+ * @param {string} pText - The text to hash
34
+ * @returns {string} The computed hash string (e.g. 'ddl-1a2b3c')
35
+ */
36
+ _computeSchemaHash(pText)
37
+ {
38
+ let tmpHash = 0;
39
+ for (let i = 0; i < pText.length; i++)
40
+ {
41
+ tmpHash = ((tmpHash << 5) - tmpHash + pText.charCodeAt(i)) | 0;
42
+ }
43
+ return 'ddl-' + Math.abs(tmpHash).toString(16);
44
+ }
45
+
46
+ /**
47
+ * Parse a MicroDDL text into a structured schema object.
48
+ *
49
+ * Symbol map:
50
+ * @ = ID (AutoIdentity)
51
+ * % = GUID (AutoGUID)
52
+ * $ = String
53
+ * * = Text (large string)
54
+ * # = Numeric
55
+ * . = Decimal
56
+ * & = DateTime
57
+ * ^ = Boolean / Deleted
58
+ *
59
+ * @param {string} pDDL - The MicroDDL text
60
+ * @returns {object} Parsed schema with Tables map
61
+ */
62
+ _parseMicroDDL(pDDL)
63
+ {
64
+ let tmpLines = pDDL.split('\n');
65
+ let tmpTables = {};
66
+ let tmpCurrentTable = null;
67
+
68
+ let tmpSymbolMap =
69
+ {
70
+ '@': { DataType: 'ID', MeadowType: 'AutoIdentity' },
71
+ '%': { DataType: 'GUID', MeadowType: 'AutoGUID' },
72
+ '$': { DataType: 'String', MeadowType: 'String' },
73
+ '*': { DataType: 'Text', MeadowType: 'String' },
74
+ '#': { DataType: 'Numeric', MeadowType: 'Numeric' },
75
+ '.': { DataType: 'Decimal', MeadowType: 'Numeric' },
76
+ '&': { DataType: 'DateTime', MeadowType: 'String' },
77
+ '^': { DataType: 'Boolean', MeadowType: 'Deleted' }
78
+ };
79
+
80
+ for (let i = 0; i < tmpLines.length; i++)
81
+ {
82
+ let tmpLine = tmpLines[i].trim();
83
+
84
+ // Skip blank lines and comments
85
+ if (!tmpLine || tmpLine.startsWith('//') || tmpLine.startsWith('--'))
86
+ {
87
+ continue;
88
+ }
89
+
90
+ // Skip join references
91
+ if (tmpLine.startsWith('->'))
92
+ {
93
+ continue;
94
+ }
95
+
96
+ // Table declaration
97
+ if (tmpLine.startsWith('!'))
98
+ {
99
+ let tmpTableName = tmpLine.substring(1).trim();
100
+ tmpCurrentTable =
101
+ {
102
+ TableName: tmpTableName,
103
+ Columns: []
104
+ };
105
+ tmpTables[tmpTableName] = tmpCurrentTable;
106
+ continue;
107
+ }
108
+
109
+ // Column definition
110
+ let tmpSymbol = tmpLine.charAt(0);
111
+ if (tmpSymbolMap.hasOwnProperty(tmpSymbol) && tmpCurrentTable)
112
+ {
113
+ let tmpRest = tmpLine.substring(1).trim();
114
+ let tmpParts = tmpRest.split(/\s+/);
115
+ let tmpColumnName = tmpParts[0] || '';
116
+ let tmpSize = tmpParts[1] || 'Default';
117
+
118
+ tmpCurrentTable.Columns.push(
119
+ {
120
+ Column: tmpColumnName,
121
+ DataType: tmpSymbolMap[tmpSymbol].DataType,
122
+ Size: tmpSize
123
+ });
124
+ }
125
+ }
126
+
127
+ return { Tables: tmpTables };
128
+ }
129
+
130
+ /**
131
+ * Connect REST API routes for schema management.
132
+ *
133
+ * @param {object} pOratorServiceServer - The Orator service server instance
134
+ */
135
+ connectRoutes(pOratorServiceServer)
136
+ {
137
+ let tmpRoutePrefix = this.options.RoutePrefix;
138
+
139
+ // GET /facto/schemas/active -- list all active (non-deleted) schemas
140
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/schemas/active`,
141
+ (pRequest, pResponse, fNext) =>
142
+ {
143
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchema)
144
+ {
145
+ pResponse.send({ Error: 'Schema DAL not initialized', Schemas: [] });
146
+ return fNext();
147
+ }
148
+
149
+ let tmpQuery = this.fable.DAL.FactoSchema.query.clone()
150
+ .addFilter('Deleted', 0)
151
+ .addFilter('Active', 1)
152
+ .setCap(500);
153
+
154
+ this.fable.DAL.FactoSchema.doReads(tmpQuery,
155
+ (pError, pQuery, pRecords) =>
156
+ {
157
+ if (pError)
158
+ {
159
+ this.fable.log.error(`SchemaManager error listing active schemas: ${pError}`);
160
+ pResponse.send({ Error: pError.message || pError, Schemas: [] });
161
+ return fNext();
162
+ }
163
+ pResponse.send({ Active: true, Count: pRecords.length, Schemas: pRecords });
164
+ return fNext();
165
+ });
166
+ });
167
+
168
+ // GET /facto/schema/:IDSchema/summary -- get a schema with its stats
169
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/schema/:IDSchema/summary`,
170
+ (pRequest, pResponse, fNext) =>
171
+ {
172
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
173
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
174
+ {
175
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
176
+ return fNext();
177
+ }
178
+
179
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchema)
180
+ {
181
+ pResponse.send({ Error: 'Schema DAL not initialized' });
182
+ return fNext();
183
+ }
184
+
185
+ let tmpAnticipate = this.fable.newAnticipate();
186
+ let tmpResult = { IDSchema: tmpIDSchema, Schema: false, DocumentationCount: 0, VersionCount: 0, DatasetCount: 0 };
187
+
188
+ // Load the schema record
189
+ tmpAnticipate.anticipate(
190
+ (fStep) =>
191
+ {
192
+ let tmpQuery = this.fable.DAL.FactoSchema.query.clone()
193
+ .addFilter('IDSchema', tmpIDSchema);
194
+ this.fable.DAL.FactoSchema.doRead(tmpQuery,
195
+ (pError, pQuery, pRecord) =>
196
+ {
197
+ if (!pError && pRecord)
198
+ {
199
+ tmpResult.Schema = pRecord;
200
+ }
201
+ return fStep();
202
+ });
203
+ });
204
+
205
+ // Count documentation entries
206
+ tmpAnticipate.anticipate(
207
+ (fStep) =>
208
+ {
209
+ if (!this.fable.DAL.FactoSchemaDocumentation)
210
+ {
211
+ return fStep();
212
+ }
213
+ let tmpQuery = this.fable.DAL.FactoSchemaDocumentation.query.clone()
214
+ .addFilter('IDSchema', tmpIDSchema)
215
+ .addFilter('Deleted', 0);
216
+ this.fable.DAL.FactoSchemaDocumentation.doCount(tmpQuery,
217
+ (pError, pQuery, pCount) =>
218
+ {
219
+ if (!pError)
220
+ {
221
+ tmpResult.DocumentationCount = pCount;
222
+ }
223
+ return fStep();
224
+ });
225
+ });
226
+
227
+ // Count versions
228
+ tmpAnticipate.anticipate(
229
+ (fStep) =>
230
+ {
231
+ if (!this.fable.DAL.FactoSchemaVersion)
232
+ {
233
+ return fStep();
234
+ }
235
+ let tmpQuery = this.fable.DAL.FactoSchemaVersion.query.clone()
236
+ .addFilter('IDSchema', tmpIDSchema)
237
+ .addFilter('Deleted', 0);
238
+ this.fable.DAL.FactoSchemaVersion.doCount(tmpQuery,
239
+ (pError, pQuery, pCount) =>
240
+ {
241
+ if (!pError)
242
+ {
243
+ tmpResult.VersionCount = pCount;
244
+ }
245
+ return fStep();
246
+ });
247
+ });
248
+
249
+ // Count datasets linked to this schema
250
+ tmpAnticipate.anticipate(
251
+ (fStep) =>
252
+ {
253
+ if (!this.fable.DAL.Dataset)
254
+ {
255
+ return fStep();
256
+ }
257
+ let tmpQuery = this.fable.DAL.Dataset.query.clone()
258
+ .addFilter('IDSchema', tmpIDSchema)
259
+ .addFilter('Deleted', 0);
260
+ this.fable.DAL.Dataset.doCount(tmpQuery,
261
+ (pError, pQuery, pCount) =>
262
+ {
263
+ if (!pError)
264
+ {
265
+ tmpResult.DatasetCount = pCount;
266
+ }
267
+ return fStep();
268
+ });
269
+ });
270
+
271
+ tmpAnticipate.wait(
272
+ (pError) =>
273
+ {
274
+ if (pError)
275
+ {
276
+ pResponse.send({ Error: pError.message || pError });
277
+ return fNext();
278
+ }
279
+ pResponse.send(tmpResult);
280
+ return fNext();
281
+ });
282
+ });
283
+
284
+ // PUT /facto/schema/:IDSchema/activate -- mark a schema as active
285
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/schema/:IDSchema/activate`,
286
+ (pRequest, pResponse, fNext) =>
287
+ {
288
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
289
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
290
+ {
291
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
292
+ return fNext();
293
+ }
294
+
295
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchema)
296
+ {
297
+ pResponse.send({ Error: 'Schema DAL not initialized' });
298
+ return fNext();
299
+ }
300
+
301
+ let tmpQuery = this.fable.DAL.FactoSchema.query.clone()
302
+ .addRecord({ IDSchema: tmpIDSchema, Active: 1 });
303
+
304
+ this.fable.DAL.FactoSchema.doUpdate(tmpQuery,
305
+ (pError, pQuery, pQueryRead, pRecord) =>
306
+ {
307
+ if (pError)
308
+ {
309
+ this.fable.log.error(`SchemaManager error activating schema ${tmpIDSchema}: ${pError}`);
310
+ pResponse.send({ Error: pError.message || pError });
311
+ return fNext();
312
+ }
313
+ pResponse.send({ Success: true, Schema: pRecord });
314
+ return fNext();
315
+ });
316
+ });
317
+
318
+ // PUT /facto/schema/:IDSchema/deactivate -- mark a schema as inactive
319
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/schema/:IDSchema/deactivate`,
320
+ (pRequest, pResponse, fNext) =>
321
+ {
322
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
323
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
324
+ {
325
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
326
+ return fNext();
327
+ }
328
+
329
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchema)
330
+ {
331
+ pResponse.send({ Error: 'Schema DAL not initialized' });
332
+ return fNext();
333
+ }
334
+
335
+ let tmpQuery = this.fable.DAL.FactoSchema.query.clone()
336
+ .addRecord({ IDSchema: tmpIDSchema, Active: 0 });
337
+
338
+ this.fable.DAL.FactoSchema.doUpdate(tmpQuery,
339
+ (pError, pQuery, pQueryRead, pRecord) =>
340
+ {
341
+ if (pError)
342
+ {
343
+ this.fable.log.error(`SchemaManager error deactivating schema ${tmpIDSchema}: ${pError}`);
344
+ pResponse.send({ Error: pError.message || pError });
345
+ return fNext();
346
+ }
347
+ pResponse.send({ Success: true, Schema: pRecord });
348
+ return fNext();
349
+ });
350
+ });
351
+
352
+ // GET /facto/schema/:IDSchema/documentation -- list documentation for a schema
353
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/schema/:IDSchema/documentation`,
354
+ (pRequest, pResponse, fNext) =>
355
+ {
356
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
357
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
358
+ {
359
+ pResponse.send({ Error: 'Invalid IDSchema parameter', Documentation: [] });
360
+ return fNext();
361
+ }
362
+
363
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchemaDocumentation)
364
+ {
365
+ pResponse.send({ Error: 'SchemaDocumentation DAL not initialized', Documentation: [] });
366
+ return fNext();
367
+ }
368
+
369
+ let tmpQuery = this.fable.DAL.FactoSchemaDocumentation.query.clone()
370
+ .addFilter('IDSchema', tmpIDSchema)
371
+ .addFilter('Deleted', 0);
372
+
373
+ this.fable.DAL.FactoSchemaDocumentation.doReads(tmpQuery,
374
+ (pError, pQuery, pRecords) =>
375
+ {
376
+ if (pError)
377
+ {
378
+ this.fable.log.error(`SchemaManager error listing documentation for schema ${tmpIDSchema}: ${pError}`);
379
+ pResponse.send({ Error: pError.message || pError, Documentation: [] });
380
+ return fNext();
381
+ }
382
+ pResponse.send({ IDSchema: tmpIDSchema, Count: pRecords.length, Documentation: pRecords });
383
+ return fNext();
384
+ });
385
+ });
386
+
387
+ // POST /facto/schema/:IDSchema/documentation -- create a documentation record
388
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/schema/:IDSchema/documentation`,
389
+ (pRequest, pResponse, fNext) =>
390
+ {
391
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
392
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
393
+ {
394
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
395
+ return fNext();
396
+ }
397
+
398
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchemaDocumentation)
399
+ {
400
+ pResponse.send({ Error: 'SchemaDocumentation DAL not initialized' });
401
+ return fNext();
402
+ }
403
+
404
+ let tmpBody = pRequest.body || {};
405
+ let tmpRecord =
406
+ {
407
+ IDSchema: tmpIDSchema,
408
+ Name: tmpBody.Name || 'Untitled',
409
+ DocumentType: tmpBody.DocumentType || 'markdown',
410
+ MimeType: tmpBody.MimeType || 'text/markdown',
411
+ Content: tmpBody.Content || '',
412
+ Description: tmpBody.Description || ''
413
+ };
414
+
415
+ let tmpQuery = this.fable.DAL.FactoSchemaDocumentation.query.clone()
416
+ .addRecord(tmpRecord);
417
+
418
+ this.fable.DAL.FactoSchemaDocumentation.doCreate(tmpQuery,
419
+ (pError, pQuery, pQueryRead, pCreatedRecord) =>
420
+ {
421
+ if (pError)
422
+ {
423
+ this.fable.log.error(`SchemaManager error creating documentation for schema ${tmpIDSchema}: ${pError}`);
424
+ pResponse.send({ Error: pError.message || pError });
425
+ return fNext();
426
+ }
427
+ pResponse.send({ Success: true, Documentation: pCreatedRecord });
428
+ return fNext();
429
+ });
430
+ });
431
+
432
+ // GET /facto/schema/:IDSchema/documentation/:IDSchemaDocumentation -- read a single doc
433
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/schema/:IDSchema/documentation/:IDSchemaDocumentation`,
434
+ (pRequest, pResponse, fNext) =>
435
+ {
436
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
437
+ let tmpIDDoc = parseInt(pRequest.params.IDSchemaDocumentation, 10);
438
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1 || isNaN(tmpIDDoc) || tmpIDDoc < 1)
439
+ {
440
+ pResponse.send({ Error: 'Invalid IDSchema or IDSchemaDocumentation parameter' });
441
+ return fNext();
442
+ }
443
+
444
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchemaDocumentation)
445
+ {
446
+ pResponse.send({ Error: 'SchemaDocumentation DAL not initialized' });
447
+ return fNext();
448
+ }
449
+
450
+ let tmpQuery = this.fable.DAL.FactoSchemaDocumentation.query.clone()
451
+ .addFilter('IDSchemaDocumentation', tmpIDDoc)
452
+ .addFilter('IDSchema', tmpIDSchema)
453
+ .addFilter('Deleted', 0);
454
+
455
+ this.fable.DAL.FactoSchemaDocumentation.doRead(tmpQuery,
456
+ (pError, pQuery, pRecord) =>
457
+ {
458
+ if (pError)
459
+ {
460
+ pResponse.send({ Error: pError.message || pError });
461
+ return fNext();
462
+ }
463
+ if (!pRecord)
464
+ {
465
+ pResponse.send({ Error: `Documentation ${tmpIDDoc} not found for schema ${tmpIDSchema}` });
466
+ return fNext();
467
+ }
468
+ pResponse.send({ Documentation: pRecord });
469
+ return fNext();
470
+ });
471
+ });
472
+
473
+ // PUT /facto/schema/:IDSchema/documentation/:IDSchemaDocumentation -- update a doc
474
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/schema/:IDSchema/documentation/:IDSchemaDocumentation`,
475
+ (pRequest, pResponse, fNext) =>
476
+ {
477
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
478
+ let tmpIDDoc = parseInt(pRequest.params.IDSchemaDocumentation, 10);
479
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1 || isNaN(tmpIDDoc) || tmpIDDoc < 1)
480
+ {
481
+ pResponse.send({ Error: 'Invalid IDSchema or IDSchemaDocumentation parameter' });
482
+ return fNext();
483
+ }
484
+
485
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchemaDocumentation)
486
+ {
487
+ pResponse.send({ Error: 'SchemaDocumentation DAL not initialized' });
488
+ return fNext();
489
+ }
490
+
491
+ let tmpBody = pRequest.body || {};
492
+ let tmpUpdateRecord = { IDSchemaDocumentation: tmpIDDoc };
493
+ if (tmpBody.hasOwnProperty('Name')) tmpUpdateRecord.Name = tmpBody.Name;
494
+ if (tmpBody.hasOwnProperty('Content')) tmpUpdateRecord.Content = tmpBody.Content;
495
+ if (tmpBody.hasOwnProperty('Description')) tmpUpdateRecord.Description = tmpBody.Description;
496
+
497
+ let tmpQuery = this.fable.DAL.FactoSchemaDocumentation.query.clone()
498
+ .addRecord(tmpUpdateRecord);
499
+
500
+ this.fable.DAL.FactoSchemaDocumentation.doUpdate(tmpQuery,
501
+ (pError, pQuery, pQueryRead, pUpdatedRecord) =>
502
+ {
503
+ if (pError)
504
+ {
505
+ this.fable.log.error(`SchemaManager error updating documentation ${tmpIDDoc}: ${pError}`);
506
+ pResponse.send({ Error: pError.message || pError });
507
+ return fNext();
508
+ }
509
+ pResponse.send({ Success: true, Documentation: pUpdatedRecord });
510
+ return fNext();
511
+ });
512
+ });
513
+
514
+ // DELETE /facto/schema/:IDSchema/documentation/:IDSchemaDocumentation -- soft-delete a doc
515
+ pOratorServiceServer.doDel(`${tmpRoutePrefix}/schema/:IDSchema/documentation/:IDSchemaDocumentation`,
516
+ (pRequest, pResponse, fNext) =>
517
+ {
518
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
519
+ let tmpIDDoc = parseInt(pRequest.params.IDSchemaDocumentation, 10);
520
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1 || isNaN(tmpIDDoc) || tmpIDDoc < 1)
521
+ {
522
+ pResponse.send({ Error: 'Invalid IDSchema or IDSchemaDocumentation parameter' });
523
+ return fNext();
524
+ }
525
+
526
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchemaDocumentation)
527
+ {
528
+ pResponse.send({ Error: 'SchemaDocumentation DAL not initialized' });
529
+ return fNext();
530
+ }
531
+
532
+ let tmpQuery = this.fable.DAL.FactoSchemaDocumentation.query.clone()
533
+ .addRecord({ IDSchemaDocumentation: tmpIDDoc, Deleted: 1, DeleteDate: new Date().toISOString() });
534
+
535
+ this.fable.DAL.FactoSchemaDocumentation.doUpdate(tmpQuery,
536
+ (pError, pQuery, pQueryRead, pRecord) =>
537
+ {
538
+ if (pError)
539
+ {
540
+ this.fable.log.error(`SchemaManager error deleting documentation ${tmpIDDoc}: ${pError}`);
541
+ pResponse.send({ Error: pError.message || pError });
542
+ return fNext();
543
+ }
544
+ pResponse.send({ Success: true, Deleted: tmpIDDoc });
545
+ return fNext();
546
+ });
547
+ });
548
+
549
+ // POST /facto/schema/:IDSchema/documentation/upload -- upload a file (image, pdf, etc.)
550
+ // Body: { Filename, ContentType, Data } where Data is base64-encoded file content
551
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/schema/:IDSchema/documentation/upload`,
552
+ (pRequest, pResponse, fNext) =>
553
+ {
554
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
555
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
556
+ {
557
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
558
+ return fNext();
559
+ }
560
+
561
+ let tmpFilename = pRequest.body && pRequest.body.Filename;
562
+ let tmpData = pRequest.body && pRequest.body.Data;
563
+ if (!tmpFilename || !tmpData)
564
+ {
565
+ pResponse.send({ Error: 'Filename and Data (base64) are required' });
566
+ return fNext();
567
+ }
568
+
569
+ let tmpPath = require('path');
570
+ let tmpFS = require('fs');
571
+
572
+ // Sanitize filename: keep only alphanumeric, hyphens, underscores, dots
573
+ let tmpSafeFilename = tmpFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
574
+ // Prefix with timestamp to avoid collisions
575
+ let tmpTimestamp = Date.now();
576
+ let tmpFinalFilename = tmpTimestamp + '-' + tmpSafeFilename;
577
+
578
+ // Build target directory: data/schema-docs/{IDSchema}/
579
+ let tmpDataDir = this.fable.settings.DataDirectory || tmpPath.join(process.cwd(), 'data');
580
+ let tmpSchemaDocsDir = tmpPath.join(tmpDataDir, 'schema-docs', String(tmpIDSchema));
581
+
582
+ // Ensure directory exists
583
+ tmpFS.mkdirSync(tmpSchemaDocsDir, { recursive: true });
584
+
585
+ // Decode base64 and write
586
+ let tmpBuffer = Buffer.from(tmpData, 'base64');
587
+ let tmpFilePath = tmpPath.join(tmpSchemaDocsDir, tmpFinalFilename);
588
+
589
+ tmpFS.writeFile(tmpFilePath, tmpBuffer,
590
+ (pError) =>
591
+ {
592
+ if (pError)
593
+ {
594
+ this.fable.log.error(`SchemaManager error writing file ${tmpFilePath}: ${pError}`);
595
+ pResponse.send({ Error: 'Failed to write file: ' + pError.message });
596
+ return fNext();
597
+ }
598
+
599
+ let tmpURL = `${tmpRoutePrefix}/schema/${tmpIDSchema}/documentation/files/${tmpFinalFilename}`;
600
+ pResponse.send({ Success: true, URL: tmpURL, Filename: tmpFinalFilename });
601
+ return fNext();
602
+ });
603
+ });
604
+
605
+ // GET /facto/schema/:IDSchema/documentation/files/:Filename -- serve an uploaded file
606
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/schema/:IDSchema/documentation/files/:Filename`,
607
+ (pRequest, pResponse, fNext) =>
608
+ {
609
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
610
+ let tmpFilename = pRequest.params.Filename;
611
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1 || !tmpFilename)
612
+ {
613
+ pResponse.send(404, { Error: 'Not found' });
614
+ return fNext();
615
+ }
616
+
617
+ let tmpPath = require('path');
618
+ let tmpFS = require('fs');
619
+
620
+ // Sanitize to prevent path traversal
621
+ let tmpSafeFilename = tmpPath.basename(tmpFilename);
622
+ let tmpDataDir = this.fable.settings.DataDirectory || tmpPath.join(process.cwd(), 'data');
623
+ let tmpFilePath = tmpPath.join(tmpDataDir, 'schema-docs', String(tmpIDSchema), tmpSafeFilename);
624
+
625
+ if (!tmpFS.existsSync(tmpFilePath))
626
+ {
627
+ pResponse.send(404, { Error: 'File not found' });
628
+ return fNext();
629
+ }
630
+
631
+ // Determine content type from extension
632
+ let tmpExt = tmpPath.extname(tmpSafeFilename).toLowerCase();
633
+ let tmpContentTypes =
634
+ {
635
+ '.png': 'image/png',
636
+ '.jpg': 'image/jpeg',
637
+ '.jpeg': 'image/jpeg',
638
+ '.gif': 'image/gif',
639
+ '.webp': 'image/webp',
640
+ '.svg': 'image/svg+xml',
641
+ '.pdf': 'application/pdf',
642
+ '.md': 'text/markdown',
643
+ '.txt': 'text/plain'
644
+ };
645
+ let tmpContentType = tmpContentTypes[tmpExt] || 'application/octet-stream';
646
+
647
+ let tmpFileData = tmpFS.readFileSync(tmpFilePath);
648
+ pResponse.setHeader('Content-Type', tmpContentType);
649
+ pResponse.setHeader('Content-Length', tmpFileData.length);
650
+ pResponse.writeHead(200);
651
+ pResponse.write(tmpFileData);
652
+ pResponse.end();
653
+ return fNext();
654
+ });
655
+
656
+ // GET /facto/schema/:IDSchema/versions -- list schema versions
657
+ pOratorServiceServer.doGet(`${tmpRoutePrefix}/schema/:IDSchema/versions`,
658
+ (pRequest, pResponse, fNext) =>
659
+ {
660
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
661
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
662
+ {
663
+ pResponse.send({ Error: 'Invalid IDSchema parameter', Versions: [] });
664
+ return fNext();
665
+ }
666
+
667
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchemaVersion)
668
+ {
669
+ pResponse.send({ Error: 'SchemaVersion DAL not initialized', Versions: [] });
670
+ return fNext();
671
+ }
672
+
673
+ let tmpQuery = this.fable.DAL.FactoSchemaVersion.query.clone()
674
+ .addFilter('IDSchema', tmpIDSchema)
675
+ .addFilter('Deleted', 0)
676
+ .addSort({ Column: 'Version', Direction: 'Descending' });
677
+
678
+ this.fable.DAL.FactoSchemaVersion.doReads(tmpQuery,
679
+ (pError, pQuery, pRecords) =>
680
+ {
681
+ if (pError)
682
+ {
683
+ this.fable.log.error(`SchemaManager error listing versions for schema ${tmpIDSchema}: ${pError}`);
684
+ pResponse.send({ Error: pError.message || pError, Versions: [] });
685
+ return fNext();
686
+ }
687
+ pResponse.send({ IDSchema: tmpIDSchema, Count: pRecords.length, Versions: pRecords });
688
+ return fNext();
689
+ });
690
+ });
691
+
692
+ // POST /facto/schema/:IDSchema/save -- save schema definition with versioning
693
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/schema/:IDSchema/save`,
694
+ (pRequest, pResponse, fNext) =>
695
+ {
696
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
697
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
698
+ {
699
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
700
+ return fNext();
701
+ }
702
+
703
+ if (!this.fable.DAL || !this.fable.DAL.FactoSchema)
704
+ {
705
+ pResponse.send({ Error: 'Schema DAL not initialized' });
706
+ return fNext();
707
+ }
708
+
709
+ let tmpBody = pRequest.body || {};
710
+ let tmpSchemaDefinition = (typeof tmpBody.SchemaDefinition === 'string') ? tmpBody.SchemaDefinition : '';
711
+ let tmpManyfestDefinition = (typeof tmpBody.ManyfestDefinition === 'string') ? tmpBody.ManyfestDefinition : '';
712
+
713
+ // Load the current schema record
714
+ let tmpQuery = this.fable.DAL.FactoSchema.query.clone()
715
+ .addFilter('IDSchema', tmpIDSchema);
716
+
717
+ this.fable.DAL.FactoSchema.doRead(tmpQuery,
718
+ (pError, pQuery, pRecord) =>
719
+ {
720
+ if (pError || !pRecord || !pRecord.IDSchema)
721
+ {
722
+ pResponse.send({ Error: 'Schema not found' });
723
+ return fNext();
724
+ }
725
+
726
+ // Update the schema fields
727
+ pRecord.SchemaDefinition = tmpSchemaDefinition;
728
+ pRecord.ManyfestDefinition = tmpManyfestDefinition;
729
+ pRecord.Version = (pRecord.Version || 0) + 1;
730
+ pRecord.SchemaHash = this._computeSchemaHash(tmpSchemaDefinition);
731
+
732
+ let tmpUpdateQuery = this.fable.DAL.FactoSchema.query.clone()
733
+ .addRecord(pRecord);
734
+
735
+ this.fable.DAL.FactoSchema.doUpdate(tmpUpdateQuery,
736
+ (pUpdateError, pUpdateQuery, pQueryRead, pUpdatedRecord) =>
737
+ {
738
+ if (pUpdateError)
739
+ {
740
+ this.fable.log.error(`SchemaManager error updating schema ${tmpIDSchema}: ${pUpdateError}`);
741
+ pResponse.send({ Error: pUpdateError.message || pUpdateError });
742
+ return fNext();
743
+ }
744
+
745
+ // Snapshot into a SchemaVersion record
746
+ if (!this.fable.DAL.FactoSchemaVersion)
747
+ {
748
+ pResponse.send({ Success: true, Schema: pUpdatedRecord });
749
+ return fNext();
750
+ }
751
+
752
+ let tmpVersionRecord =
753
+ {
754
+ IDSchema: tmpIDSchema,
755
+ Version: pUpdatedRecord.Version,
756
+ SchemaDefinition: tmpSchemaDefinition,
757
+ ManyfestDefinition: tmpManyfestDefinition,
758
+ SchemaHash: pUpdatedRecord.SchemaHash
759
+ };
760
+
761
+ let tmpVersionQuery = this.fable.DAL.FactoSchemaVersion.query.clone()
762
+ .addRecord(tmpVersionRecord);
763
+
764
+ this.fable.DAL.FactoSchemaVersion.doCreate(tmpVersionQuery,
765
+ (pVersionError, pVersionQuery, pVersionQueryRead, pVersionRecord) =>
766
+ {
767
+ if (pVersionError)
768
+ {
769
+ this.fable.log.error(`SchemaManager error creating version for schema ${tmpIDSchema}: ${pVersionError}`);
770
+ }
771
+ pResponse.send({ Success: true, Schema: pUpdatedRecord, SchemaVersion: pVersionRecord || false });
772
+ return fNext();
773
+ });
774
+ });
775
+ });
776
+ });
777
+
778
+ // PUT /facto/schema/:IDSchema/compile -- compile MicroDDL text
779
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/schema/:IDSchema/compile`,
780
+ (pRequest, pResponse, fNext) =>
781
+ {
782
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
783
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1)
784
+ {
785
+ pResponse.send({ Error: 'Invalid IDSchema parameter' });
786
+ return fNext();
787
+ }
788
+
789
+ let tmpBody = pRequest.body || {};
790
+ let tmpDDL = tmpBody.DDL;
791
+ if (typeof tmpDDL !== 'string' || tmpDDL.length === 0)
792
+ {
793
+ pResponse.send({ Error: 'DDL text is required in the request body' });
794
+ return fNext();
795
+ }
796
+
797
+ let tmpParsedSchema = this._parseMicroDDL(tmpDDL);
798
+ let tmpSchemaHash = this._computeSchemaHash(tmpDDL);
799
+
800
+ pResponse.send({ Success: true, IDSchema: tmpIDSchema, SchemaHash: tmpSchemaHash, ParsedSchema: tmpParsedSchema });
801
+ return fNext();
802
+ });
803
+
804
+ // PUT /facto/schema/:IDSchema/link-dataset/:IDDataset -- link a dataset to this schema
805
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/schema/:IDSchema/link-dataset/:IDDataset`,
806
+ (pRequest, pResponse, fNext) =>
807
+ {
808
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
809
+ let tmpIDDataset = parseInt(pRequest.params.IDDataset, 10);
810
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1 || isNaN(tmpIDDataset) || tmpIDDataset < 1)
811
+ {
812
+ pResponse.send({ Error: 'Invalid IDSchema or IDDataset parameter' });
813
+ return fNext();
814
+ }
815
+
816
+ if (!this.fable.DAL || !this.fable.DAL.Dataset)
817
+ {
818
+ pResponse.send({ Error: 'Dataset DAL not initialized' });
819
+ return fNext();
820
+ }
821
+
822
+ let tmpQuery = this.fable.DAL.Dataset.query.clone()
823
+ .addRecord({ IDDataset: tmpIDDataset, IDSchema: tmpIDSchema });
824
+
825
+ this.fable.DAL.Dataset.doUpdate(tmpQuery,
826
+ (pError, pQuery, pQueryRead, pRecord) =>
827
+ {
828
+ if (pError)
829
+ {
830
+ this.fable.log.error(`SchemaManager error linking dataset ${tmpIDDataset} to schema ${tmpIDSchema}: ${pError}`);
831
+ pResponse.send({ Error: pError.message || pError });
832
+ return fNext();
833
+ }
834
+ pResponse.send({ Success: true, IDSchema: tmpIDSchema, IDDataset: tmpIDDataset, Dataset: pRecord });
835
+ return fNext();
836
+ });
837
+ });
838
+
839
+ // PUT /facto/schema/:IDSchema/unlink-dataset/:IDDataset -- unlink a dataset from this schema
840
+ pOratorServiceServer.doPut(`${tmpRoutePrefix}/schema/:IDSchema/unlink-dataset/:IDDataset`,
841
+ (pRequest, pResponse, fNext) =>
842
+ {
843
+ let tmpIDSchema = parseInt(pRequest.params.IDSchema, 10);
844
+ let tmpIDDataset = parseInt(pRequest.params.IDDataset, 10);
845
+ if (isNaN(tmpIDSchema) || tmpIDSchema < 1 || isNaN(tmpIDDataset) || tmpIDDataset < 1)
846
+ {
847
+ pResponse.send({ Error: 'Invalid IDSchema or IDDataset parameter' });
848
+ return fNext();
849
+ }
850
+
851
+ if (!this.fable.DAL || !this.fable.DAL.Dataset)
852
+ {
853
+ pResponse.send({ Error: 'Dataset DAL not initialized' });
854
+ return fNext();
855
+ }
856
+
857
+ let tmpQuery = this.fable.DAL.Dataset.query.clone()
858
+ .addRecord({ IDDataset: tmpIDDataset, IDSchema: 0 });
859
+
860
+ this.fable.DAL.Dataset.doUpdate(tmpQuery,
861
+ (pError, pQuery, pQueryRead, pRecord) =>
862
+ {
863
+ if (pError)
864
+ {
865
+ this.fable.log.error(`SchemaManager error unlinking dataset ${tmpIDDataset} from schema ${tmpIDSchema}: ${pError}`);
866
+ pResponse.send({ Error: pError.message || pError });
867
+ return fNext();
868
+ }
869
+ pResponse.send({ Success: true, IDSchema: tmpIDSchema, IDDataset: tmpIDDataset, Dataset: pRecord });
870
+ return fNext();
871
+ });
872
+ });
873
+
874
+ // ================================================================
875
+ // Analyze Records - Discover schema from sample record content
876
+ // ================================================================
877
+ pOratorServiceServer.doPost(`${tmpRoutePrefix}/schemas/analyze-records`,
878
+ (pRequest, pResponse, fNext) =>
879
+ {
880
+ if (!this.fable.DAL || !this.fable.DAL.Record)
881
+ {
882
+ pResponse.send({ Error: 'Record DAL not initialized' });
883
+ return fNext();
884
+ }
885
+
886
+ let tmpBody = pRequest.body || {};
887
+ let tmpIDDataset = parseInt(tmpBody.IDDataset, 10) || 0;
888
+ let tmpIDSource = parseInt(tmpBody.IDSource, 10) || 0;
889
+ let tmpSampleSize = Math.min(parseInt(tmpBody.SampleSize, 10) || 50, 200);
890
+
891
+ if (!tmpIDDataset && !tmpIDSource)
892
+ {
893
+ pResponse.send({ Error: 'IDDataset or IDSource is required' });
894
+ return fNext();
895
+ }
896
+
897
+ let tmpQuery = this.fable.DAL.Record.query.clone();
898
+ tmpQuery.addFilter('Deleted', 0);
899
+ if (tmpIDDataset) tmpQuery.addFilter('IDDataset', tmpIDDataset);
900
+ if (tmpIDSource) tmpQuery.addFilter('IDSource', tmpIDSource);
901
+ tmpQuery.setCap(tmpSampleSize);
902
+
903
+ this.fable.DAL.Record.doReads(tmpQuery,
904
+ (pError, pQuery, pRecords) =>
905
+ {
906
+ if (pError)
907
+ {
908
+ this.fable.log.error(`SchemaManager error reading records for analysis: ${pError}`);
909
+ pResponse.send({ Error: pError.message || pError });
910
+ return fNext();
911
+ }
912
+
913
+ let tmpRecords = pRecords || [];
914
+ if (tmpRecords.length === 0)
915
+ {
916
+ pResponse.send({ Error: 'No records found matching the criteria', Fields: [], RecordsAnalyzed: 0 });
917
+ return fNext();
918
+ }
919
+
920
+ // Analyze each record's content
921
+ let tmpFieldMap = {};
922
+ let tmpRecordsAnalyzed = 0;
923
+
924
+ let tmpManyfest = this.fable.newManyfest();
925
+
926
+ for (let i = 0; i < tmpRecords.length; i++)
927
+ {
928
+ let tmpContentData = null;
929
+ try
930
+ {
931
+ tmpContentData = JSON.parse(tmpRecords[i].Content);
932
+ }
933
+ catch (e)
934
+ {
935
+ continue;
936
+ }
937
+ if (!tmpContentData || typeof tmpContentData !== 'object') continue;
938
+
939
+ tmpRecordsAnalyzed++;
940
+
941
+ // Use Manyfest to discover all addresses
942
+ let tmpDiscovered = tmpManyfest.objectAddressGeneration.generateAddressses(tmpContentData);
943
+ let tmpAddresses = Object.keys(tmpDiscovered);
944
+
945
+ for (let j = 0; j < tmpAddresses.length; j++)
946
+ {
947
+ let tmpAddr = tmpAddresses[j];
948
+ let tmpEntry = tmpDiscovered[tmpAddr];
949
+
950
+ if (!tmpFieldMap[tmpAddr])
951
+ {
952
+ tmpFieldMap[tmpAddr] =
953
+ {
954
+ Address: tmpAddr,
955
+ Hash: tmpAddr,
956
+ Name: this._prettifyFieldName(tmpAddr),
957
+ DataType: tmpEntry.DataType || 'String',
958
+ SampleValues: [],
959
+ Frequency: 0,
960
+ InSchema: true
961
+ };
962
+ }
963
+
964
+ tmpFieldMap[tmpAddr].Frequency++;
965
+
966
+ // Collect sample values (up to 5)
967
+ if (tmpEntry.Default !== undefined && tmpEntry.Default !== null && tmpEntry.Default !== '' && tmpFieldMap[tmpAddr].SampleValues.length < 5)
968
+ {
969
+ let tmpVal = tmpEntry.Default;
970
+ if (typeof tmpVal === 'object') tmpVal = JSON.stringify(tmpVal);
971
+ if (typeof tmpVal === 'string' && tmpVal.length > 100) tmpVal = tmpVal.substring(0, 100) + '\u2026';
972
+ tmpFieldMap[tmpAddr].SampleValues.push(tmpVal);
973
+ }
974
+ }
975
+ }
976
+
977
+ // Enhanced type inference
978
+ let tmpFields = Object.values(tmpFieldMap);
979
+ for (let i = 0; i < tmpFields.length; i++)
980
+ {
981
+ let tmpField = tmpFields[i];
982
+ // Skip non-leaf types
983
+ if (tmpField.DataType === 'Object' || tmpField.DataType === 'Array') continue;
984
+ // Skip fields with no samples
985
+ if (tmpField.SampleValues.length === 0) continue;
986
+
987
+ tmpField.DataType = this._inferEnhancedType(tmpField.SampleValues, tmpField.DataType);
988
+ }
989
+
990
+ // Sort fields: leaf primitives first (alphabetical), then objects/arrays
991
+ tmpFields.sort(
992
+ (a, b) =>
993
+ {
994
+ let tmpAIsContainer = (a.DataType === 'Object' || a.DataType === 'Array') ? 1 : 0;
995
+ let tmpBIsContainer = (b.DataType === 'Object' || b.DataType === 'Array') ? 1 : 0;
996
+ if (tmpAIsContainer !== tmpBIsContainer) return tmpAIsContainer - tmpBIsContainer;
997
+ return a.Address.localeCompare(b.Address);
998
+ });
999
+
1000
+ // Exclude container types from InSchema by default
1001
+ for (let i = 0; i < tmpFields.length; i++)
1002
+ {
1003
+ if (tmpFields[i].DataType === 'Object' || tmpFields[i].DataType === 'Array')
1004
+ {
1005
+ tmpFields[i].InSchema = false;
1006
+ }
1007
+ }
1008
+
1009
+ pResponse.send({ Fields: tmpFields, RecordsAnalyzed: tmpRecordsAnalyzed });
1010
+ return fNext();
1011
+ });
1012
+ });
1013
+
1014
+ this.fable.log.info(`SchemaManager routes connected at ${tmpRoutePrefix}/schema(s)/*`);
1015
+ }
1016
+
1017
+ /**
1018
+ * Convert a dot-path address to a human-readable name.
1019
+ * e.g. "metadata.author_name" → "Author Name"
1020
+ */
1021
+ _prettifyFieldName(pAddress)
1022
+ {
1023
+ // Take the last segment of the address
1024
+ let tmpParts = pAddress.split('.');
1025
+ let tmpLast = tmpParts[tmpParts.length - 1];
1026
+ // Remove array indices
1027
+ tmpLast = tmpLast.replace(/\[\d+\]/g, '');
1028
+ // Replace underscores and hyphens with spaces, then title-case
1029
+ return tmpLast
1030
+ .replace(/[_-]/g, ' ')
1031
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
1032
+ .replace(/\b\w/g, function(c) { return c.toUpperCase(); })
1033
+ .trim() || pAddress;
1034
+ }
1035
+
1036
+ /**
1037
+ * Enhanced type inference from sample values.
1038
+ */
1039
+ _inferEnhancedType(pSampleValues, pBaseType)
1040
+ {
1041
+ if (!pSampleValues || pSampleValues.length === 0) return pBaseType;
1042
+
1043
+ // If already a Number from Manyfest, try to distinguish Integer vs Float
1044
+ if (pBaseType === 'Number')
1045
+ {
1046
+ let tmpAllIntegers = true;
1047
+ for (let i = 0; i < pSampleValues.length; i++)
1048
+ {
1049
+ let tmpVal = pSampleValues[i];
1050
+ if (typeof tmpVal === 'number' && !Number.isInteger(tmpVal))
1051
+ {
1052
+ tmpAllIntegers = false;
1053
+ break;
1054
+ }
1055
+ }
1056
+ return tmpAllIntegers ? 'Integer' : 'Float';
1057
+ }
1058
+
1059
+ // If String, try to detect if all values are actually numbers, booleans, or dates
1060
+ if (pBaseType === 'String')
1061
+ {
1062
+ let tmpAllNumbers = true;
1063
+ let tmpAllIntegers = true;
1064
+ let tmpAllBooleans = true;
1065
+ let tmpAllDates = true;
1066
+ let tmpDatePattern = /^\d{4}[-/]\d{1,2}[-/]\d{1,2}([ T]\d{1,2}:\d{2}(:\d{2})?)?/;
1067
+
1068
+ for (let i = 0; i < pSampleValues.length; i++)
1069
+ {
1070
+ let tmpVal = String(pSampleValues[i]).trim();
1071
+ if (tmpVal === '') continue;
1072
+
1073
+ // Number check
1074
+ if (isNaN(parseFloat(tmpVal)) || !isFinite(tmpVal))
1075
+ {
1076
+ tmpAllNumbers = false;
1077
+ tmpAllIntegers = false;
1078
+ }
1079
+ else if (tmpVal.indexOf('.') >= 0)
1080
+ {
1081
+ tmpAllIntegers = false;
1082
+ }
1083
+
1084
+ // Boolean check
1085
+ let tmpLower = tmpVal.toLowerCase();
1086
+ if (tmpLower !== 'true' && tmpLower !== 'false' && tmpLower !== 'yes' && tmpLower !== 'no' && tmpLower !== '1' && tmpLower !== '0')
1087
+ {
1088
+ tmpAllBooleans = false;
1089
+ }
1090
+
1091
+ // Date check
1092
+ if (!tmpDatePattern.test(tmpVal))
1093
+ {
1094
+ tmpAllDates = false;
1095
+ }
1096
+ }
1097
+
1098
+ if (tmpAllBooleans) return 'Boolean';
1099
+ if (tmpAllIntegers && tmpAllNumbers) return 'Integer';
1100
+ if (tmpAllNumbers) return 'Float';
1101
+ if (tmpAllDates) return 'DateTime';
1102
+ }
1103
+
1104
+ return pBaseType;
1105
+ }
1106
+ }
1107
+
1108
+ module.exports = RetoldFactoSchemaManager;
1109
+ module.exports.serviceType = 'RetoldFactoSchemaManager';
1110
+ module.exports.default_configuration = defaultSchemaManagerOptions;