@cepseudo/engine 1.0.0
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/LICENSE +21 -0
- package/README.md +209 -0
- package/dist/component_types.d.ts +92 -0
- package/dist/component_types.d.ts.map +1 -0
- package/dist/component_types.js +93 -0
- package/dist/component_types.js.map +1 -0
- package/dist/digital_twin_engine.d.ts +390 -0
- package/dist/digital_twin_engine.d.ts.map +1 -0
- package/dist/digital_twin_engine.js +1200 -0
- package/dist/digital_twin_engine.js.map +1 -0
- package/dist/endpoints.d.ts +45 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +87 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/error_handler.d.ts +20 -0
- package/dist/error_handler.d.ts.map +1 -0
- package/dist/error_handler.js +68 -0
- package/dist/error_handler.js.map +1 -0
- package/dist/global_assets_handler.d.ts +63 -0
- package/dist/global_assets_handler.d.ts.map +1 -0
- package/dist/global_assets_handler.js +127 -0
- package/dist/global_assets_handler.js.map +1 -0
- package/dist/graceful_shutdown.d.ts +44 -0
- package/dist/graceful_shutdown.d.ts.map +1 -0
- package/dist/graceful_shutdown.js +79 -0
- package/dist/graceful_shutdown.js.map +1 -0
- package/dist/health.d.ts +112 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +190 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/initializer.d.ts +62 -0
- package/dist/initializer.d.ts.map +1 -0
- package/dist/initializer.js +110 -0
- package/dist/initializer.js.map +1 -0
- package/dist/loader/component_loader.d.ts +133 -0
- package/dist/loader/component_loader.d.ts.map +1 -0
- package/dist/loader/component_loader.js +340 -0
- package/dist/loader/component_loader.js.map +1 -0
- package/dist/openapi/generator.d.ts +93 -0
- package/dist/openapi/generator.d.ts.map +1 -0
- package/dist/openapi/generator.js +293 -0
- package/dist/openapi/generator.js.map +1 -0
- package/dist/queue_manager.d.ts +87 -0
- package/dist/queue_manager.d.ts.map +1 -0
- package/dist/queue_manager.js +196 -0
- package/dist/queue_manager.js.map +1 -0
- package/dist/scheduler.d.ts +29 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +375 -0
- package/dist/scheduler.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
import express from 'ultimate-express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import cors from 'cors';
|
|
5
|
+
import compression from 'compression';
|
|
6
|
+
import { initializeComponents, initializeAssetsManagers } from './initializer.js';
|
|
7
|
+
import { detectComponentType, isCollector, isHarvester, isHandler, isAssetsManager, isCustomTableManager } from './component_types.js';
|
|
8
|
+
import { UserService, AuthMiddleware } from '@cepseudo/auth';
|
|
9
|
+
import { exposeEndpoints } from './endpoints.js';
|
|
10
|
+
import { scheduleComponents } from './scheduler.js';
|
|
11
|
+
import { LogLevel, engineEventBus } from '@cepseudo/shared';
|
|
12
|
+
import { QueueManager } from './queue_manager.js';
|
|
13
|
+
import { UploadProcessor, UploadReconciler } from '@cepseudo/assets';
|
|
14
|
+
import { isAsyncUploadable } from '@cepseudo/assets';
|
|
15
|
+
import { HealthChecker, createDatabaseCheck, createRedisCheck, createStorageCheck, livenessCheck } from './health.js';
|
|
16
|
+
/**
|
|
17
|
+
* Digital Twin Engine - Core orchestrator for collectors, harvesters, and handlers
|
|
18
|
+
*
|
|
19
|
+
* The engine manages the lifecycle of all components, sets up queues for processing,
|
|
20
|
+
* exposes HTTP endpoints, and handles the overall coordination of the digital twin system.
|
|
21
|
+
*
|
|
22
|
+
* @class DigitalTwinEngine
|
|
23
|
+
* @example
|
|
24
|
+
* ```TypeScript
|
|
25
|
+
* import { DigitalTwinEngine } from './digital_twin_engine.js'
|
|
26
|
+
* import { StorageServiceFactory } from '../storage/storage_factory.js'
|
|
27
|
+
* import { KnexDatabaseAdapter } from '../database/adapters/knex_database_adapter.js'
|
|
28
|
+
*
|
|
29
|
+
* const storage = StorageServiceFactory.create()
|
|
30
|
+
* const database = new KnexDatabaseAdapter({ client: 'sqlite3', connection: ':memory:' }, storage)
|
|
31
|
+
*
|
|
32
|
+
* const engine = new DigitalTwinEngine({
|
|
33
|
+
* storage,
|
|
34
|
+
* database,
|
|
35
|
+
* collectors: [myCollector],
|
|
36
|
+
* server: { port: 3000 }
|
|
37
|
+
* })
|
|
38
|
+
*
|
|
39
|
+
* await engine.start()
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class DigitalTwinEngine {
|
|
43
|
+
#collectors;
|
|
44
|
+
#harvesters;
|
|
45
|
+
#handlers;
|
|
46
|
+
#assetsManagers;
|
|
47
|
+
#customTableManagers;
|
|
48
|
+
#storage;
|
|
49
|
+
#database;
|
|
50
|
+
#app;
|
|
51
|
+
#router;
|
|
52
|
+
#options;
|
|
53
|
+
#queueManager;
|
|
54
|
+
#uploadProcessor;
|
|
55
|
+
#uploadReconciler;
|
|
56
|
+
/** uWebSockets.js TemplatedApp - has close() method to shut down all connections */
|
|
57
|
+
#server;
|
|
58
|
+
#workers = [];
|
|
59
|
+
#isShuttingDown = false;
|
|
60
|
+
#isStarted = false;
|
|
61
|
+
#shutdownTimeout = 30000;
|
|
62
|
+
#healthChecker = new HealthChecker();
|
|
63
|
+
// Mutable arrays for dynamically registered components
|
|
64
|
+
#dynamicCollectors = [];
|
|
65
|
+
#dynamicHarvesters = [];
|
|
66
|
+
#dynamicHandlers = [];
|
|
67
|
+
#dynamicAssetsManagers = [];
|
|
68
|
+
#dynamicCustomTableManagers = [];
|
|
69
|
+
/** Get all collectors (from constructor + register()) */
|
|
70
|
+
get #allCollectors() {
|
|
71
|
+
return [...this.#collectors, ...this.#dynamicCollectors];
|
|
72
|
+
}
|
|
73
|
+
/** Get all harvesters (from constructor + register()) */
|
|
74
|
+
get #allHarvesters() {
|
|
75
|
+
return [...this.#harvesters, ...this.#dynamicHarvesters];
|
|
76
|
+
}
|
|
77
|
+
/** Get all handlers (from constructor + register()) */
|
|
78
|
+
get #allHandlers() {
|
|
79
|
+
return [...this.#handlers, ...this.#dynamicHandlers];
|
|
80
|
+
}
|
|
81
|
+
/** Get all assets managers (from constructor + register()) */
|
|
82
|
+
get #allAssetsManagers() {
|
|
83
|
+
return [...this.#assetsManagers, ...this.#dynamicAssetsManagers];
|
|
84
|
+
}
|
|
85
|
+
/** Get all custom table managers (from constructor + register()) */
|
|
86
|
+
get #allCustomTableManagers() {
|
|
87
|
+
return [...this.#customTableManagers, ...this.#dynamicCustomTableManagers];
|
|
88
|
+
}
|
|
89
|
+
/** Get all active components (collectors and harvesters) */
|
|
90
|
+
get #activeComponents() {
|
|
91
|
+
return [...this.#allCollectors, ...this.#allHarvesters];
|
|
92
|
+
}
|
|
93
|
+
/** Get all components (collectors + harvesters + handlers + assetsManagers + customTableManagers) */
|
|
94
|
+
get #allComponents() {
|
|
95
|
+
return [
|
|
96
|
+
...this.#allCollectors,
|
|
97
|
+
...this.#allHarvesters,
|
|
98
|
+
...this.#allHandlers,
|
|
99
|
+
...this.#allAssetsManagers,
|
|
100
|
+
...this.#allCustomTableManagers
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
/** Check if multi-queue mode is enabled */
|
|
104
|
+
get #isMultiQueueEnabled() {
|
|
105
|
+
return this.#options.queues?.multiQueue ?? true;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Creates a new Digital Twin Engine instance
|
|
109
|
+
*
|
|
110
|
+
* @param {EngineOptions} options - Configuration options for the engine
|
|
111
|
+
* @throws {Error} If required options (storage, database) are missing
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```TypeScript
|
|
115
|
+
* const engine = new DigitalTwinEngine({
|
|
116
|
+
* storage: myStorageService,
|
|
117
|
+
* database: myDatabaseAdapter,
|
|
118
|
+
* collectors: [collector1, collector2],
|
|
119
|
+
* server: { port: 4000, host: 'localhost' }
|
|
120
|
+
* })
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
constructor(options) {
|
|
124
|
+
this.#options = this.#applyDefaults(options);
|
|
125
|
+
this.#collectors = this.#options.collectors ?? [];
|
|
126
|
+
this.#harvesters = this.#options.harvesters ?? [];
|
|
127
|
+
this.#handlers = this.#options.handlers ?? [];
|
|
128
|
+
this.#assetsManagers = this.#options.assetsManagers ?? [];
|
|
129
|
+
this.#customTableManagers = this.#options.customTableManagers ?? [];
|
|
130
|
+
this.#storage = this.#options.storage;
|
|
131
|
+
this.#database = this.#options.database;
|
|
132
|
+
this.#app = express();
|
|
133
|
+
this.#router = express.Router();
|
|
134
|
+
this.#queueManager = this.#createQueueManager();
|
|
135
|
+
this.#uploadProcessor = this.#createUploadProcessor();
|
|
136
|
+
this.#uploadReconciler = new UploadReconciler(this.#database, this.#storage);
|
|
137
|
+
}
|
|
138
|
+
#createUploadProcessor() {
|
|
139
|
+
// Only create upload processor if we have a queue manager (which means Redis is available)
|
|
140
|
+
if (!this.#queueManager) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return new UploadProcessor(this.#storage, this.#database);
|
|
144
|
+
}
|
|
145
|
+
#applyDefaults(options) {
|
|
146
|
+
return {
|
|
147
|
+
collectors: [],
|
|
148
|
+
harvesters: [],
|
|
149
|
+
handlers: [],
|
|
150
|
+
assetsManagers: [],
|
|
151
|
+
customTableManagers: [],
|
|
152
|
+
server: {
|
|
153
|
+
port: 3000,
|
|
154
|
+
host: '0.0.0.0',
|
|
155
|
+
...options.server
|
|
156
|
+
},
|
|
157
|
+
queues: {
|
|
158
|
+
multiQueue: true,
|
|
159
|
+
workers: {
|
|
160
|
+
collectors: 1,
|
|
161
|
+
harvesters: 1,
|
|
162
|
+
...options.queues?.workers
|
|
163
|
+
},
|
|
164
|
+
...options.queues
|
|
165
|
+
},
|
|
166
|
+
logging: {
|
|
167
|
+
level: LogLevel.INFO,
|
|
168
|
+
format: 'text',
|
|
169
|
+
...options.logging
|
|
170
|
+
},
|
|
171
|
+
dryRun: false,
|
|
172
|
+
...options
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
#createQueueManager() {
|
|
176
|
+
// Create queue manager if we have collectors, harvesters, OR assets managers that may need async uploads
|
|
177
|
+
// Note: At construction time, only constructor-provided components are available
|
|
178
|
+
// Dynamic components registered via register() will be handled at start() time
|
|
179
|
+
const hasActiveComponents = this.#collectors.length > 0 || this.#harvesters.length > 0;
|
|
180
|
+
const hasAssetsManagers = this.#assetsManagers.length > 0;
|
|
181
|
+
if (!hasActiveComponents && !hasAssetsManagers) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return new QueueManager({
|
|
185
|
+
redis: this.#options.redis,
|
|
186
|
+
collectorWorkers: this.#options.queues?.workers?.collectors,
|
|
187
|
+
harvesterWorkers: this.#options.queues?.workers?.harvesters,
|
|
188
|
+
queueOptions: this.#options.queues?.options
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Initialize store managers and create their database tables
|
|
193
|
+
* @private
|
|
194
|
+
*/
|
|
195
|
+
async #initializeCustomTableManagers(authMiddleware) {
|
|
196
|
+
for (const customTableManager of this.#customTableManagers) {
|
|
197
|
+
// Inject dependencies
|
|
198
|
+
customTableManager.setDependencies(this.#database, authMiddleware);
|
|
199
|
+
// Initialize the table with custom columns
|
|
200
|
+
await customTableManager.initializeTable();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Ensure temporary upload directory exists
|
|
205
|
+
* @private
|
|
206
|
+
*/
|
|
207
|
+
async #ensureTempUploadDir() {
|
|
208
|
+
const tempDir = process.env.TEMP_UPLOAD_DIR || '/tmp/digitaltwin-uploads';
|
|
209
|
+
try {
|
|
210
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
throw new Error(`Failed to create temp upload directory ${tempDir}: ${error}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Setup monitoring endpoints for queue statistics and health checks
|
|
218
|
+
* @private
|
|
219
|
+
*/
|
|
220
|
+
#setupMonitoringEndpoints() {
|
|
221
|
+
// Register default health checks
|
|
222
|
+
this.#healthChecker.registerCheck('database', createDatabaseCheck(this.#database));
|
|
223
|
+
if (this.#queueManager) {
|
|
224
|
+
this.#healthChecker.registerCheck('redis', createRedisCheck(this.#queueManager));
|
|
225
|
+
}
|
|
226
|
+
this.#healthChecker.registerCheck('storage', createStorageCheck(this.#storage));
|
|
227
|
+
// Set component counts (includes both constructor and dynamically registered components)
|
|
228
|
+
this.#healthChecker.setComponentCounts({
|
|
229
|
+
collectors: this.#allCollectors.length,
|
|
230
|
+
harvesters: this.#allHarvesters.length,
|
|
231
|
+
handlers: this.#allHandlers.length,
|
|
232
|
+
assetsManagers: this.#allAssetsManagers.length
|
|
233
|
+
});
|
|
234
|
+
// Liveness probe - shallow check, always returns ok if process is running
|
|
235
|
+
this.#router.get('/api/health/live', (req, res) => {
|
|
236
|
+
res.status(200).json(livenessCheck());
|
|
237
|
+
});
|
|
238
|
+
// Readiness probe - deep check with database and redis verification
|
|
239
|
+
this.#router.get('/api/health/ready', async (req, res) => {
|
|
240
|
+
const health = await this.#healthChecker.performCheck();
|
|
241
|
+
const statusCode = health.status === 'unhealthy' ? 503 : 200;
|
|
242
|
+
res.status(statusCode).json(health);
|
|
243
|
+
});
|
|
244
|
+
// Full health check endpoint (detailed)
|
|
245
|
+
this.#router.get('/api/health', async (req, res) => {
|
|
246
|
+
const health = await this.#healthChecker.performCheck();
|
|
247
|
+
res.json(health);
|
|
248
|
+
});
|
|
249
|
+
// Queue statistics endpoint
|
|
250
|
+
this.#router.get('/api/queues/stats', async (req, res) => {
|
|
251
|
+
if (this.#queueManager) {
|
|
252
|
+
const stats = await this.#queueManager.getQueueStats();
|
|
253
|
+
res.json(stats);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
res.json({
|
|
257
|
+
collectors: { status: 'No collectors configured' },
|
|
258
|
+
harvesters: { status: 'No harvesters configured' }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Starts the Digital Twin Engine
|
|
265
|
+
*
|
|
266
|
+
* This method:
|
|
267
|
+
* 1. Initializes all registered components (collectors, harvesters, handlers)
|
|
268
|
+
* 2. Set up HTTP endpoints for component access
|
|
269
|
+
* 3. Configures and starts background job queues
|
|
270
|
+
* 4. Starts the HTTP server
|
|
271
|
+
* 5. Exposes queue monitoring endpoints
|
|
272
|
+
*
|
|
273
|
+
* @async
|
|
274
|
+
* @returns {Promise<void>}
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```TypeScript
|
|
278
|
+
* await engine.start()
|
|
279
|
+
* console.log('Engine is running!')
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
async start() {
|
|
283
|
+
const isDryRun = this.#options.dryRun ?? false;
|
|
284
|
+
if (isDryRun) {
|
|
285
|
+
// In dry run, just validate everything without creating tables
|
|
286
|
+
const validationResult = await this.validateConfiguration();
|
|
287
|
+
if (!validationResult.valid) {
|
|
288
|
+
throw new Error(`Validation failed:\n${validationResult.engineErrors.join('\n')}`);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Mark as started to prevent component registration
|
|
293
|
+
this.#isStarted = true;
|
|
294
|
+
// Recreate queue manager if we have new components registered after construction
|
|
295
|
+
if (!this.#queueManager && (this.#collectors.length > 0 || this.#harvesters.length > 0 || this.#assetsManagers.length > 0)) {
|
|
296
|
+
this.#queueManager = this.#createQueueManager();
|
|
297
|
+
}
|
|
298
|
+
// Normal startup - initialize user management tables and auth middleware
|
|
299
|
+
const userRepository = this.#database.getUserRepository();
|
|
300
|
+
const userService = new UserService(userRepository);
|
|
301
|
+
await userService.initializeTables();
|
|
302
|
+
const authMiddleware = new AuthMiddleware(userService);
|
|
303
|
+
// Get autoMigration setting (default: true)
|
|
304
|
+
const autoMigration = this.#options.autoMigration ?? true;
|
|
305
|
+
// Initialize components and create tables if needed
|
|
306
|
+
await initializeComponents(this.#activeComponents, this.#database, this.#storage, autoMigration);
|
|
307
|
+
// Initialize assets managers and create their tables if needed
|
|
308
|
+
await initializeAssetsManagers(this.#assetsManagers, this.#database, this.#storage, autoMigration, authMiddleware);
|
|
309
|
+
// Initialize store managers and create their tables if needed
|
|
310
|
+
await this.#initializeCustomTableManagers(authMiddleware);
|
|
311
|
+
// Initialize handlers (inject dependencies if needed)
|
|
312
|
+
for (const handler of this.#handlers) {
|
|
313
|
+
if ('setDependencies' in handler && typeof handler.setDependencies === 'function') {
|
|
314
|
+
handler.setDependencies(this.#database, this.#storage);
|
|
315
|
+
}
|
|
316
|
+
// If it's a GlobalAssetsHandler, inject the AssetsManager instances
|
|
317
|
+
if ('setAssetsManagers' in handler && typeof handler.setAssetsManagers === 'function') {
|
|
318
|
+
handler.setAssetsManagers(this.#assetsManagers);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Inject upload queue to components that support async uploads
|
|
322
|
+
if (this.#queueManager) {
|
|
323
|
+
for (const manager of this.#allAssetsManagers) {
|
|
324
|
+
if (isAsyncUploadable(manager)) {
|
|
325
|
+
manager.setUploadQueue(this.#queueManager.uploadQueue);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Start upload processor worker (for async file processing)
|
|
330
|
+
// Uses same Redis config as QueueManager (defaults to localhost:6379 if not specified)
|
|
331
|
+
if (this.#uploadProcessor) {
|
|
332
|
+
const redisConfig = this.#options.redis || {
|
|
333
|
+
host: 'localhost',
|
|
334
|
+
port: 6379,
|
|
335
|
+
maxRetriesPerRequest: null
|
|
336
|
+
};
|
|
337
|
+
this.#uploadProcessor.start(redisConfig);
|
|
338
|
+
}
|
|
339
|
+
// Start upload reconciler for presigned uploads (registers all asset manager tables)
|
|
340
|
+
const assetTableNames = this.#allAssetsManagers.map(m => m.getConfiguration().name);
|
|
341
|
+
if (assetTableNames.length > 0) {
|
|
342
|
+
this.#uploadReconciler.registerTables(assetTableNames);
|
|
343
|
+
this.#uploadReconciler.start();
|
|
344
|
+
}
|
|
345
|
+
await exposeEndpoints(this.#router, this.#allComponents);
|
|
346
|
+
// Setup component scheduling with queue manager (only if we have active components)
|
|
347
|
+
if (this.#activeComponents.length > 0 && this.#queueManager) {
|
|
348
|
+
this.#workers = await scheduleComponents(this.#activeComponents, this.#queueManager, this.#isMultiQueueEnabled);
|
|
349
|
+
}
|
|
350
|
+
this.#setupMonitoringEndpoints();
|
|
351
|
+
// Ensure temporary upload directory exists
|
|
352
|
+
await this.#ensureTempUploadDir();
|
|
353
|
+
// HTTP compression - disabled by default as API gateways (APISIX, Kong, etc.) typically handle this
|
|
354
|
+
// Enable with DIGITALTWIN_ENABLE_COMPRESSION=true for standalone deployments without a gateway
|
|
355
|
+
const enableCompression = process.env.DIGITALTWIN_ENABLE_COMPRESSION === 'true';
|
|
356
|
+
if (enableCompression) {
|
|
357
|
+
const compressionMiddleware = compression({
|
|
358
|
+
filter: (req, res) => {
|
|
359
|
+
// Don't compress binary streams
|
|
360
|
+
if (req.headers['accept']?.includes('application/octet-stream')) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
// Use default filter for other content types
|
|
364
|
+
return compression.filter(req, res);
|
|
365
|
+
},
|
|
366
|
+
level: 6, // Balance between speed and compression ratio
|
|
367
|
+
threshold: 1024 // Only compress responses larger than 1KB
|
|
368
|
+
});
|
|
369
|
+
this.#app.use(compressionMiddleware);
|
|
370
|
+
}
|
|
371
|
+
// Enable CORS for cross-origin requests from frontend applications
|
|
372
|
+
this.#app.use(cors({
|
|
373
|
+
origin: process.env.CORS_ORIGIN || true, // Allow all origins by default, configure in production
|
|
374
|
+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
375
|
+
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
376
|
+
credentials: true // Allow cookies/credentials
|
|
377
|
+
}));
|
|
378
|
+
// Configure Express middlewares for body parsing - no limits for large files
|
|
379
|
+
this.#app.use(express.json({ limit: '10gb' }));
|
|
380
|
+
this.#app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
|
381
|
+
// Add multipart/form-data support for file uploads with disk storage for large files
|
|
382
|
+
const upload = multer({
|
|
383
|
+
storage: multer.diskStorage({
|
|
384
|
+
destination: (req, file, cb) => {
|
|
385
|
+
// Use temporary directory, will be cleaned up after processing
|
|
386
|
+
const tempDir = process.env.TEMP_UPLOAD_DIR || '/tmp/digitaltwin-uploads';
|
|
387
|
+
cb(null, tempDir);
|
|
388
|
+
},
|
|
389
|
+
filename: (req, file, cb) => {
|
|
390
|
+
// Generate unique filename to avoid conflicts
|
|
391
|
+
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
392
|
+
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
|
393
|
+
}
|
|
394
|
+
}),
|
|
395
|
+
limits: {
|
|
396
|
+
// Remove file size limit to allow large files (10GB+)
|
|
397
|
+
files: 1, // Only one file per request for safety
|
|
398
|
+
parts: 10, // Limit form parts
|
|
399
|
+
headerPairs: 2000 // Limit header pairs
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
this.#app.use(upload.single('file'));
|
|
403
|
+
this.#app.use(this.#router);
|
|
404
|
+
const { port, host = '0.0.0.0' } = this.#options.server ?? {
|
|
405
|
+
port: 3000,
|
|
406
|
+
host: '0.0.0.0'
|
|
407
|
+
};
|
|
408
|
+
// Wait for server to be ready
|
|
409
|
+
// Note: ultimate-express types say (host, port, callback) but implementation accepts (port, host, callback)
|
|
410
|
+
// The implementation internally reorders to (host, port) for uWebSockets.js
|
|
411
|
+
// Using type assertion to work around this types bug in ultimate-express
|
|
412
|
+
await new Promise(resolve => {
|
|
413
|
+
const app = this.#app;
|
|
414
|
+
this.#server = app.listen(port, host, () => {
|
|
415
|
+
resolve();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
// Note: uWebSockets.js (used by ultimate-express) handles timeouts differently than Node.js http.Server:
|
|
419
|
+
// - HTTP idle timeout is 10 seconds (only when connection is inactive)
|
|
420
|
+
// - During active data transfer (file uploads), the connection stays open
|
|
421
|
+
// - Properties like timeout, keepAliveTimeout, headersTimeout don't exist on TemplatedApp
|
|
422
|
+
// For large file uploads, this should work fine as the connection remains active during transfer.
|
|
423
|
+
// Attempt to load optional packages (e.g. @cepseudo/ngsi-ld)
|
|
424
|
+
await this.#loadOptionalPackages();
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Attempts to load optional plugin packages that extend the engine.
|
|
428
|
+
* Failures are silently ignored — the engine works without them.
|
|
429
|
+
* @private
|
|
430
|
+
*/
|
|
431
|
+
async #loadOptionalPackages() {
|
|
432
|
+
try {
|
|
433
|
+
// Using a computed specifier prevents TypeScript from requiring the module at compile time.
|
|
434
|
+
// The engine works correctly whether or not this optional package is installed.
|
|
435
|
+
const ngsiLdPkg = '@cepseudo/ngsi-ld';
|
|
436
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
437
|
+
const ngsiLd = await import(ngsiLdPkg);
|
|
438
|
+
const { Logger } = await import('@cepseudo/shared');
|
|
439
|
+
await ngsiLd.registerNgsiLd({
|
|
440
|
+
router: this.#router,
|
|
441
|
+
db: this.#database,
|
|
442
|
+
redis: this.getRedisConfig(),
|
|
443
|
+
components: this.getAllComponents(),
|
|
444
|
+
logger: new Logger('ngsi-ld'),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
// Package not installed — skip silently
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Get the server port
|
|
453
|
+
*
|
|
454
|
+
* @returns {number | undefined} The server port or undefined if not started
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* ```TypeScript
|
|
458
|
+
* const port = engine.getPort()
|
|
459
|
+
* console.log(`Server running on port ${port}`)
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
getPort() {
|
|
463
|
+
if (!this.#server)
|
|
464
|
+
return undefined;
|
|
465
|
+
// ultimate-express stores port on the app instance
|
|
466
|
+
// Use type assertion to access the port property
|
|
467
|
+
const app = this.#app;
|
|
468
|
+
return app.port ?? this.#options.server?.port;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Returns the internal Express Router.
|
|
472
|
+
* Used by optional plugin packages to register additional routes.
|
|
473
|
+
*/
|
|
474
|
+
getRouter() {
|
|
475
|
+
return this.#router;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Returns the DatabaseAdapter instance.
|
|
479
|
+
* Used by optional plugin packages for their own persistence needs.
|
|
480
|
+
*/
|
|
481
|
+
getDatabase() {
|
|
482
|
+
return this.#database;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Returns the Redis connection configuration extracted from engine options.
|
|
486
|
+
* Falls back to localhost:6379 when no Redis config was provided.
|
|
487
|
+
*/
|
|
488
|
+
getRedisConfig() {
|
|
489
|
+
const redis = this.#options.redis;
|
|
490
|
+
if (!redis) {
|
|
491
|
+
return { host: 'localhost', port: 6379 };
|
|
492
|
+
}
|
|
493
|
+
if ('host' in redis && 'port' in redis) {
|
|
494
|
+
return {
|
|
495
|
+
host: redis.host,
|
|
496
|
+
port: redis.port,
|
|
497
|
+
password: redis.password,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// Fallback for path-based connection strings
|
|
501
|
+
return { host: 'localhost', port: 6379 };
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Returns a flat list of all components registered with the engine.
|
|
505
|
+
* Used by optional plugin packages to inspect component capabilities.
|
|
506
|
+
*/
|
|
507
|
+
getAllComponents() {
|
|
508
|
+
return [...this.#allComponents];
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Registers a single component with automatic type detection.
|
|
512
|
+
*
|
|
513
|
+
* The engine automatically detects the component type based on its class
|
|
514
|
+
* and adds it to the appropriate internal collection.
|
|
515
|
+
*
|
|
516
|
+
* @param component - Component instance to register
|
|
517
|
+
* @returns The engine instance for method chaining
|
|
518
|
+
* @throws Error if component type cannot be determined or is already registered
|
|
519
|
+
*
|
|
520
|
+
* @example
|
|
521
|
+
* ```typescript
|
|
522
|
+
* const engine = new DigitalTwinEngine({ storage, database })
|
|
523
|
+
*
|
|
524
|
+
* engine
|
|
525
|
+
* .register(new WeatherCollector())
|
|
526
|
+
* .register(new TrafficAnalysisHarvester())
|
|
527
|
+
* .register(new ApiHandler())
|
|
528
|
+
* .register(new GLTFAssetsManager())
|
|
529
|
+
*
|
|
530
|
+
* await engine.start()
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
register(component) {
|
|
534
|
+
if (this.#isStarted) {
|
|
535
|
+
throw new Error('Cannot register components after the engine has started');
|
|
536
|
+
}
|
|
537
|
+
const type = detectComponentType(component);
|
|
538
|
+
const config = component.getConfiguration();
|
|
539
|
+
// Check for duplicate registration
|
|
540
|
+
if (this.#isComponentRegistered(config.name, type)) {
|
|
541
|
+
throw new Error(`Component "${config.name}" of type "${type}" is already registered. ` +
|
|
542
|
+
'Each component must have a unique name within its type.');
|
|
543
|
+
}
|
|
544
|
+
switch (type) {
|
|
545
|
+
case 'collector':
|
|
546
|
+
if (isCollector(component)) {
|
|
547
|
+
this.#dynamicCollectors.push(component);
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
case 'harvester':
|
|
551
|
+
if (isHarvester(component)) {
|
|
552
|
+
this.#dynamicHarvesters.push(component);
|
|
553
|
+
}
|
|
554
|
+
break;
|
|
555
|
+
case 'handler':
|
|
556
|
+
if (isHandler(component)) {
|
|
557
|
+
this.#dynamicHandlers.push(component);
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
case 'assets_manager':
|
|
561
|
+
if (isAssetsManager(component)) {
|
|
562
|
+
this.#dynamicAssetsManagers.push(component);
|
|
563
|
+
}
|
|
564
|
+
break;
|
|
565
|
+
case 'custom_table_manager':
|
|
566
|
+
if (isCustomTableManager(component)) {
|
|
567
|
+
this.#dynamicCustomTableManagers.push(component);
|
|
568
|
+
}
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
return this;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Registers multiple components at once with automatic type detection.
|
|
575
|
+
*
|
|
576
|
+
* Useful for registering all components from a module or when loading
|
|
577
|
+
* components dynamically.
|
|
578
|
+
*
|
|
579
|
+
* @param components - Array of component instances to register
|
|
580
|
+
* @returns The engine instance for method chaining
|
|
581
|
+
* @throws Error if any component type cannot be determined or is duplicate
|
|
582
|
+
*
|
|
583
|
+
* @example
|
|
584
|
+
* ```typescript
|
|
585
|
+
* const engine = new DigitalTwinEngine({ storage, database })
|
|
586
|
+
*
|
|
587
|
+
* engine.registerAll([
|
|
588
|
+
* new WeatherCollector(),
|
|
589
|
+
* new TrafficCollector(),
|
|
590
|
+
* new AnalysisHarvester(),
|
|
591
|
+
* new ApiHandler()
|
|
592
|
+
* ])
|
|
593
|
+
*
|
|
594
|
+
* await engine.start()
|
|
595
|
+
* ```
|
|
596
|
+
*/
|
|
597
|
+
registerAll(components) {
|
|
598
|
+
for (const component of components) {
|
|
599
|
+
this.register(component);
|
|
600
|
+
}
|
|
601
|
+
return this;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Registers components with explicit type specification.
|
|
605
|
+
*
|
|
606
|
+
* Provides full type safety at compile time. Use this method when you
|
|
607
|
+
* have pre-sorted components from auto-discovery or want explicit control.
|
|
608
|
+
*
|
|
609
|
+
* @param components - Object with typed component arrays
|
|
610
|
+
* @returns The engine instance for method chaining
|
|
611
|
+
*
|
|
612
|
+
* @example
|
|
613
|
+
* ```typescript
|
|
614
|
+
* const loaded = await loadComponents('./src/components')
|
|
615
|
+
*
|
|
616
|
+
* engine.registerComponents({
|
|
617
|
+
* collectors: loaded.collectors,
|
|
618
|
+
* harvesters: loaded.harvesters,
|
|
619
|
+
* handlers: loaded.handlers,
|
|
620
|
+
* assetsManagers: loaded.assetsManagers,
|
|
621
|
+
* customTableManagers: loaded.customTableManagers
|
|
622
|
+
* })
|
|
623
|
+
* ```
|
|
624
|
+
*/
|
|
625
|
+
registerComponents(components) {
|
|
626
|
+
if (components.collectors) {
|
|
627
|
+
this.#dynamicCollectors.push(...components.collectors);
|
|
628
|
+
}
|
|
629
|
+
if (components.harvesters) {
|
|
630
|
+
this.#dynamicHarvesters.push(...components.harvesters);
|
|
631
|
+
}
|
|
632
|
+
if (components.handlers) {
|
|
633
|
+
this.#dynamicHandlers.push(...components.handlers);
|
|
634
|
+
}
|
|
635
|
+
if (components.assetsManagers) {
|
|
636
|
+
this.#dynamicAssetsManagers.push(...components.assetsManagers);
|
|
637
|
+
}
|
|
638
|
+
if (components.customTableManagers) {
|
|
639
|
+
this.#dynamicCustomTableManagers.push(...components.customTableManagers);
|
|
640
|
+
}
|
|
641
|
+
return this;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Checks if a component with the given name is already registered.
|
|
645
|
+
*/
|
|
646
|
+
#isComponentRegistered(name, type) {
|
|
647
|
+
const allComponents = this.#getAllComponentsOfType(type);
|
|
648
|
+
return allComponents.some(c => c.getConfiguration().name === name);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Gets all components of a specific type (both from constructor and register()).
|
|
652
|
+
*/
|
|
653
|
+
#getAllComponentsOfType(type) {
|
|
654
|
+
switch (type) {
|
|
655
|
+
case 'collector':
|
|
656
|
+
return this.#allCollectors;
|
|
657
|
+
case 'harvester':
|
|
658
|
+
return this.#allHarvesters;
|
|
659
|
+
case 'handler':
|
|
660
|
+
return this.#allHandlers;
|
|
661
|
+
case 'assets_manager':
|
|
662
|
+
return this.#allAssetsManagers;
|
|
663
|
+
case 'custom_table_manager':
|
|
664
|
+
return this.#allCustomTableManagers;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Configure the shutdown timeout (in ms)
|
|
669
|
+
* @param timeout Timeout in milliseconds (default: 30000)
|
|
670
|
+
*/
|
|
671
|
+
setShutdownTimeout(timeout) {
|
|
672
|
+
this.#shutdownTimeout = timeout;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Check if the engine is currently shutting down
|
|
676
|
+
* @returns true if shutdown is in progress
|
|
677
|
+
*/
|
|
678
|
+
isShuttingDown() {
|
|
679
|
+
return this.#isShuttingDown;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Register a custom health check
|
|
683
|
+
* @param name Unique name for the check
|
|
684
|
+
* @param checkFn Function that performs the check
|
|
685
|
+
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```typescript
|
|
688
|
+
* engine.registerHealthCheck('external-api', async () => {
|
|
689
|
+
* try {
|
|
690
|
+
* const res = await fetch('https://api.example.com/health')
|
|
691
|
+
* return { status: res.ok ? 'up' : 'down' }
|
|
692
|
+
* } catch (error) {
|
|
693
|
+
* return { status: 'down', error: error.message }
|
|
694
|
+
* }
|
|
695
|
+
* })
|
|
696
|
+
* ```
|
|
697
|
+
*/
|
|
698
|
+
registerHealthCheck(name, checkFn) {
|
|
699
|
+
this.#healthChecker.registerCheck(name, checkFn);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Remove a custom health check
|
|
703
|
+
* @param name Name of the check to remove
|
|
704
|
+
* @returns true if the check was removed, false if it didn't exist
|
|
705
|
+
*/
|
|
706
|
+
removeHealthCheck(name) {
|
|
707
|
+
return this.#healthChecker.removeCheck(name);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Get list of registered health check names
|
|
711
|
+
*/
|
|
712
|
+
getHealthCheckNames() {
|
|
713
|
+
return this.#healthChecker.getCheckNames();
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Stops the Digital Twin Engine with graceful shutdown
|
|
717
|
+
*
|
|
718
|
+
* This method:
|
|
719
|
+
* 1. Prevents new work from being accepted
|
|
720
|
+
* 2. Removes all event listeners
|
|
721
|
+
* 3. Closes HTTP server
|
|
722
|
+
* 4. Drains queues and waits for active jobs
|
|
723
|
+
* 5. Closes all queue workers
|
|
724
|
+
* 6. Stops upload processor
|
|
725
|
+
* 7. Closes queue manager
|
|
726
|
+
* 8. Closes database connections
|
|
727
|
+
*
|
|
728
|
+
* @async
|
|
729
|
+
* @returns {Promise<void>}
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* ```TypeScript
|
|
733
|
+
* await engine.stop()
|
|
734
|
+
* console.log('Engine stopped gracefully')
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
async stop() {
|
|
738
|
+
if (this.#isShuttingDown) {
|
|
739
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
740
|
+
console.warn('[DigitalTwin] Shutdown already in progress');
|
|
741
|
+
}
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
this.#isShuttingDown = true;
|
|
745
|
+
const startTime = Date.now();
|
|
746
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
747
|
+
console.log('[DigitalTwin] Graceful shutdown initiated...');
|
|
748
|
+
}
|
|
749
|
+
const errors = [];
|
|
750
|
+
// 1. Remove all event listeners to prevent new work
|
|
751
|
+
this.#cleanupEventListeners();
|
|
752
|
+
// 2. Close HTTP server (uWebSockets.js TemplatedApp.close() is synchronous)
|
|
753
|
+
if (this.#server) {
|
|
754
|
+
try {
|
|
755
|
+
this.#server.close();
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
errors.push(this.#wrapError('Server close', error));
|
|
759
|
+
}
|
|
760
|
+
this.#server = undefined;
|
|
761
|
+
}
|
|
762
|
+
// 3. Drain queues - wait for active jobs with timeout
|
|
763
|
+
if (this.#queueManager) {
|
|
764
|
+
try {
|
|
765
|
+
await this.#drainQueues();
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
errors.push(this.#wrapError('Queue drain', error));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// 4. Close all workers with extended timeout and force close
|
|
772
|
+
await this.#closeWorkers(errors);
|
|
773
|
+
// 5. Stop upload reconciler and processor
|
|
774
|
+
try {
|
|
775
|
+
this.#uploadReconciler.stop();
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
errors.push(this.#wrapError('Upload reconciler', error));
|
|
779
|
+
}
|
|
780
|
+
if (this.#uploadProcessor) {
|
|
781
|
+
try {
|
|
782
|
+
await this.#uploadProcessor.stop();
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
errors.push(this.#wrapError('Upload processor', error));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// 6. Close queue connections (only if we have a queue manager)
|
|
789
|
+
if (this.#queueManager) {
|
|
790
|
+
try {
|
|
791
|
+
await this.#queueManager.close();
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
errors.push(this.#wrapError('Queue manager', error));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// 7. Close database connections
|
|
798
|
+
try {
|
|
799
|
+
await this.#database.close();
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
errors.push(this.#wrapError('Database', error));
|
|
803
|
+
}
|
|
804
|
+
const duration = Date.now() - startTime;
|
|
805
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
806
|
+
if (errors.length > 0) {
|
|
807
|
+
console.error(`[DigitalTwin] Shutdown completed with ${errors.length} errors in ${duration}ms:`, errors.map(e => e.message).join(', '));
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
console.log(`[DigitalTwin] Shutdown completed successfully in ${duration}ms`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
#cleanupEventListeners() {
|
|
815
|
+
engineEventBus.removeAllListeners();
|
|
816
|
+
}
|
|
817
|
+
async #drainQueues() {
|
|
818
|
+
if (!this.#queueManager)
|
|
819
|
+
return;
|
|
820
|
+
const timeout = Math.min(this.#shutdownTimeout / 2, 15000);
|
|
821
|
+
const startTime = Date.now();
|
|
822
|
+
while (Date.now() - startTime < timeout) {
|
|
823
|
+
try {
|
|
824
|
+
const stats = await this.#queueManager.getQueueStats();
|
|
825
|
+
const totalActive = Object.values(stats).reduce((sum, q) => sum + (q.active || 0), 0);
|
|
826
|
+
if (totalActive === 0)
|
|
827
|
+
break;
|
|
828
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
829
|
+
console.log(`[DigitalTwin] Waiting for ${totalActive} active jobs...`);
|
|
830
|
+
}
|
|
831
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async #closeWorkers(errors) {
|
|
840
|
+
const workerTimeout = Math.min(this.#shutdownTimeout / 3, 10000);
|
|
841
|
+
await Promise.all(this.#workers.map(async (worker) => {
|
|
842
|
+
try {
|
|
843
|
+
await Promise.race([
|
|
844
|
+
worker.close(),
|
|
845
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Worker close timeout')), workerTimeout))
|
|
846
|
+
]);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
try {
|
|
850
|
+
await worker.disconnect();
|
|
851
|
+
}
|
|
852
|
+
catch (disconnectError) {
|
|
853
|
+
errors.push(this.#wrapError('Worker disconnect', disconnectError));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}));
|
|
857
|
+
this.#workers = [];
|
|
858
|
+
}
|
|
859
|
+
#wrapError(context, error) {
|
|
860
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
861
|
+
return new Error(`${context}: ${message}`);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Validate the engine configuration and all components
|
|
865
|
+
*
|
|
866
|
+
* This method checks that all components are properly configured and can be initialized
|
|
867
|
+
* without actually creating tables or starting the server.
|
|
868
|
+
*
|
|
869
|
+
* @returns {Promise<ValidationResult>} Comprehensive validation results
|
|
870
|
+
*
|
|
871
|
+
* @example
|
|
872
|
+
* ```typescript
|
|
873
|
+
* const result = await engine.validateConfiguration()
|
|
874
|
+
* if (!result.valid) {
|
|
875
|
+
* console.error('Validation errors:', result.engineErrors)
|
|
876
|
+
* }
|
|
877
|
+
* ```
|
|
878
|
+
*/
|
|
879
|
+
async validateConfiguration() {
|
|
880
|
+
const componentResults = [];
|
|
881
|
+
const engineErrors = [];
|
|
882
|
+
// Validate collectors (includes dynamically registered)
|
|
883
|
+
for (const collector of this.#allCollectors) {
|
|
884
|
+
componentResults.push(await this.#validateComponent(collector, 'collector'));
|
|
885
|
+
}
|
|
886
|
+
// Validate harvesters (includes dynamically registered)
|
|
887
|
+
for (const harvester of this.#allHarvesters) {
|
|
888
|
+
componentResults.push(await this.#validateComponent(harvester, 'harvester'));
|
|
889
|
+
}
|
|
890
|
+
// Validate handlers (includes dynamically registered)
|
|
891
|
+
for (const handler of this.#allHandlers) {
|
|
892
|
+
componentResults.push(await this.#validateComponent(handler, 'handler'));
|
|
893
|
+
}
|
|
894
|
+
// Validate assets managers (includes dynamically registered)
|
|
895
|
+
for (const assetsManager of this.#allAssetsManagers) {
|
|
896
|
+
componentResults.push(await this.#validateComponent(assetsManager, 'assets_manager'));
|
|
897
|
+
}
|
|
898
|
+
// Validate custom table managers (includes dynamically registered)
|
|
899
|
+
for (const customTableManager of this.#allCustomTableManagers) {
|
|
900
|
+
componentResults.push(await this.#validateComponent(customTableManager, 'custom_table_manager'));
|
|
901
|
+
}
|
|
902
|
+
// Validate engine-level configuration
|
|
903
|
+
try {
|
|
904
|
+
if (!this.#storage) {
|
|
905
|
+
engineErrors.push('Storage service is required');
|
|
906
|
+
}
|
|
907
|
+
if (!this.#database) {
|
|
908
|
+
engineErrors.push('Database adapter is required');
|
|
909
|
+
}
|
|
910
|
+
// Test storage connection
|
|
911
|
+
if (this.#storage && typeof this.#storage.save === 'function') {
|
|
912
|
+
// Storage validation passed
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
engineErrors.push('Storage service does not implement required methods');
|
|
916
|
+
}
|
|
917
|
+
// Test database connection
|
|
918
|
+
if (this.#database && typeof this.#database.save === 'function') {
|
|
919
|
+
// Database validation passed
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
engineErrors.push('Database adapter does not implement required methods');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
engineErrors.push(`Engine configuration error: ${error instanceof Error ? error.message : String(error)}`);
|
|
927
|
+
}
|
|
928
|
+
// Calculate summary
|
|
929
|
+
const validComponents = componentResults.filter(c => c.valid).length;
|
|
930
|
+
const totalWarnings = componentResults.reduce((acc, c) => acc + c.warnings.length, 0);
|
|
931
|
+
const result = {
|
|
932
|
+
valid: componentResults.every(c => c.valid) && engineErrors.length === 0,
|
|
933
|
+
components: componentResults,
|
|
934
|
+
engineErrors,
|
|
935
|
+
summary: {
|
|
936
|
+
total: componentResults.length,
|
|
937
|
+
valid: validComponents,
|
|
938
|
+
invalid: componentResults.length - validComponents,
|
|
939
|
+
warnings: totalWarnings
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Test all components by running their core methods without persistence
|
|
946
|
+
*
|
|
947
|
+
* @returns {Promise<ComponentValidationResult[]>} Test results for each component
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* ```typescript
|
|
951
|
+
* const results = await engine.testComponents()
|
|
952
|
+
* results.forEach(result => {
|
|
953
|
+
* console.log(`${result.name}: ${result.valid ? '✅' : '❌'}`)
|
|
954
|
+
* })
|
|
955
|
+
* ```
|
|
956
|
+
*/
|
|
957
|
+
async testComponents() {
|
|
958
|
+
const results = [];
|
|
959
|
+
// Test collectors (includes dynamically registered)
|
|
960
|
+
for (const collector of this.#allCollectors) {
|
|
961
|
+
const result = await this.#testCollector(collector);
|
|
962
|
+
results.push(result);
|
|
963
|
+
}
|
|
964
|
+
// Test harvesters (includes dynamically registered)
|
|
965
|
+
for (const harvester of this.#allHarvesters) {
|
|
966
|
+
const result = await this.#testHarvester(harvester);
|
|
967
|
+
results.push(result);
|
|
968
|
+
}
|
|
969
|
+
// Test handlers (includes dynamically registered)
|
|
970
|
+
for (const handler of this.#allHandlers) {
|
|
971
|
+
const result = await this.#testHandler(handler);
|
|
972
|
+
results.push(result);
|
|
973
|
+
}
|
|
974
|
+
// Test assets managers (includes dynamically registered)
|
|
975
|
+
for (const assetsManager of this.#allAssetsManagers) {
|
|
976
|
+
const result = await this.#testAssetsManager(assetsManager);
|
|
977
|
+
results.push(result);
|
|
978
|
+
}
|
|
979
|
+
return results;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Validate a single component
|
|
983
|
+
*/
|
|
984
|
+
async #validateComponent(component, type) {
|
|
985
|
+
const errors = [];
|
|
986
|
+
const warnings = [];
|
|
987
|
+
try {
|
|
988
|
+
// Check if component has required methods
|
|
989
|
+
if (typeof component.getConfiguration !== 'function') {
|
|
990
|
+
errors.push('Component must implement getConfiguration() method');
|
|
991
|
+
}
|
|
992
|
+
const config = component.getConfiguration();
|
|
993
|
+
// Validate configuration
|
|
994
|
+
if (!config.name) {
|
|
995
|
+
errors.push('Component configuration must have a name');
|
|
996
|
+
}
|
|
997
|
+
if (!config.description) {
|
|
998
|
+
warnings.push('Component configuration should have a description');
|
|
999
|
+
}
|
|
1000
|
+
// Type-specific validation
|
|
1001
|
+
if (type === 'collector' || type === 'harvester') {
|
|
1002
|
+
const activeComponent = component;
|
|
1003
|
+
if (typeof activeComponent.setDependencies !== 'function') {
|
|
1004
|
+
errors.push('Active components must implement setDependencies() method');
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (type === 'collector') {
|
|
1008
|
+
const collector = component;
|
|
1009
|
+
if (typeof collector.collect !== 'function') {
|
|
1010
|
+
errors.push('Collector must implement collect() method');
|
|
1011
|
+
}
|
|
1012
|
+
if (typeof collector.getSchedule !== 'function') {
|
|
1013
|
+
errors.push('Collector must implement getSchedule() method');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (type === 'harvester') {
|
|
1017
|
+
const harvester = component;
|
|
1018
|
+
if (typeof harvester.harvest !== 'function') {
|
|
1019
|
+
errors.push('Harvester must implement harvest() method');
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (type === 'assets_manager') {
|
|
1023
|
+
const assetsManager = component;
|
|
1024
|
+
if (typeof assetsManager.uploadAsset !== 'function') {
|
|
1025
|
+
errors.push('AssetsManager must implement uploadAsset() method');
|
|
1026
|
+
}
|
|
1027
|
+
if (typeof assetsManager.getAllAssets !== 'function') {
|
|
1028
|
+
errors.push('AssetsManager must implement getAllAssets() method');
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (type === 'custom_table_manager') {
|
|
1032
|
+
const customTableManager = component;
|
|
1033
|
+
if (typeof customTableManager.setDependencies !== 'function') {
|
|
1034
|
+
errors.push('CustomTableManager must implement setDependencies() method');
|
|
1035
|
+
}
|
|
1036
|
+
if (typeof customTableManager.initializeTable !== 'function') {
|
|
1037
|
+
errors.push('CustomTableManager must implement initializeTable() method');
|
|
1038
|
+
}
|
|
1039
|
+
// Validate store configuration
|
|
1040
|
+
const config = customTableManager.getConfiguration();
|
|
1041
|
+
if (typeof config !== 'object' || config === null) {
|
|
1042
|
+
errors.push('CustomTableManager must return a valid configuration object');
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
if (!config.columns || typeof config.columns !== 'object') {
|
|
1046
|
+
errors.push('CustomTableManager configuration must define columns');
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
// Validate columns definition
|
|
1050
|
+
const columnCount = Object.keys(config.columns).length;
|
|
1051
|
+
if (columnCount === 0) {
|
|
1052
|
+
warnings.push('CustomTableManager has no custom columns defined');
|
|
1053
|
+
}
|
|
1054
|
+
// Validate column names and types
|
|
1055
|
+
for (const [columnName, columnType] of Object.entries(config.columns)) {
|
|
1056
|
+
if (!columnName || typeof columnName !== 'string') {
|
|
1057
|
+
errors.push('Column names must be non-empty strings');
|
|
1058
|
+
}
|
|
1059
|
+
if (!columnType || typeof columnType !== 'string') {
|
|
1060
|
+
errors.push(`Column '${columnName}' must have a valid SQL type`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
errors.push(`Validation error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
name: component.getConfiguration?.()?.name || 'unknown',
|
|
1072
|
+
type,
|
|
1073
|
+
valid: errors.length === 0,
|
|
1074
|
+
errors,
|
|
1075
|
+
warnings
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Test a collector by running its collect method
|
|
1080
|
+
*/
|
|
1081
|
+
async #testCollector(collector) {
|
|
1082
|
+
const errors = [];
|
|
1083
|
+
const warnings = [];
|
|
1084
|
+
const config = collector.getConfiguration();
|
|
1085
|
+
try {
|
|
1086
|
+
// Test the collect method
|
|
1087
|
+
const result = await collector.collect();
|
|
1088
|
+
if (!Buffer.isBuffer(result)) {
|
|
1089
|
+
errors.push('collect() method must return a Buffer');
|
|
1090
|
+
}
|
|
1091
|
+
if (result.length === 0) {
|
|
1092
|
+
warnings.push('collect() method returned empty buffer');
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
errors.push(`collect() method failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
name: config.name,
|
|
1100
|
+
type: 'collector',
|
|
1101
|
+
valid: errors.length === 0,
|
|
1102
|
+
errors,
|
|
1103
|
+
warnings
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Test a harvester (more complex as it needs mock data)
|
|
1108
|
+
*/
|
|
1109
|
+
async #testHarvester(harvester) {
|
|
1110
|
+
const errors = [];
|
|
1111
|
+
const warnings = [];
|
|
1112
|
+
const config = harvester.getConfiguration();
|
|
1113
|
+
try {
|
|
1114
|
+
// Create mock data for testing
|
|
1115
|
+
const mockData = {
|
|
1116
|
+
id: 1,
|
|
1117
|
+
name: 'test',
|
|
1118
|
+
date: new Date(),
|
|
1119
|
+
contentType: 'application/json',
|
|
1120
|
+
url: 'test://url',
|
|
1121
|
+
data: async () => Buffer.from('{"test": true}')
|
|
1122
|
+
};
|
|
1123
|
+
// Test the harvest method
|
|
1124
|
+
const result = await harvester.harvest(mockData, {});
|
|
1125
|
+
if (!Buffer.isBuffer(result)) {
|
|
1126
|
+
errors.push('harvest() method must return a Buffer');
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
errors.push(`harvest() method failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
name: config.name,
|
|
1134
|
+
type: 'harvester',
|
|
1135
|
+
valid: errors.length === 0,
|
|
1136
|
+
errors,
|
|
1137
|
+
warnings
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Test a handler
|
|
1142
|
+
*/
|
|
1143
|
+
async #testHandler(handler) {
|
|
1144
|
+
const errors = [];
|
|
1145
|
+
const warnings = [];
|
|
1146
|
+
const config = handler.getConfiguration();
|
|
1147
|
+
try {
|
|
1148
|
+
// Handlers are mostly validated through their endpoint configuration
|
|
1149
|
+
if (typeof handler.getEndpoints === 'function') {
|
|
1150
|
+
const endpoints = handler.getEndpoints();
|
|
1151
|
+
if (!Array.isArray(endpoints)) {
|
|
1152
|
+
errors.push('getEndpoints() must return an array');
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
errors.push(`Handler test failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1158
|
+
}
|
|
1159
|
+
return {
|
|
1160
|
+
name: config.name,
|
|
1161
|
+
type: 'handler',
|
|
1162
|
+
valid: errors.length === 0,
|
|
1163
|
+
errors,
|
|
1164
|
+
warnings
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Test an assets manager
|
|
1169
|
+
*/
|
|
1170
|
+
async #testAssetsManager(assetsManager) {
|
|
1171
|
+
const errors = [];
|
|
1172
|
+
const warnings = [];
|
|
1173
|
+
const config = assetsManager.getConfiguration();
|
|
1174
|
+
try {
|
|
1175
|
+
// Test configuration
|
|
1176
|
+
if (!config.contentType) {
|
|
1177
|
+
errors.push('AssetsManager configuration must have a contentType');
|
|
1178
|
+
}
|
|
1179
|
+
// In dry run mode, we can't test actual upload/download without dependencies
|
|
1180
|
+
// Just validate that the methods exist and are callable
|
|
1181
|
+
if (typeof assetsManager.getEndpoints === 'function') {
|
|
1182
|
+
const endpoints = assetsManager.getEndpoints();
|
|
1183
|
+
if (!Array.isArray(endpoints)) {
|
|
1184
|
+
errors.push('getEndpoints() must return an array');
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
errors.push(`AssetsManager test failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1190
|
+
}
|
|
1191
|
+
return {
|
|
1192
|
+
name: config.name,
|
|
1193
|
+
type: 'assets_manager',
|
|
1194
|
+
valid: errors.length === 0,
|
|
1195
|
+
errors,
|
|
1196
|
+
warnings
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
//# sourceMappingURL=digital_twin_engine.js.map
|