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.
@@ -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 (!this.database.Resource.prototype.predict) {
236
- this.database.Resource.prototype.predict = async function(input, targetAttribute) {
237
- const mlPlugin = this.database._mlPlugin;
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 (!this.database.Resource.prototype.trainModel) {
248
- this.database.Resource.prototype.trainModel = async function(targetAttribute, options = {}) {
249
- const mlPlugin = this.database._mlPlugin;
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 (!this.database.Resource.prototype.listModels) {
260
- this.database.Resource.prototype.listModels = function() {
261
- const mlPlugin = this.database._mlPlugin;
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 methods into Resource prototype');
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
- eventSource.on(baseEvent, eventHandler);
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