s3db.js 12.4.0 → 13.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.
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Machine Learning Plugin
3
+ *
4
+ * Train and use ML models directly on s3db.js resources
5
+ * Supports regression, classification, time series, and custom neural networks
6
+ */
7
+
8
+ import Plugin from './plugin.class.js';
9
+ import { requirePluginDependency } from './concerns/plugin-dependencies.js';
10
+ import tryFn from '../concerns/try-fn.js';
11
+
12
+ import { RegressionModel } from './ml/regression-model.class.js';
13
+ import { ClassificationModel } from './ml/classification-model.class.js';
14
+ import { TimeSeriesModel } from './ml/timeseries-model.class.js';
15
+ import { NeuralNetworkModel } from './ml/neural-network-model.class.js';
16
+
17
+ import {
18
+ MLError,
19
+ ModelConfigError,
20
+ ModelNotFoundError,
21
+ TrainingError,
22
+ TensorFlowDependencyError
23
+ } from './ml.errors.js';
24
+
25
+ /**
26
+ * ML Plugin Configuration
27
+ *
28
+ * @typedef {Object} MLPluginOptions
29
+ * @property {Object} models - Model configurations
30
+ * @property {boolean} [verbose=false] - Enable verbose logging
31
+ * @property {number} [minTrainingSamples=10] - Minimum samples required for training
32
+ *
33
+ * @example
34
+ * new MLPlugin({
35
+ * models: {
36
+ * productPrices: {
37
+ * type: 'regression',
38
+ * resource: 'products',
39
+ * features: ['cost', 'margin', 'demand'],
40
+ * target: 'price',
41
+ * autoTrain: true,
42
+ * trainInterval: 3600000, // 1 hour
43
+ * trainAfterInserts: 100,
44
+ * modelConfig: {
45
+ * epochs: 50,
46
+ * batchSize: 32,
47
+ * learningRate: 0.01
48
+ * }
49
+ * }
50
+ * },
51
+ * verbose: true
52
+ * })
53
+ */
54
+ export class MLPlugin extends Plugin {
55
+ constructor(options = {}) {
56
+ super(options);
57
+
58
+ this.config = {
59
+ models: options.models || {},
60
+ verbose: options.verbose || false,
61
+ minTrainingSamples: options.minTrainingSamples || 10
62
+ };
63
+
64
+ // Validate TensorFlow.js dependency
65
+ requirePluginDependency('@tensorflow/tfjs-node', 'MLPlugin', {
66
+ installCommand: 'pnpm add @tensorflow/tfjs-node',
67
+ reason: 'Required for machine learning model training and inference'
68
+ });
69
+
70
+ // Model instances
71
+ this.models = {};
72
+
73
+ // Training state
74
+ this.training = new Map(); // Track ongoing training
75
+ this.insertCounters = new Map(); // Track inserts per resource
76
+
77
+ // Interval handles for auto-training
78
+ this.intervals = [];
79
+
80
+ // Stats
81
+ this.stats = {
82
+ totalTrainings: 0,
83
+ totalPredictions: 0,
84
+ totalErrors: 0,
85
+ startedAt: null
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Install the plugin
91
+ */
92
+ async onInstall() {
93
+ if (this.config.verbose) {
94
+ console.log('[MLPlugin] Installing ML Plugin...');
95
+ }
96
+
97
+ // Validate model configurations
98
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
99
+ this._validateModelConfig(modelName, modelConfig);
100
+ }
101
+
102
+ // Initialize models
103
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
104
+ await this._initializeModel(modelName, modelConfig);
105
+ }
106
+
107
+ // Setup auto-training hooks
108
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
109
+ if (modelConfig.autoTrain) {
110
+ this._setupAutoTraining(modelName, modelConfig);
111
+ }
112
+ }
113
+
114
+ this.stats.startedAt = new Date().toISOString();
115
+
116
+ if (this.config.verbose) {
117
+ console.log(`[MLPlugin] Installed with ${Object.keys(this.models).length} models`);
118
+ }
119
+
120
+ this.emit('installed', {
121
+ plugin: 'MLPlugin',
122
+ models: Object.keys(this.models)
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Start the plugin
128
+ */
129
+ async onStart() {
130
+ // Try to load previously trained models
131
+ for (const modelName of Object.keys(this.models)) {
132
+ await this._loadModel(modelName);
133
+ }
134
+
135
+ if (this.config.verbose) {
136
+ console.log('[MLPlugin] Started');
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Stop the plugin
142
+ */
143
+ async onStop() {
144
+ // Stop all intervals
145
+ for (const handle of this.intervals) {
146
+ clearInterval(handle);
147
+ }
148
+ this.intervals = [];
149
+
150
+ // Dispose all models
151
+ for (const [modelName, model] of Object.entries(this.models)) {
152
+ if (model && model.dispose) {
153
+ model.dispose();
154
+ }
155
+ }
156
+
157
+ if (this.config.verbose) {
158
+ console.log('[MLPlugin] Stopped');
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Uninstall the plugin
164
+ */
165
+ async onUninstall(options = {}) {
166
+ await this.onStop();
167
+
168
+ if (options.purgeData) {
169
+ // Delete all saved models from plugin storage
170
+ for (const modelName of Object.keys(this.models)) {
171
+ await this._deleteModel(modelName);
172
+ }
173
+
174
+ if (this.config.verbose) {
175
+ console.log('[MLPlugin] Purged all model data');
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Validate model configuration
182
+ * @private
183
+ */
184
+ _validateModelConfig(modelName, config) {
185
+ const validTypes = ['regression', 'classification', 'timeseries', 'neural-network'];
186
+
187
+ if (!config.type || !validTypes.includes(config.type)) {
188
+ throw new ModelConfigError(
189
+ `Model "${modelName}" must have a valid type: ${validTypes.join(', ')}`,
190
+ { modelName, type: config.type, validTypes }
191
+ );
192
+ }
193
+
194
+ if (!config.resource) {
195
+ throw new ModelConfigError(
196
+ `Model "${modelName}" must specify a resource`,
197
+ { modelName }
198
+ );
199
+ }
200
+
201
+ if (!config.features || !Array.isArray(config.features) || config.features.length === 0) {
202
+ throw new ModelConfigError(
203
+ `Model "${modelName}" must specify at least one feature`,
204
+ { modelName, features: config.features }
205
+ );
206
+ }
207
+
208
+ if (!config.target) {
209
+ throw new ModelConfigError(
210
+ `Model "${modelName}" must specify a target field`,
211
+ { modelName }
212
+ );
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Initialize a model instance
218
+ * @private
219
+ */
220
+ async _initializeModel(modelName, config) {
221
+ const modelOptions = {
222
+ name: modelName,
223
+ resource: config.resource,
224
+ features: config.features,
225
+ target: config.target,
226
+ modelConfig: config.modelConfig || {},
227
+ verbose: this.config.verbose
228
+ };
229
+
230
+ try {
231
+ switch (config.type) {
232
+ case 'regression':
233
+ this.models[modelName] = new RegressionModel(modelOptions);
234
+ break;
235
+
236
+ case 'classification':
237
+ this.models[modelName] = new ClassificationModel(modelOptions);
238
+ break;
239
+
240
+ case 'timeseries':
241
+ this.models[modelName] = new TimeSeriesModel(modelOptions);
242
+ break;
243
+
244
+ case 'neural-network':
245
+ this.models[modelName] = new NeuralNetworkModel(modelOptions);
246
+ break;
247
+
248
+ default:
249
+ throw new ModelConfigError(
250
+ `Unknown model type: ${config.type}`,
251
+ { modelName, type: config.type }
252
+ );
253
+ }
254
+
255
+ if (this.config.verbose) {
256
+ console.log(`[MLPlugin] Initialized model "${modelName}" (${config.type})`);
257
+ }
258
+ } catch (error) {
259
+ console.error(`[MLPlugin] Failed to initialize model "${modelName}":`, error.message);
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Setup auto-training for a model
266
+ * @private
267
+ */
268
+ _setupAutoTraining(modelName, config) {
269
+ const resource = this.database.resources[config.resource];
270
+
271
+ if (!resource) {
272
+ console.warn(`[MLPlugin] Resource "${config.resource}" not found for model "${modelName}"`);
273
+ return;
274
+ }
275
+
276
+ // Initialize insert counter
277
+ this.insertCounters.set(modelName, 0);
278
+
279
+ // Hook: Track inserts
280
+ if (config.trainAfterInserts && config.trainAfterInserts > 0) {
281
+ this.addMiddleware(resource, 'insert', async (next, data, options) => {
282
+ const result = await next(data, options);
283
+
284
+ // Increment counter
285
+ const currentCount = this.insertCounters.get(modelName) || 0;
286
+ this.insertCounters.set(modelName, currentCount + 1);
287
+
288
+ // Check if we should train
289
+ if (this.insertCounters.get(modelName) >= config.trainAfterInserts) {
290
+ if (this.config.verbose) {
291
+ console.log(`[MLPlugin] Auto-training "${modelName}" after ${config.trainAfterInserts} inserts`);
292
+ }
293
+
294
+ // Reset counter
295
+ this.insertCounters.set(modelName, 0);
296
+
297
+ // Train asynchronously (don't block insert)
298
+ this.train(modelName).catch(err => {
299
+ console.error(`[MLPlugin] Auto-training failed for "${modelName}":`, err.message);
300
+ });
301
+ }
302
+
303
+ return result;
304
+ });
305
+ }
306
+
307
+ // Interval-based training
308
+ if (config.trainInterval && config.trainInterval > 0) {
309
+ const handle = setInterval(async () => {
310
+ if (this.config.verbose) {
311
+ console.log(`[MLPlugin] Auto-training "${modelName}" (interval: ${config.trainInterval}ms)`);
312
+ }
313
+
314
+ try {
315
+ await this.train(modelName);
316
+ } catch (error) {
317
+ console.error(`[MLPlugin] Auto-training failed for "${modelName}":`, error.message);
318
+ }
319
+ }, config.trainInterval);
320
+
321
+ this.intervals.push(handle);
322
+
323
+ if (this.config.verbose) {
324
+ console.log(`[MLPlugin] Setup interval training for "${modelName}" (every ${config.trainInterval}ms)`);
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Train a model
331
+ * @param {string} modelName - Model name
332
+ * @param {Object} options - Training options
333
+ * @returns {Object} Training results
334
+ */
335
+ async train(modelName, options = {}) {
336
+ const model = this.models[modelName];
337
+ if (!model) {
338
+ throw new ModelNotFoundError(
339
+ `Model "${modelName}" not found`,
340
+ { modelName, availableModels: Object.keys(this.models) }
341
+ );
342
+ }
343
+
344
+ // Check if already training
345
+ if (this.training.get(modelName)) {
346
+ if (this.config.verbose) {
347
+ console.log(`[MLPlugin] Model "${modelName}" is already training, skipping...`);
348
+ }
349
+ return { skipped: true, reason: 'already_training' };
350
+ }
351
+
352
+ // Mark as training
353
+ this.training.set(modelName, true);
354
+
355
+ try {
356
+ // Get model config
357
+ const modelConfig = this.config.models[modelName];
358
+
359
+ // Get resource
360
+ const resource = this.database.resources[modelConfig.resource];
361
+ if (!resource) {
362
+ throw new ModelNotFoundError(
363
+ `Resource "${modelConfig.resource}" not found`,
364
+ { modelName, resource: modelConfig.resource }
365
+ );
366
+ }
367
+
368
+ // Fetch training data
369
+ if (this.config.verbose) {
370
+ console.log(`[MLPlugin] Fetching training data for "${modelName}"...`);
371
+ }
372
+
373
+ const [ok, err, data] = await tryFn(() => resource.list());
374
+
375
+ if (!ok) {
376
+ throw new TrainingError(
377
+ `Failed to fetch training data: ${err.message}`,
378
+ { modelName, resource: modelConfig.resource, originalError: err.message }
379
+ );
380
+ }
381
+
382
+ if (!data || data.length < this.config.minTrainingSamples) {
383
+ throw new TrainingError(
384
+ `Insufficient training data: ${data?.length || 0} samples (minimum: ${this.config.minTrainingSamples})`,
385
+ { modelName, samples: data?.length || 0, minimum: this.config.minTrainingSamples }
386
+ );
387
+ }
388
+
389
+ if (this.config.verbose) {
390
+ console.log(`[MLPlugin] Training "${modelName}" with ${data.length} samples...`);
391
+ }
392
+
393
+ // Train model
394
+ const result = await model.train(data);
395
+
396
+ // Save model to plugin storage
397
+ await this._saveModel(modelName);
398
+
399
+ this.stats.totalTrainings++;
400
+
401
+ if (this.config.verbose) {
402
+ console.log(`[MLPlugin] Training completed for "${modelName}":`, result);
403
+ }
404
+
405
+ this.emit('modelTrained', {
406
+ modelName,
407
+ type: modelConfig.type,
408
+ result
409
+ });
410
+
411
+ return result;
412
+ } catch (error) {
413
+ this.stats.totalErrors++;
414
+
415
+ if (error instanceof MLError) {
416
+ throw error;
417
+ }
418
+
419
+ throw new TrainingError(
420
+ `Training failed for "${modelName}": ${error.message}`,
421
+ { modelName, originalError: error.message }
422
+ );
423
+ } finally {
424
+ this.training.set(modelName, false);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Make a prediction
430
+ * @param {string} modelName - Model name
431
+ * @param {Object|Array} input - Input data (object for single prediction, array for time series)
432
+ * @returns {Object} Prediction result
433
+ */
434
+ async predict(modelName, input) {
435
+ const model = this.models[modelName];
436
+ if (!model) {
437
+ throw new ModelNotFoundError(
438
+ `Model "${modelName}" not found`,
439
+ { modelName, availableModels: Object.keys(this.models) }
440
+ );
441
+ }
442
+
443
+ try {
444
+ const result = await model.predict(input);
445
+ this.stats.totalPredictions++;
446
+
447
+ this.emit('prediction', {
448
+ modelName,
449
+ input,
450
+ result
451
+ });
452
+
453
+ return result;
454
+ } catch (error) {
455
+ this.stats.totalErrors++;
456
+ throw error;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Make predictions for multiple inputs
462
+ * @param {string} modelName - Model name
463
+ * @param {Array} inputs - Array of input objects
464
+ * @returns {Array} Array of prediction results
465
+ */
466
+ async predictBatch(modelName, inputs) {
467
+ const model = this.models[modelName];
468
+ if (!model) {
469
+ throw new ModelNotFoundError(
470
+ `Model "${modelName}" not found`,
471
+ { modelName, availableModels: Object.keys(this.models) }
472
+ );
473
+ }
474
+
475
+ return await model.predictBatch(inputs);
476
+ }
477
+
478
+ /**
479
+ * Retrain a model (reset and train from scratch)
480
+ * @param {string} modelName - Model name
481
+ * @param {Object} options - Options
482
+ * @returns {Object} Training results
483
+ */
484
+ async retrain(modelName, options = {}) {
485
+ const model = this.models[modelName];
486
+ if (!model) {
487
+ throw new ModelNotFoundError(
488
+ `Model "${modelName}" not found`,
489
+ { modelName, availableModels: Object.keys(this.models) }
490
+ );
491
+ }
492
+
493
+ // Dispose current model
494
+ if (model.dispose) {
495
+ model.dispose();
496
+ }
497
+
498
+ // Re-initialize
499
+ const modelConfig = this.config.models[modelName];
500
+ await this._initializeModel(modelName, modelConfig);
501
+
502
+ // Train
503
+ return await this.train(modelName, options);
504
+ }
505
+
506
+ /**
507
+ * Get model statistics
508
+ * @param {string} modelName - Model name
509
+ * @returns {Object} Model stats
510
+ */
511
+ getModelStats(modelName) {
512
+ const model = this.models[modelName];
513
+ if (!model) {
514
+ throw new ModelNotFoundError(
515
+ `Model "${modelName}" not found`,
516
+ { modelName, availableModels: Object.keys(this.models) }
517
+ );
518
+ }
519
+
520
+ return model.getStats();
521
+ }
522
+
523
+ /**
524
+ * Get plugin statistics
525
+ * @returns {Object} Plugin stats
526
+ */
527
+ getStats() {
528
+ return {
529
+ ...this.stats,
530
+ models: Object.keys(this.models).length,
531
+ trainedModels: Object.values(this.models).filter(m => m.isTrained).length
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Export a model
537
+ * @param {string} modelName - Model name
538
+ * @returns {Object} Serialized model
539
+ */
540
+ async exportModel(modelName) {
541
+ const model = this.models[modelName];
542
+ if (!model) {
543
+ throw new ModelNotFoundError(
544
+ `Model "${modelName}" not found`,
545
+ { modelName, availableModels: Object.keys(this.models) }
546
+ );
547
+ }
548
+
549
+ return await model.export();
550
+ }
551
+
552
+ /**
553
+ * Import a model
554
+ * @param {string} modelName - Model name
555
+ * @param {Object} data - Serialized model data
556
+ */
557
+ async importModel(modelName, data) {
558
+ const model = this.models[modelName];
559
+ if (!model) {
560
+ throw new ModelNotFoundError(
561
+ `Model "${modelName}" not found`,
562
+ { modelName, availableModels: Object.keys(this.models) }
563
+ );
564
+ }
565
+
566
+ await model.import(data);
567
+
568
+ // Save to plugin storage
569
+ await this._saveModel(modelName);
570
+
571
+ if (this.config.verbose) {
572
+ console.log(`[MLPlugin] Imported model "${modelName}"`);
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Save model to plugin storage
578
+ * @private
579
+ */
580
+ async _saveModel(modelName) {
581
+ try {
582
+ const storage = this.getStorage();
583
+ const exportedModel = await this.models[modelName].export();
584
+
585
+ if (!exportedModel) {
586
+ if (this.config.verbose) {
587
+ console.log(`[MLPlugin] Model "${modelName}" not trained, skipping save`);
588
+ }
589
+ return;
590
+ }
591
+
592
+ // Use patch() for faster metadata-only updates (enforce-limits behavior)
593
+ await storage.patch(`model_${modelName}`, {
594
+ modelName,
595
+ data: JSON.stringify(exportedModel),
596
+ savedAt: new Date().toISOString()
597
+ });
598
+
599
+ if (this.config.verbose) {
600
+ console.log(`[MLPlugin] Saved model "${modelName}" to plugin storage`);
601
+ }
602
+ } catch (error) {
603
+ console.error(`[MLPlugin] Failed to save model "${modelName}":`, error.message);
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Load model from plugin storage
609
+ * @private
610
+ */
611
+ async _loadModel(modelName) {
612
+ try {
613
+ const storage = this.getStorage();
614
+ const [ok, err, record] = await tryFn(() => storage.get(`model_${modelName}`));
615
+
616
+ if (!ok || !record) {
617
+ if (this.config.verbose) {
618
+ console.log(`[MLPlugin] No saved model found for "${modelName}"`);
619
+ }
620
+ return;
621
+ }
622
+
623
+ const modelData = JSON.parse(record.data);
624
+ await this.models[modelName].import(modelData);
625
+
626
+ if (this.config.verbose) {
627
+ console.log(`[MLPlugin] Loaded model "${modelName}" from plugin storage`);
628
+ }
629
+ } catch (error) {
630
+ console.error(`[MLPlugin] Failed to load model "${modelName}":`, error.message);
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Delete model from plugin storage
636
+ * @private
637
+ */
638
+ async _deleteModel(modelName) {
639
+ try {
640
+ const storage = this.getStorage();
641
+ await storage.delete(`model_${modelName}`);
642
+
643
+ if (this.config.verbose) {
644
+ console.log(`[MLPlugin] Deleted model "${modelName}" from plugin storage`);
645
+ }
646
+ } catch (error) {
647
+ // Ignore errors (model might not exist)
648
+ if (this.config.verbose) {
649
+ console.log(`[MLPlugin] Could not delete model "${modelName}": ${error.message}`);
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ export default MLPlugin;