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,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;
|