s3db.js 13.4.0 → 13.5.1
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/dist/s3db.cjs.js +12167 -11177
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +12168 -11178
- package/dist/s3db.es.js.map +1 -1
- package/package.json +3 -3
- package/src/database.class.js +2 -2
- package/src/plugins/api/auth/basic-auth.js +17 -9
- package/src/plugins/api/index.js +23 -19
- package/src/plugins/api/routes/auth-routes.js +100 -79
- package/src/plugins/api/routes/resource-routes.js +3 -2
- package/src/plugins/api/server.js +176 -5
- package/src/plugins/api/utils/custom-routes.js +102 -0
- package/src/plugins/api/utils/openapi-generator.js +52 -6
- package/src/plugins/concerns/plugin-dependencies.js +1 -1
- package/src/plugins/eventual-consistency/consolidation.js +2 -2
- package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
- package/src/plugins/eventual-consistency/install.js +2 -2
- package/src/plugins/ml/base-model.class.js +33 -9
- package/src/plugins/ml.plugin.js +474 -13
- package/src/plugins/state-machine.plugin.js +57 -2
package/src/plugins/ml.plugin.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Plugin } from './plugin.class.js';
|
|
9
|
+
import { Resource } from '../resource.class.js';
|
|
9
10
|
import { requirePluginDependency } from './concerns/plugin-dependencies.js';
|
|
10
11
|
import tryFn from '../concerns/try-fn.js';
|
|
11
12
|
|
|
@@ -71,12 +72,12 @@ export class MLPlugin extends Plugin {
|
|
|
71
72
|
enableVersioning: options.enableVersioning !== false // Default true
|
|
72
73
|
};
|
|
73
74
|
|
|
74
|
-
// Validate TensorFlow.js dependency
|
|
75
|
-
requirePluginDependency('ml-plugin');
|
|
76
|
-
|
|
77
75
|
// Model instances
|
|
78
76
|
this.models = {};
|
|
79
77
|
|
|
78
|
+
// Dependency validation flag (lazy validation)
|
|
79
|
+
this._dependenciesValidated = false;
|
|
80
|
+
|
|
80
81
|
// Model versioning
|
|
81
82
|
this.modelVersions = new Map(); // Track versions per model: { currentVersion, latestVersion }
|
|
82
83
|
|
|
@@ -107,6 +108,33 @@ export class MLPlugin extends Plugin {
|
|
|
107
108
|
console.log('[MLPlugin] Installing ML Plugin...');
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
// Validate plugin dependencies (lazy validation)
|
|
112
|
+
if (!this._dependenciesValidated) {
|
|
113
|
+
const result = await requirePluginDependency('ml-plugin', {
|
|
114
|
+
throwOnError: false,
|
|
115
|
+
checkVersions: true
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!result.valid) {
|
|
119
|
+
// In test environments with Jest VM modules, dynamic imports may fail
|
|
120
|
+
// even when packages are installed. Try direct import as fallback.
|
|
121
|
+
try {
|
|
122
|
+
await import('@tensorflow/tfjs-node');
|
|
123
|
+
if (this.config.verbose) {
|
|
124
|
+
console.log('[MLPlugin] TensorFlow.js loaded successfully (fallback import)');
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// If both methods fail, throw the original error
|
|
128
|
+
throw new TensorFlowDependencyError(
|
|
129
|
+
'TensorFlow.js dependency not found. Install with: pnpm add @tensorflow/tfjs-node\n' +
|
|
130
|
+
result.messages.join('\n')
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this._dependenciesValidated = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
110
138
|
// Validate model configurations
|
|
111
139
|
for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
|
|
112
140
|
this._validateModelConfig(modelName, modelConfig);
|
|
@@ -231,10 +259,166 @@ export class MLPlugin extends Plugin {
|
|
|
231
259
|
this.database._mlPlugin = this;
|
|
232
260
|
}
|
|
233
261
|
|
|
262
|
+
// Create namespace "ml" on Resource prototype
|
|
263
|
+
if (!Object.prototype.hasOwnProperty.call(Resource.prototype, 'ml')) {
|
|
264
|
+
Object.defineProperty(Resource.prototype, 'ml', {
|
|
265
|
+
get() {
|
|
266
|
+
const resource = this;
|
|
267
|
+
const mlPlugin = resource.database?._mlPlugin;
|
|
268
|
+
|
|
269
|
+
if (!mlPlugin) {
|
|
270
|
+
throw new Error('MLPlugin not installed');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
/**
|
|
275
|
+
* Auto-setup and train ML model (zero-config)
|
|
276
|
+
* @param {string} target - Target attribute to predict
|
|
277
|
+
* @param {Object} options - Configuration options
|
|
278
|
+
* @returns {Promise<Object>} Training results
|
|
279
|
+
*/
|
|
280
|
+
learn: async (target, options = {}) => {
|
|
281
|
+
return await mlPlugin._resourceLearn(resource.name, target, options);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Make prediction
|
|
286
|
+
* @param {Object} input - Input features
|
|
287
|
+
* @param {string} target - Target attribute
|
|
288
|
+
* @returns {Promise<Object>} Prediction result
|
|
289
|
+
*/
|
|
290
|
+
predict: async (input, target) => {
|
|
291
|
+
return await mlPlugin._resourcePredict(resource.name, input, target);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Train model manually
|
|
296
|
+
* @param {string} target - Target attribute
|
|
297
|
+
* @param {Object} options - Training options
|
|
298
|
+
* @returns {Promise<Object>} Training results
|
|
299
|
+
*/
|
|
300
|
+
train: async (target, options = {}) => {
|
|
301
|
+
return await mlPlugin._resourceTrainModel(resource.name, target, options);
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* List all models for this resource
|
|
306
|
+
* @returns {Array} List of models
|
|
307
|
+
*/
|
|
308
|
+
list: () => {
|
|
309
|
+
return mlPlugin._resourceListModels(resource.name);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* List model versions
|
|
314
|
+
* @param {string} target - Target attribute
|
|
315
|
+
* @returns {Promise<Array>} List of versions
|
|
316
|
+
*/
|
|
317
|
+
versions: async (target) => {
|
|
318
|
+
const modelName = mlPlugin._findModelForResource(resource.name, target);
|
|
319
|
+
if (!modelName) {
|
|
320
|
+
throw new ModelNotFoundError(
|
|
321
|
+
`No model found for resource "${resource.name}" with target "${target}"`,
|
|
322
|
+
{ resourceName: resource.name, targetAttribute: target }
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return await mlPlugin.listModelVersions(modelName);
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Rollback to previous version
|
|
330
|
+
* @param {string} target - Target attribute
|
|
331
|
+
* @param {number} version - Version to rollback to (optional)
|
|
332
|
+
* @returns {Promise<Object>} Rollback info
|
|
333
|
+
*/
|
|
334
|
+
rollback: async (target, version = null) => {
|
|
335
|
+
const modelName = mlPlugin._findModelForResource(resource.name, target);
|
|
336
|
+
if (!modelName) {
|
|
337
|
+
throw new ModelNotFoundError(
|
|
338
|
+
`No model found for resource "${resource.name}" with target "${target}"`,
|
|
339
|
+
{ resourceName: resource.name, targetAttribute: target }
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return await mlPlugin.rollbackVersion(modelName, version);
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Compare two versions
|
|
347
|
+
* @param {string} target - Target attribute
|
|
348
|
+
* @param {number} v1 - First version
|
|
349
|
+
* @param {number} v2 - Second version
|
|
350
|
+
* @returns {Promise<Object>} Comparison results
|
|
351
|
+
*/
|
|
352
|
+
compare: async (target, v1, v2) => {
|
|
353
|
+
const modelName = mlPlugin._findModelForResource(resource.name, target);
|
|
354
|
+
if (!modelName) {
|
|
355
|
+
throw new ModelNotFoundError(
|
|
356
|
+
`No model found for resource "${resource.name}" with target "${target}"`,
|
|
357
|
+
{ resourceName: resource.name, targetAttribute: target }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return await mlPlugin.compareVersions(modelName, v1, v2);
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Get model statistics
|
|
365
|
+
* @param {string} target - Target attribute
|
|
366
|
+
* @returns {Object} Model stats
|
|
367
|
+
*/
|
|
368
|
+
stats: (target) => {
|
|
369
|
+
const modelName = mlPlugin._findModelForResource(resource.name, target);
|
|
370
|
+
if (!modelName) {
|
|
371
|
+
throw new ModelNotFoundError(
|
|
372
|
+
`No model found for resource "${resource.name}" with target "${target}"`,
|
|
373
|
+
{ resourceName: resource.name, targetAttribute: target }
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return mlPlugin.getModelStats(modelName);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Export model
|
|
381
|
+
* @param {string} target - Target attribute
|
|
382
|
+
* @returns {Promise<Object>} Exported model
|
|
383
|
+
*/
|
|
384
|
+
export: async (target) => {
|
|
385
|
+
const modelName = mlPlugin._findModelForResource(resource.name, target);
|
|
386
|
+
if (!modelName) {
|
|
387
|
+
throw new ModelNotFoundError(
|
|
388
|
+
`No model found for resource "${resource.name}" with target "${target}"`,
|
|
389
|
+
{ resourceName: resource.name, targetAttribute: target }
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return await mlPlugin.exportModel(modelName);
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Import model
|
|
397
|
+
* @param {string} target - Target attribute
|
|
398
|
+
* @param {Object} data - Model data
|
|
399
|
+
* @returns {Promise<void>}
|
|
400
|
+
*/
|
|
401
|
+
import: async (target, data) => {
|
|
402
|
+
const modelName = mlPlugin._findModelForResource(resource.name, target);
|
|
403
|
+
if (!modelName) {
|
|
404
|
+
throw new ModelNotFoundError(
|
|
405
|
+
`No model found for resource "${resource.name}" with target "${target}"`,
|
|
406
|
+
{ resourceName: resource.name, targetAttribute: target }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return await mlPlugin.importModel(modelName, data);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
configurable: true
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Keep legacy methods for backward compatibility
|
|
234
418
|
// Add predict() method to Resource prototype
|
|
235
|
-
if (!
|
|
236
|
-
|
|
237
|
-
const mlPlugin = this.database
|
|
419
|
+
if (!Object.prototype.hasOwnProperty.call(Resource.prototype, 'predict')) {
|
|
420
|
+
Resource.prototype.predict = async function(input, targetAttribute) {
|
|
421
|
+
const mlPlugin = this.database?._mlPlugin;
|
|
238
422
|
if (!mlPlugin) {
|
|
239
423
|
throw new Error('MLPlugin not installed');
|
|
240
424
|
}
|
|
@@ -244,9 +428,9 @@ export class MLPlugin extends Plugin {
|
|
|
244
428
|
}
|
|
245
429
|
|
|
246
430
|
// Add trainModel() method to Resource prototype
|
|
247
|
-
if (!
|
|
248
|
-
|
|
249
|
-
const mlPlugin = this.database
|
|
431
|
+
if (!Object.prototype.hasOwnProperty.call(Resource.prototype, 'trainModel')) {
|
|
432
|
+
Resource.prototype.trainModel = async function(targetAttribute, options = {}) {
|
|
433
|
+
const mlPlugin = this.database?._mlPlugin;
|
|
250
434
|
if (!mlPlugin) {
|
|
251
435
|
throw new Error('MLPlugin not installed');
|
|
252
436
|
}
|
|
@@ -256,9 +440,9 @@ export class MLPlugin extends Plugin {
|
|
|
256
440
|
}
|
|
257
441
|
|
|
258
442
|
// Add listModels() method to Resource prototype
|
|
259
|
-
if (!
|
|
260
|
-
|
|
261
|
-
const mlPlugin = this.database
|
|
443
|
+
if (!Object.prototype.hasOwnProperty.call(Resource.prototype, 'listModels')) {
|
|
444
|
+
Resource.prototype.listModels = function() {
|
|
445
|
+
const mlPlugin = this.database?._mlPlugin;
|
|
262
446
|
if (!mlPlugin) {
|
|
263
447
|
throw new Error('MLPlugin not installed');
|
|
264
448
|
}
|
|
@@ -268,7 +452,7 @@ export class MLPlugin extends Plugin {
|
|
|
268
452
|
}
|
|
269
453
|
|
|
270
454
|
if (this.config.verbose) {
|
|
271
|
-
console.log('[MLPlugin] Injected ML
|
|
455
|
+
console.log('[MLPlugin] Injected ML namespace (resource.ml.*) into Resource prototype');
|
|
272
456
|
}
|
|
273
457
|
}
|
|
274
458
|
|
|
@@ -296,6 +480,283 @@ export class MLPlugin extends Plugin {
|
|
|
296
480
|
return null;
|
|
297
481
|
}
|
|
298
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Auto-setup and train ML model (resource.ml.learn implementation)
|
|
485
|
+
* @param {string} resourceName - Resource name
|
|
486
|
+
* @param {string} target - Target attribute to predict
|
|
487
|
+
* @param {Object} options - Configuration options
|
|
488
|
+
* @returns {Promise<Object>} Training results
|
|
489
|
+
* @private
|
|
490
|
+
*/
|
|
491
|
+
async _resourceLearn(resourceName, target, options = {}) {
|
|
492
|
+
// Check if model already exists
|
|
493
|
+
let modelName = this._findModelForResource(resourceName, target);
|
|
494
|
+
|
|
495
|
+
if (modelName) {
|
|
496
|
+
// Model exists, just retrain
|
|
497
|
+
if (this.config.verbose) {
|
|
498
|
+
console.log(`[MLPlugin] Model "${modelName}" already exists, retraining...`);
|
|
499
|
+
}
|
|
500
|
+
return await this.train(modelName, options);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Create new model dynamically
|
|
504
|
+
modelName = `${resourceName}_${target}_auto`;
|
|
505
|
+
|
|
506
|
+
if (this.config.verbose) {
|
|
507
|
+
console.log(`[MLPlugin] Auto-creating model "${modelName}" for ${resourceName}.${target}...`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Get resource
|
|
511
|
+
const resource = this.database.resources[resourceName];
|
|
512
|
+
if (!resource) {
|
|
513
|
+
throw new ModelConfigError(
|
|
514
|
+
`Resource "${resourceName}" not found`,
|
|
515
|
+
{ resourceName, availableResources: Object.keys(this.database.resources) }
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Auto-detect type if not specified
|
|
520
|
+
let modelType = options.type;
|
|
521
|
+
if (!modelType) {
|
|
522
|
+
modelType = await this._autoDetectType(resourceName, target);
|
|
523
|
+
if (this.config.verbose) {
|
|
524
|
+
console.log(`[MLPlugin] Auto-detected type: ${modelType}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Auto-select features if not specified
|
|
529
|
+
let features = options.features;
|
|
530
|
+
if (!features || features.length === 0) {
|
|
531
|
+
features = await this._autoSelectFeatures(resourceName, target);
|
|
532
|
+
if (this.config.verbose) {
|
|
533
|
+
console.log(`[MLPlugin] Auto-selected features: ${features.join(', ')}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Get sample count to adjust batchSize automatically
|
|
538
|
+
const [samplesOk, samplesErr, sampleData] = await tryFn(() => resource.list());
|
|
539
|
+
const sampleCount = (samplesOk && sampleData) ? sampleData.length : 0;
|
|
540
|
+
|
|
541
|
+
// Get default model config and adjust batchSize based on available data
|
|
542
|
+
let defaultModelConfig = this._getDefaultModelConfig(modelType);
|
|
543
|
+
|
|
544
|
+
// Check if user explicitly provided batchSize
|
|
545
|
+
const userProvidedBatchSize = options.modelConfig && options.modelConfig.batchSize !== undefined;
|
|
546
|
+
|
|
547
|
+
if (!userProvidedBatchSize && sampleCount > 0 && sampleCount < defaultModelConfig.batchSize) {
|
|
548
|
+
// Adjust batchSize to be at most half of available samples (only if user didn't provide one)
|
|
549
|
+
defaultModelConfig.batchSize = Math.max(4, Math.floor(sampleCount / 2));
|
|
550
|
+
if (this.config.verbose) {
|
|
551
|
+
console.log(`[MLPlugin] Auto-adjusted batchSize to ${defaultModelConfig.batchSize} based on ${sampleCount} samples`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Merge custom modelConfig with defaults
|
|
556
|
+
// If user didn't provide batchSize, keep the auto-adjusted one from defaultModelConfig
|
|
557
|
+
const customModelConfig = options.modelConfig || {};
|
|
558
|
+
const mergedModelConfig = {
|
|
559
|
+
...defaultModelConfig,
|
|
560
|
+
...customModelConfig,
|
|
561
|
+
// Preserve auto-adjusted batchSize if user didn't provide one
|
|
562
|
+
...(!userProvidedBatchSize && { batchSize: defaultModelConfig.batchSize })
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// Create model config
|
|
566
|
+
const modelConfig = {
|
|
567
|
+
type: modelType,
|
|
568
|
+
resource: resourceName,
|
|
569
|
+
features: features,
|
|
570
|
+
target: target,
|
|
571
|
+
autoTrain: options.autoTrain !== undefined ? options.autoTrain : false,
|
|
572
|
+
saveModel: options.saveModel !== undefined ? options.saveModel : true,
|
|
573
|
+
saveTrainingData: options.saveTrainingData !== undefined ? options.saveTrainingData : false,
|
|
574
|
+
modelConfig: mergedModelConfig,
|
|
575
|
+
...options
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// Register model
|
|
579
|
+
this.config.models[modelName] = modelConfig;
|
|
580
|
+
|
|
581
|
+
// Initialize model
|
|
582
|
+
await this._initializeModel(modelName, modelConfig);
|
|
583
|
+
|
|
584
|
+
// Update cache
|
|
585
|
+
this._buildModelCache();
|
|
586
|
+
|
|
587
|
+
// Train immediately
|
|
588
|
+
if (this.config.verbose) {
|
|
589
|
+
console.log(`[MLPlugin] Training model "${modelName}"...`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const result = await this.train(modelName, options);
|
|
593
|
+
|
|
594
|
+
if (this.config.verbose) {
|
|
595
|
+
console.log(`[MLPlugin] ✅ Model "${modelName}" ready!`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
modelName,
|
|
600
|
+
type: modelType,
|
|
601
|
+
features,
|
|
602
|
+
target,
|
|
603
|
+
...result
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Auto-detect model type based on target attribute
|
|
609
|
+
* @param {string} resourceName - Resource name
|
|
610
|
+
* @param {string} target - Target attribute
|
|
611
|
+
* @returns {Promise<string>} Model type
|
|
612
|
+
* @private
|
|
613
|
+
*/
|
|
614
|
+
async _autoDetectType(resourceName, target) {
|
|
615
|
+
const resource = this.database.resources[resourceName];
|
|
616
|
+
|
|
617
|
+
// Get some sample data
|
|
618
|
+
const [ok, err, samples] = await tryFn(() => resource.list({ limit: 100 }));
|
|
619
|
+
|
|
620
|
+
if (!ok || !samples || samples.length === 0) {
|
|
621
|
+
// Default to regression if no data
|
|
622
|
+
return 'regression';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Analyze target values
|
|
626
|
+
const targetValues = samples.map(s => s[target]).filter(v => v != null);
|
|
627
|
+
|
|
628
|
+
if (targetValues.length === 0) {
|
|
629
|
+
return 'regression';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Check if numeric
|
|
633
|
+
const isNumeric = targetValues.every(v => typeof v === 'number');
|
|
634
|
+
|
|
635
|
+
if (isNumeric) {
|
|
636
|
+
// Check for time series (if data has timestamp)
|
|
637
|
+
const hasTimestamp = samples.every(s => s.timestamp || s.createdAt || s.date);
|
|
638
|
+
if (hasTimestamp) {
|
|
639
|
+
return 'timeseries';
|
|
640
|
+
}
|
|
641
|
+
return 'regression';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Check if categorical (strings/booleans)
|
|
645
|
+
const isCategorical = targetValues.every(v => typeof v === 'string' || typeof v === 'boolean');
|
|
646
|
+
|
|
647
|
+
if (isCategorical) {
|
|
648
|
+
return 'classification';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Default
|
|
652
|
+
return 'regression';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Auto-select best features for prediction
|
|
657
|
+
* @param {string} resourceName - Resource name
|
|
658
|
+
* @param {string} target - Target attribute
|
|
659
|
+
* @returns {Promise<Array>} Selected features
|
|
660
|
+
* @private
|
|
661
|
+
*/
|
|
662
|
+
async _autoSelectFeatures(resourceName, target) {
|
|
663
|
+
const resource = this.database.resources[resourceName];
|
|
664
|
+
|
|
665
|
+
// Get all numeric attributes from schema
|
|
666
|
+
const schema = resource.schema;
|
|
667
|
+
const attributes = schema?.attributes || {};
|
|
668
|
+
|
|
669
|
+
const numericFields = [];
|
|
670
|
+
|
|
671
|
+
for (const [fieldName, fieldDef] of Object.entries(attributes)) {
|
|
672
|
+
// Skip target
|
|
673
|
+
if (fieldName === target) continue;
|
|
674
|
+
|
|
675
|
+
// Skip system fields
|
|
676
|
+
if (['id', 'createdAt', 'updatedAt', 'createdBy'].includes(fieldName)) continue;
|
|
677
|
+
|
|
678
|
+
// Check if numeric type
|
|
679
|
+
const fieldType = typeof fieldDef === 'string' ? fieldDef.split('|')[0] : fieldDef.type;
|
|
680
|
+
|
|
681
|
+
if (fieldType === 'number' || fieldType === 'integer' || fieldType === 'float') {
|
|
682
|
+
numericFields.push(fieldName);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// If no numeric fields found, try to detect from data
|
|
687
|
+
if (numericFields.length === 0) {
|
|
688
|
+
const [ok, err, samples] = await tryFn(() => resource.list({ limit: 10 }));
|
|
689
|
+
|
|
690
|
+
if (ok && samples && samples.length > 0) {
|
|
691
|
+
const firstSample = samples[0];
|
|
692
|
+
|
|
693
|
+
for (const [key, value] of Object.entries(firstSample)) {
|
|
694
|
+
if (key === target) continue;
|
|
695
|
+
if (['id', 'createdAt', 'updatedAt', 'createdBy'].includes(key)) continue;
|
|
696
|
+
|
|
697
|
+
if (typeof value === 'number') {
|
|
698
|
+
numericFields.push(key);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (numericFields.length === 0) {
|
|
705
|
+
throw new ModelConfigError(
|
|
706
|
+
`No numeric features found for target "${target}" in resource "${resourceName}"`,
|
|
707
|
+
{ resourceName, target, availableAttributes: Object.keys(attributes) }
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return numericFields;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Get default model config for type
|
|
716
|
+
* @param {string} type - Model type
|
|
717
|
+
* @returns {Object} Default config
|
|
718
|
+
* @private
|
|
719
|
+
*/
|
|
720
|
+
_getDefaultModelConfig(type) {
|
|
721
|
+
const defaults = {
|
|
722
|
+
regression: {
|
|
723
|
+
epochs: 50,
|
|
724
|
+
batchSize: 32,
|
|
725
|
+
learningRate: 0.01,
|
|
726
|
+
validationSplit: 0.2,
|
|
727
|
+
polynomial: 1
|
|
728
|
+
},
|
|
729
|
+
classification: {
|
|
730
|
+
epochs: 50,
|
|
731
|
+
batchSize: 32,
|
|
732
|
+
learningRate: 0.01,
|
|
733
|
+
validationSplit: 0.2,
|
|
734
|
+
units: 64,
|
|
735
|
+
dropout: 0.2
|
|
736
|
+
},
|
|
737
|
+
timeseries: {
|
|
738
|
+
epochs: 50,
|
|
739
|
+
batchSize: 16,
|
|
740
|
+
learningRate: 0.001,
|
|
741
|
+
validationSplit: 0.2,
|
|
742
|
+
lookback: 10,
|
|
743
|
+
lstmUnits: 50
|
|
744
|
+
},
|
|
745
|
+
'neural-network': {
|
|
746
|
+
epochs: 50,
|
|
747
|
+
batchSize: 32,
|
|
748
|
+
learningRate: 0.01,
|
|
749
|
+
validationSplit: 0.2,
|
|
750
|
+
layers: [
|
|
751
|
+
{ units: 64, activation: 'relu', dropout: 0.2 },
|
|
752
|
+
{ units: 32, activation: 'relu' }
|
|
753
|
+
]
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
return defaults[type] || defaults.regression;
|
|
758
|
+
}
|
|
759
|
+
|
|
299
760
|
/**
|
|
300
761
|
* Resource predict implementation
|
|
301
762
|
* @private
|
|
@@ -133,10 +133,46 @@ export class StateMachinePlugin extends Plugin {
|
|
|
133
133
|
this.machines = new Map();
|
|
134
134
|
this.triggerIntervals = [];
|
|
135
135
|
this.schedulerPlugin = null;
|
|
136
|
+
this._pendingEventHandlers = new Set();
|
|
136
137
|
|
|
137
138
|
this._validateConfiguration();
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Wait for all pending event handlers to complete
|
|
143
|
+
* Useful when working with async events (asyncEvents: true)
|
|
144
|
+
* @param {number} timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async waitForPendingEvents(timeout = 5000) {
|
|
148
|
+
if (this._pendingEventHandlers.size === 0) {
|
|
149
|
+
return; // No pending events
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const startTime = Date.now();
|
|
153
|
+
|
|
154
|
+
while (this._pendingEventHandlers.size > 0) {
|
|
155
|
+
if (Date.now() - startTime > timeout) {
|
|
156
|
+
throw new StateMachineError(
|
|
157
|
+
`Timeout waiting for ${this._pendingEventHandlers.size} pending event handlers`,
|
|
158
|
+
{
|
|
159
|
+
operation: 'waitForPendingEvents',
|
|
160
|
+
pendingCount: this._pendingEventHandlers.size,
|
|
161
|
+
timeout
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Wait for at least one handler to complete
|
|
167
|
+
if (this._pendingEventHandlers.size > 0) {
|
|
168
|
+
await Promise.race(Array.from(this._pendingEventHandlers));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Small delay before checking again
|
|
172
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
_validateConfiguration() {
|
|
141
177
|
if (!this.config.stateMachines || Object.keys(this.config.stateMachines).length === 0) {
|
|
142
178
|
throw new StateMachineError('At least one state machine must be defined', {
|
|
@@ -1331,10 +1367,29 @@ export class StateMachinePlugin extends Plugin {
|
|
|
1331
1367
|
// Resource events are typically: inserted, updated, deleted
|
|
1332
1368
|
const baseEvent = typeof baseEventName === 'function' ? 'updated' : baseEventName;
|
|
1333
1369
|
|
|
1334
|
-
|
|
1370
|
+
// IMPORTANT: For resources with async events, we need to ensure the event handler
|
|
1371
|
+
// completes before returning control. We wrap the handler to track pending operations.
|
|
1372
|
+
const wrappedHandler = async (...args) => {
|
|
1373
|
+
// Track this as a pending operation
|
|
1374
|
+
const handlerPromise = eventHandler(...args);
|
|
1375
|
+
|
|
1376
|
+
// Store promise if state machine has event tracking
|
|
1377
|
+
if (!this._pendingEventHandlers) {
|
|
1378
|
+
this._pendingEventHandlers = new Set();
|
|
1379
|
+
}
|
|
1380
|
+
this._pendingEventHandlers.add(handlerPromise);
|
|
1381
|
+
|
|
1382
|
+
try {
|
|
1383
|
+
await handlerPromise;
|
|
1384
|
+
} finally {
|
|
1385
|
+
this._pendingEventHandlers.delete(handlerPromise);
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
eventSource.on(baseEvent, wrappedHandler);
|
|
1335
1390
|
|
|
1336
1391
|
if (this.config.verbose) {
|
|
1337
|
-
console.log(`[StateMachinePlugin] Listening to resource event '${baseEvent}' from '${eventSource.name}' for trigger '${triggerName}'`);
|
|
1392
|
+
console.log(`[StateMachinePlugin] Listening to resource event '${baseEvent}' from '${eventSource.name}' for trigger '${triggerName}' (async-safe)`);
|
|
1338
1393
|
}
|
|
1339
1394
|
} else {
|
|
1340
1395
|
// Original behavior: listen to database or plugin events
|