s3db.js 13.0.0 → 13.2.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.
Files changed (37) hide show
  1. package/README.md +9 -9
  2. package/dist/s3db.cjs.js +3637 -191
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.es.js +3637 -191
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +2 -1
  7. package/src/clients/memory-client.class.js +16 -16
  8. package/src/clients/s3-client.class.js +17 -17
  9. package/src/concerns/error-classifier.js +204 -0
  10. package/src/database.class.js +9 -9
  11. package/src/plugins/api/index.js +1 -7
  12. package/src/plugins/api/routes/resource-routes.js +3 -3
  13. package/src/plugins/api/server.js +29 -9
  14. package/src/plugins/audit.plugin.js +2 -4
  15. package/src/plugins/backup.plugin.js +10 -12
  16. package/src/plugins/cache.plugin.js +4 -6
  17. package/src/plugins/concerns/plugin-dependencies.js +12 -0
  18. package/src/plugins/costs.plugin.js +0 -2
  19. package/src/plugins/eventual-consistency/index.js +1 -3
  20. package/src/plugins/fulltext.plugin.js +2 -4
  21. package/src/plugins/geo.plugin.js +3 -5
  22. package/src/plugins/importer/index.js +0 -2
  23. package/src/plugins/index.js +0 -1
  24. package/src/plugins/metrics.plugin.js +2 -4
  25. package/src/plugins/ml.plugin.js +1004 -42
  26. package/src/plugins/plugin.class.js +1 -3
  27. package/src/plugins/queue-consumer.plugin.js +1 -3
  28. package/src/plugins/relation.plugin.js +2 -4
  29. package/src/plugins/replicator.plugin.js +18 -20
  30. package/src/plugins/s3-queue.plugin.js +6 -8
  31. package/src/plugins/scheduler.plugin.js +9 -11
  32. package/src/plugins/state-machine.errors.js +9 -1
  33. package/src/plugins/state-machine.plugin.js +605 -20
  34. package/src/plugins/tfstate/index.js +0 -2
  35. package/src/plugins/ttl.plugin.js +40 -25
  36. package/src/plugins/vector.plugin.js +10 -12
  37. package/src/resource.class.js +58 -40
@@ -5,7 +5,7 @@
5
5
  * Supports regression, classification, time series, and custom neural networks
6
6
  */
7
7
 
8
- import Plugin from './plugin.class.js';
8
+ import { Plugin } from './plugin.class.js';
9
9
  import { requirePluginDependency } from './concerns/plugin-dependencies.js';
10
10
  import tryFn from '../concerns/try-fn.js';
11
11
 
@@ -29,6 +29,8 @@ import {
29
29
  * @property {Object} models - Model configurations
30
30
  * @property {boolean} [verbose=false] - Enable verbose logging
31
31
  * @property {number} [minTrainingSamples=10] - Minimum samples required for training
32
+ * @property {boolean} [saveModel=true] - Save trained models to S3
33
+ * @property {boolean} [saveTrainingData=false] - Save intermediate training data to S3
32
34
  *
33
35
  * @example
34
36
  * new MLPlugin({
@@ -38,9 +40,12 @@ import {
38
40
  * resource: 'products',
39
41
  * features: ['cost', 'margin', 'demand'],
40
42
  * target: 'price',
43
+ * partition: { name: 'byCategory', values: { category: 'electronics' } }, // Optional
41
44
  * autoTrain: true,
42
45
  * trainInterval: 3600000, // 1 hour
43
46
  * trainAfterInserts: 100,
47
+ * saveModel: true, // Save to S3 after training
48
+ * saveTrainingData: true, // Save prepared dataset
44
49
  * modelConfig: {
45
50
  * epochs: 50,
46
51
  * batchSize: 32,
@@ -48,7 +53,9 @@ import {
48
53
  * }
49
54
  * }
50
55
  * },
51
- * verbose: true
56
+ * verbose: true,
57
+ * saveModel: true,
58
+ * saveTrainingData: false
52
59
  * })
53
60
  */
54
61
  export class MLPlugin extends Plugin {
@@ -58,18 +65,24 @@ export class MLPlugin extends Plugin {
58
65
  this.config = {
59
66
  models: options.models || {},
60
67
  verbose: options.verbose || false,
61
- minTrainingSamples: options.minTrainingSamples || 10
68
+ minTrainingSamples: options.minTrainingSamples || 10,
69
+ saveModel: options.saveModel !== false, // Default true
70
+ saveTrainingData: options.saveTrainingData || false,
71
+ enableVersioning: options.enableVersioning !== false // Default true
62
72
  };
63
73
 
64
74
  // 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
- });
75
+ requirePluginDependency('ml-plugin');
69
76
 
70
77
  // Model instances
71
78
  this.models = {};
72
79
 
80
+ // Model versioning
81
+ this.modelVersions = new Map(); // Track versions per model: { currentVersion, latestVersion }
82
+
83
+ // Model cache for resource.predict()
84
+ this.modelCache = new Map(); // Cache: resourceName_attribute -> modelName
85
+
73
86
  // Training state
74
87
  this.training = new Map(); // Track ongoing training
75
88
  this.insertCounters = new Map(); // Track inserts per resource
@@ -104,6 +117,12 @@ export class MLPlugin extends Plugin {
104
117
  await this._initializeModel(modelName, modelConfig);
105
118
  }
106
119
 
120
+ // Build model cache (resource -> attribute -> modelName mapping)
121
+ this._buildModelCache();
122
+
123
+ // Inject ML methods into Resource prototype
124
+ this._injectResourceMethods();
125
+
107
126
  // Setup auto-training hooks
108
127
  for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
109
128
  if (modelConfig.autoTrain) {
@@ -117,7 +136,7 @@ export class MLPlugin extends Plugin {
117
136
  console.log(`[MLPlugin] Installed with ${Object.keys(this.models).length} models`);
118
137
  }
119
138
 
120
- this.emit('installed', {
139
+ this.emit('db:plugin:installed', {
121
140
  plugin: 'MLPlugin',
122
141
  models: Object.keys(this.models)
123
142
  });
@@ -127,6 +146,13 @@ export class MLPlugin extends Plugin {
127
146
  * Start the plugin
128
147
  */
129
148
  async onStart() {
149
+ // Initialize versioning for each model
150
+ if (this.config.enableVersioning) {
151
+ for (const modelName of Object.keys(this.models)) {
152
+ await this._initializeVersioning(modelName);
153
+ }
154
+ }
155
+
130
156
  // Try to load previously trained models
131
157
  for (const modelName of Object.keys(this.models)) {
132
158
  await this._loadModel(modelName);
@@ -166,15 +192,172 @@ export class MLPlugin extends Plugin {
166
192
  await this.onStop();
167
193
 
168
194
  if (options.purgeData) {
169
- // Delete all saved models from plugin storage
195
+ // Delete all saved models and training data from plugin storage
170
196
  for (const modelName of Object.keys(this.models)) {
171
197
  await this._deleteModel(modelName);
198
+ await this._deleteTrainingData(modelName);
199
+ }
200
+
201
+ if (this.config.verbose) {
202
+ console.log('[MLPlugin] Purged all model data and training data');
172
203
  }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Build model cache for fast lookup
209
+ * @private
210
+ */
211
+ _buildModelCache() {
212
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
213
+ const cacheKey = `${modelConfig.resource}_${modelConfig.target}`;
214
+ this.modelCache.set(cacheKey, modelName);
173
215
 
174
216
  if (this.config.verbose) {
175
- console.log('[MLPlugin] Purged all model data');
217
+ console.log(`[MLPlugin] Cached model "${modelName}" for ${modelConfig.resource}.predict(..., '${modelConfig.target}')`);
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Inject ML methods into Resource instances
224
+ * @private
225
+ */
226
+ _injectResourceMethods() {
227
+ const plugin = this;
228
+
229
+ // Store reference to plugin in database for resource access
230
+ if (!this.database._mlPlugin) {
231
+ this.database._mlPlugin = this;
232
+ }
233
+
234
+ // 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;
238
+ if (!mlPlugin) {
239
+ throw new Error('MLPlugin not installed');
240
+ }
241
+
242
+ return await mlPlugin._resourcePredict(this.name, input, targetAttribute);
243
+ };
244
+ }
245
+
246
+ // 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;
250
+ if (!mlPlugin) {
251
+ throw new Error('MLPlugin not installed');
252
+ }
253
+
254
+ return await mlPlugin._resourceTrainModel(this.name, targetAttribute, options);
255
+ };
256
+ }
257
+
258
+ // 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;
262
+ if (!mlPlugin) {
263
+ throw new Error('MLPlugin not installed');
264
+ }
265
+
266
+ return mlPlugin._resourceListModels(this.name);
267
+ };
268
+ }
269
+
270
+ if (this.config.verbose) {
271
+ console.log('[MLPlugin] Injected ML methods into Resource prototype');
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Find model for a resource and target attribute
277
+ * @private
278
+ */
279
+ _findModelForResource(resourceName, targetAttribute) {
280
+ const cacheKey = `${resourceName}_${targetAttribute}`;
281
+
282
+ // Try cache first
283
+ if (this.modelCache.has(cacheKey)) {
284
+ return this.modelCache.get(cacheKey);
285
+ }
286
+
287
+ // Search through all models
288
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
289
+ if (modelConfig.resource === resourceName && modelConfig.target === targetAttribute) {
290
+ // Cache for next time
291
+ this.modelCache.set(cacheKey, modelName);
292
+ return modelName;
176
293
  }
177
294
  }
295
+
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Resource predict implementation
301
+ * @private
302
+ */
303
+ async _resourcePredict(resourceName, input, targetAttribute) {
304
+ const modelName = this._findModelForResource(resourceName, targetAttribute);
305
+
306
+ if (!modelName) {
307
+ throw new ModelNotFoundError(
308
+ `No model found for resource "${resourceName}" with target "${targetAttribute}"`,
309
+ { resourceName, targetAttribute, availableModels: Object.keys(this.models) }
310
+ );
311
+ }
312
+
313
+ if (this.config.verbose) {
314
+ console.log(`[MLPlugin] Resource prediction: ${resourceName}.predict(..., '${targetAttribute}') -> model "${modelName}"`);
315
+ }
316
+
317
+ return await this.predict(modelName, input);
318
+ }
319
+
320
+ /**
321
+ * Resource trainModel implementation
322
+ * @private
323
+ */
324
+ async _resourceTrainModel(resourceName, targetAttribute, options = {}) {
325
+ const modelName = this._findModelForResource(resourceName, targetAttribute);
326
+
327
+ if (!modelName) {
328
+ throw new ModelNotFoundError(
329
+ `No model found for resource "${resourceName}" with target "${targetAttribute}"`,
330
+ { resourceName, targetAttribute, availableModels: Object.keys(this.models) }
331
+ );
332
+ }
333
+
334
+ if (this.config.verbose) {
335
+ console.log(`[MLPlugin] Resource training: ${resourceName}.trainModel('${targetAttribute}') -> model "${modelName}"`);
336
+ }
337
+
338
+ return await this.train(modelName, options);
339
+ }
340
+
341
+ /**
342
+ * List models for a resource
343
+ * @private
344
+ */
345
+ _resourceListModels(resourceName) {
346
+ const models = [];
347
+
348
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
349
+ if (modelConfig.resource === resourceName) {
350
+ models.push({
351
+ name: modelName,
352
+ type: modelConfig.type,
353
+ target: modelConfig.target,
354
+ features: modelConfig.features,
355
+ isTrained: this.models[modelName]?.isTrained || false
356
+ });
357
+ }
358
+ }
359
+
360
+ return models;
178
361
  }
179
362
 
180
363
  /**
@@ -365,18 +548,67 @@ export class MLPlugin extends Plugin {
365
548
  );
366
549
  }
367
550
 
368
- // Fetch training data
551
+ // Fetch training data (with optional partition filtering)
369
552
  if (this.config.verbose) {
370
553
  console.log(`[MLPlugin] Fetching training data for "${modelName}"...`);
371
554
  }
372
555
 
373
- const [ok, err, data] = await tryFn(() => resource.list());
556
+ let data;
557
+ const partition = modelConfig.partition;
374
558
 
375
- if (!ok) {
376
- throw new TrainingError(
377
- `Failed to fetch training data: ${err.message}`,
378
- { modelName, resource: modelConfig.resource, originalError: err.message }
559
+ if (partition && partition.name) {
560
+ // Use partition filtering
561
+ if (this.config.verbose) {
562
+ console.log(`[MLPlugin] Using partition "${partition.name}" with values:`, partition.values);
563
+ }
564
+
565
+ const [ok, err, partitionData] = await tryFn(() =>
566
+ resource.listPartition(partition.name, partition.values)
379
567
  );
568
+
569
+ if (!ok) {
570
+ throw new TrainingError(
571
+ `Failed to fetch training data from partition: ${err.message}`,
572
+ { modelName, resource: modelConfig.resource, partition: partition.name, originalError: err.message }
573
+ );
574
+ }
575
+
576
+ data = partitionData;
577
+ } else {
578
+ // Fetch all data
579
+ const [ok, err, allData] = await tryFn(() => resource.list());
580
+
581
+ if (!ok) {
582
+ throw new TrainingError(
583
+ `Failed to fetch training data: ${err.message}`,
584
+ { modelName, resource: modelConfig.resource, originalError: err.message }
585
+ );
586
+ }
587
+
588
+ data = allData;
589
+ }
590
+
591
+ // Apply custom filter function if provided
592
+ if (modelConfig.filter && typeof modelConfig.filter === 'function') {
593
+ if (this.config.verbose) {
594
+ console.log(`[MLPlugin] Applying custom filter function...`);
595
+ }
596
+
597
+ const originalLength = data.length;
598
+ data = data.filter(modelConfig.filter);
599
+
600
+ if (this.config.verbose) {
601
+ console.log(`[MLPlugin] Filter reduced dataset from ${originalLength} to ${data.length} samples`);
602
+ }
603
+ }
604
+
605
+ // Apply custom map function if provided
606
+ if (modelConfig.map && typeof modelConfig.map === 'function') {
607
+ if (this.config.verbose) {
608
+ console.log(`[MLPlugin] Applying custom map function...`);
609
+ }
610
+
611
+ data = data.map(modelConfig.map);
380
612
  }
381
613
 
382
614
  if (!data || data.length < this.config.minTrainingSamples) {
@@ -390,11 +622,26 @@ export class MLPlugin extends Plugin {
390
622
  console.log(`[MLPlugin] Training "${modelName}" with ${data.length} samples...`);
391
623
  }
392
624
 
625
+ // Save intermediate training data if enabled
626
+ const shouldSaveTrainingData = modelConfig.saveTrainingData !== undefined
627
+ ? modelConfig.saveTrainingData
628
+ : this.config.saveTrainingData;
629
+
630
+ if (shouldSaveTrainingData) {
631
+ await this._saveTrainingData(modelName, data);
632
+ }
633
+
393
634
  // Train model
394
635
  const result = await model.train(data);
395
636
 
396
- // Save model to plugin storage
397
- await this._saveModel(modelName);
637
+ // Save model to plugin storage if enabled
638
+ const shouldSaveModel = modelConfig.saveModel !== undefined
639
+ ? modelConfig.saveModel
640
+ : this.config.saveModel;
641
+
642
+ if (shouldSaveModel) {
643
+ await this._saveModel(modelName);
644
+ }
398
645
 
399
646
  this.stats.totalTrainings++;
400
647
 
@@ -402,7 +649,7 @@ export class MLPlugin extends Plugin {
402
649
  console.log(`[MLPlugin] Training completed for "${modelName}":`, result);
403
650
  }
404
651
 
405
- this.emit('modelTrained', {
652
+ this.emit('plg:ml:model-trained', {
406
653
  modelName,
407
654
  type: modelConfig.type,
408
655
  result
@@ -444,7 +691,7 @@ export class MLPlugin extends Plugin {
444
691
  const result = await model.predict(input);
445
692
  this.stats.totalPredictions++;
446
693
 
447
- this.emit('prediction', {
694
+ this.emit('plg:ml:prediction', {
448
695
  modelName,
449
696
  input,
450
697
  result
@@ -573,6 +820,91 @@ export class MLPlugin extends Plugin {
573
820
  }
574
821
  }
575
822
 
823
+ /**
824
+ * Initialize versioning for a model
825
+ * @private
826
+ */
827
+ async _initializeVersioning(modelName) {
828
+ try {
829
+ const storage = this.getStorage();
830
+ const modelConfig = this.config.models[modelName];
831
+ const resourceName = modelConfig.resource;
832
+ const [ok, err, versionInfo] = await tryFn(() =>
833
+ storage.get(storage.getPluginKey(resourceName, 'metadata', modelName, 'versions'))
834
+ );
835
+
836
+ if (ok && versionInfo) {
837
+ // Load existing version info
838
+ this.modelVersions.set(modelName, {
839
+ currentVersion: versionInfo.currentVersion || 1,
840
+ latestVersion: versionInfo.latestVersion || 1
841
+ });
842
+
843
+ if (this.config.verbose) {
844
+ console.log(`[MLPlugin] Loaded version info for "${modelName}": v${versionInfo.currentVersion}`);
845
+ }
846
+ } else {
847
+ // Initialize new versioning
848
+ this.modelVersions.set(modelName, {
849
+ currentVersion: 1,
850
+ latestVersion: 0 // No versions yet
851
+ });
852
+
853
+ if (this.config.verbose) {
854
+ console.log(`[MLPlugin] Initialized versioning for "${modelName}"`);
855
+ }
856
+ }
857
+ } catch (error) {
858
+ console.error(`[MLPlugin] Failed to initialize versioning for "${modelName}":`, error.message);
859
+ // Fallback to v1
860
+ this.modelVersions.set(modelName, { currentVersion: 1, latestVersion: 0 });
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Get next version number for a model
866
+ * @private
867
+ */
868
+ _getNextVersion(modelName) {
869
+ const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
870
+ return versionInfo.latestVersion + 1;
871
+ }
872
+
873
+ /**
874
+ * Update version info in storage
875
+ * @private
876
+ */
877
+ async _updateVersionInfo(modelName, version) {
878
+ try {
879
+ const storage = this.getStorage();
880
+ const modelConfig = this.config.models[modelName];
881
+ const resourceName = modelConfig.resource;
882
+ const versionInfo = this.modelVersions.get(modelName) || { currentVersion: 1, latestVersion: 0 };
883
+
884
+ versionInfo.latestVersion = Math.max(versionInfo.latestVersion, version);
885
+ versionInfo.currentVersion = version; // Set new version as current
886
+
887
+ this.modelVersions.set(modelName, versionInfo);
888
+
889
+ await storage.set(
890
+ storage.getPluginKey(resourceName, 'metadata', modelName, 'versions'),
891
+ {
892
+ modelName,
893
+ currentVersion: versionInfo.currentVersion,
894
+ latestVersion: versionInfo.latestVersion,
895
+ updatedAt: new Date().toISOString()
896
+ },
897
+ { behavior: 'body-overflow' }
898
+ );
899
+
900
+ if (this.config.verbose) {
901
+ console.log(`[MLPlugin] Updated version info for "${modelName}": current=v${versionInfo.currentVersion}, latest=v${versionInfo.latestVersion}`);
902
+ }
903
+ } catch (error) {
904
+ console.error(`[MLPlugin] Failed to update version info for "${modelName}":`, error.message);
905
+ }
906
+ }
907
+
576
908
  /**
577
909
  * Save model to plugin storage
578
910
  * @private
@@ -580,6 +912,8 @@ export class MLPlugin extends Plugin {
580
912
  async _saveModel(modelName) {
581
913
  try {
582
914
  const storage = this.getStorage();
915
+ const modelConfig = this.config.models[modelName];
916
+ const resourceName = modelConfig.resource;
583
917
  const exportedModel = await this.models[modelName].export();
584
918
 
585
919
  if (!exportedModel) {
@@ -589,21 +923,198 @@ export class MLPlugin extends Plugin {
589
923
  return;
590
924
  }
591
925
 
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
- });
926
+ const enableVersioning = this.config.enableVersioning;
927
+
928
+ if (enableVersioning) {
929
+ // Save with version
930
+ const version = this._getNextVersion(modelName);
931
+ const modelStats = this.models[modelName].getStats();
932
+
933
+ // Save versioned model binary to S3 body
934
+ await storage.set(
935
+ storage.getPluginKey(resourceName, 'models', modelName, `v${version}`),
936
+ {
937
+ modelName,
938
+ version,
939
+ type: 'model',
940
+ modelData: exportedModel, // TensorFlow.js model object (will go to body)
941
+ metrics: {
942
+ loss: modelStats.loss,
943
+ accuracy: modelStats.accuracy,
944
+ samples: modelStats.samples
945
+ },
946
+ savedAt: new Date().toISOString()
947
+ },
948
+ { behavior: 'body-only' } // Large binary data goes to S3 body
949
+ );
598
950
 
599
- if (this.config.verbose) {
600
- console.log(`[MLPlugin] Saved model "${modelName}" to plugin storage`);
951
+ // Update version info
952
+ await this._updateVersionInfo(modelName, version);
953
+
954
+ // Save active reference (points to current version)
955
+ await storage.set(
956
+ storage.getPluginKey(resourceName, 'metadata', modelName, 'active'),
957
+ {
958
+ modelName,
959
+ version,
960
+ type: 'reference',
961
+ updatedAt: new Date().toISOString()
962
+ },
963
+ { behavior: 'body-overflow' } // Small metadata
964
+ );
965
+
966
+ if (this.config.verbose) {
967
+ console.log(`[MLPlugin] Saved model "${modelName}" v${version} to S3 (resource=${resourceName}/plugin=ml/models/${modelName}/v${version})`);
968
+ }
969
+ } else {
970
+ // Save without versioning (legacy behavior)
971
+ await storage.set(
972
+ storage.getPluginKey(resourceName, 'models', modelName, 'latest'),
973
+ {
974
+ modelName,
975
+ type: 'model',
976
+ modelData: exportedModel,
977
+ savedAt: new Date().toISOString()
978
+ },
979
+ { behavior: 'body-only' }
980
+ );
981
+
982
+ if (this.config.verbose) {
983
+ console.log(`[MLPlugin] Saved model "${modelName}" to S3 (resource=${resourceName}/plugin=ml/models/${modelName}/latest)`);
984
+ }
601
985
  }
602
986
  } catch (error) {
603
987
  console.error(`[MLPlugin] Failed to save model "${modelName}":`, error.message);
604
988
  }
605
989
  }
606
990
 
991
+ /**
992
+ * Save intermediate training data to plugin storage (incremental - only new samples)
993
+ * @private
994
+ */
995
+ async _saveTrainingData(modelName, rawData) {
996
+ try {
997
+ const storage = this.getStorage();
998
+ const model = this.models[modelName];
999
+ const modelConfig = this.config.models[modelName];
1000
+ const resourceName = modelConfig.resource;
1001
+ const modelStats = model.getStats();
1002
+ const enableVersioning = this.config.enableVersioning;
1003
+
1004
+ // Extract features and target from raw data
1005
+ const processedData = rawData.map(item => {
1006
+ const features = {};
1007
+ modelConfig.features.forEach(feature => {
1008
+ features[feature] = item[feature];
1009
+ });
1010
+ return {
1011
+ id: item.id || `${Date.now()}_${Math.random()}`, // Use record ID or generate
1012
+ features,
1013
+ target: item[modelConfig.target]
1014
+ };
1015
+ });
1016
+
1017
+ if (enableVersioning) {
1018
+ const version = this._getNextVersion(modelName);
1019
+
1020
+ // Load existing history to calculate incremental data
1021
+ const [ok, err, existing] = await tryFn(() =>
1022
+ storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName))
1023
+ );
1024
+
1025
+ let history = [];
1026
+ let previousSampleIds = new Set();
1027
+
1028
+ if (ok && existing && existing.history) {
1029
+ history = existing.history;
1030
+ // Collect all IDs from previous versions
1031
+ history.forEach(entry => {
1032
+ if (entry.sampleIds) {
1033
+ entry.sampleIds.forEach(id => previousSampleIds.add(id));
1034
+ }
1035
+ });
1036
+ }
1037
+
1038
+ // Detect new samples (not in previous versions)
1039
+ const currentSampleIds = new Set(processedData.map(d => d.id));
1040
+ const newSamples = processedData.filter(d => !previousSampleIds.has(d.id));
1041
+ const newSampleIds = newSamples.map(d => d.id);
1042
+
1043
+ // Save only NEW samples to S3 body (incremental)
1044
+ if (newSamples.length > 0) {
1045
+ await storage.set(
1046
+ storage.getPluginKey(resourceName, 'training', 'data', modelName, `v${version}`),
1047
+ {
1048
+ modelName,
1049
+ version,
1050
+ samples: newSamples, // Only new samples
1051
+ features: modelConfig.features,
1052
+ target: modelConfig.target,
1053
+ savedAt: new Date().toISOString()
1054
+ },
1055
+ { behavior: 'body-only' } // Dataset goes to S3 body
1056
+ );
1057
+ }
1058
+
1059
+ // Append metadata to history (no full dataset duplication)
1060
+ const historyEntry = {
1061
+ version,
1062
+ totalSamples: processedData.length, // Total cumulative
1063
+ newSamples: newSamples.length, // Only new in this version
1064
+ sampleIds: Array.from(currentSampleIds), // All IDs for this version
1065
+ newSampleIds, // IDs of new samples
1066
+ storageKey: newSamples.length > 0 ? `training/data/${modelName}/v${version}` : null,
1067
+ metrics: {
1068
+ loss: modelStats.loss,
1069
+ accuracy: modelStats.accuracy,
1070
+ r2: modelStats.r2
1071
+ },
1072
+ trainedAt: new Date().toISOString()
1073
+ };
1074
+
1075
+ history.push(historyEntry);
1076
+
1077
+ // Save updated history (metadata only, no full datasets)
1078
+ await storage.set(
1079
+ storage.getPluginKey(resourceName, 'training', 'history', modelName),
1080
+ {
1081
+ modelName,
1082
+ type: 'training_history',
1083
+ totalTrainings: history.length,
1084
+ latestVersion: version,
1085
+ history, // Array of metadata entries (not full data)
1086
+ updatedAt: new Date().toISOString()
1087
+ },
1088
+ { behavior: 'body-overflow' } // History metadata
1089
+ );
1090
+
1091
+ if (this.config.verbose) {
1092
+ console.log(`[MLPlugin] Saved training data for "${modelName}" v${version}: ${newSamples.length} new samples (total: ${processedData.length}, storage: resource=${resourceName}/plugin=ml/training/data/${modelName}/v${version})`);
1093
+ }
1094
+ } else {
1095
+ // Legacy: Replace training data (non-incremental)
1096
+ await storage.set(
1097
+ storage.getPluginKey(resourceName, 'training', 'data', modelName, 'latest'),
1098
+ {
1099
+ modelName,
1100
+ type: 'training_data',
1101
+ samples: processedData,
1102
+ features: modelConfig.features,
1103
+ target: modelConfig.target,
1104
+ savedAt: new Date().toISOString()
1105
+ },
1106
+ { behavior: 'body-only' }
1107
+ );
1108
+
1109
+ if (this.config.verbose) {
1110
+ console.log(`[MLPlugin] Saved training data for "${modelName}" (${processedData.length} samples) to S3 (resource=${resourceName}/plugin=ml/training/data/${modelName}/latest)`);
1111
+ }
1112
+ }
1113
+ } catch (error) {
1114
+ console.error(`[MLPlugin] Failed to save training data for "${modelName}":`, error.message);
1115
+ }
1116
+ }
1117
+
607
1118
  /**
608
1119
  * Load model from plugin storage
609
1120
  * @private
@@ -611,20 +1122,72 @@ export class MLPlugin extends Plugin {
611
1122
  async _loadModel(modelName) {
612
1123
  try {
613
1124
  const storage = this.getStorage();
614
- const [ok, err, record] = await tryFn(() => storage.get(`model_${modelName}`));
1125
+ const modelConfig = this.config.models[modelName];
1126
+ const resourceName = modelConfig.resource;
1127
+ const enableVersioning = this.config.enableVersioning;
1128
+
1129
+ if (enableVersioning) {
1130
+ // Load active version reference
1131
+ const [okRef, errRef, activeRef] = await tryFn(() =>
1132
+ storage.get(storage.getPluginKey(resourceName, 'metadata', modelName, 'active'))
1133
+ );
1134
+
1135
+ if (okRef && activeRef && activeRef.version) {
1136
+ // Load the active version
1137
+ const version = activeRef.version;
1138
+ const [ok, err, versionData] = await tryFn(() =>
1139
+ storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version}`))
1140
+ );
1141
+
1142
+ if (ok && versionData && versionData.modelData) {
1143
+ await this.models[modelName].import(versionData.modelData);
1144
+
1145
+ if (this.config.verbose) {
1146
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (active) from S3 (resource=${resourceName}/plugin=ml/models/${modelName}/v${version})`);
1147
+ }
1148
+ return;
1149
+ }
1150
+ }
1151
+
1152
+ // No active reference, try to load latest version
1153
+ const versionInfo = this.modelVersions.get(modelName);
1154
+ if (versionInfo && versionInfo.latestVersion > 0) {
1155
+ const version = versionInfo.latestVersion;
1156
+ const [ok, err, versionData] = await tryFn(() =>
1157
+ storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version}`))
1158
+ );
1159
+
1160
+ if (ok && versionData && versionData.modelData) {
1161
+ await this.models[modelName].import(versionData.modelData);
1162
+
1163
+ if (this.config.verbose) {
1164
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (latest) from S3`);
1165
+ }
1166
+ return;
1167
+ }
1168
+ }
615
1169
 
616
- if (!ok || !record) {
617
1170
  if (this.config.verbose) {
618
- console.log(`[MLPlugin] No saved model found for "${modelName}"`);
1171
+ console.log(`[MLPlugin] No saved model versions found for "${modelName}"`);
619
1172
  }
620
- return;
621
- }
1173
+ } else {
1174
+ // Legacy: Load non-versioned model
1175
+ const [ok, err, record] = await tryFn(() =>
1176
+ storage.get(storage.getPluginKey(resourceName, 'models', modelName, 'latest'))
1177
+ );
622
1178
 
623
- const modelData = JSON.parse(record.data);
624
- await this.models[modelName].import(modelData);
1179
+ if (!ok || !record || !record.modelData) {
1180
+ if (this.config.verbose) {
1181
+ console.log(`[MLPlugin] No saved model found for "${modelName}"`);
1182
+ }
1183
+ return;
1184
+ }
625
1185
 
626
- if (this.config.verbose) {
627
- console.log(`[MLPlugin] Loaded model "${modelName}" from plugin storage`);
1186
+ await this.models[modelName].import(record.modelData);
1187
+
1188
+ if (this.config.verbose) {
1189
+ console.log(`[MLPlugin] Loaded model "${modelName}" from S3 (resource=${resourceName}/plugin=ml/models/${modelName}/latest)`);
1190
+ }
628
1191
  }
629
1192
  } catch (error) {
630
1193
  console.error(`[MLPlugin] Failed to load model "${modelName}":`, error.message);
@@ -632,16 +1195,119 @@ export class MLPlugin extends Plugin {
632
1195
  }
633
1196
 
634
1197
  /**
635
- * Delete model from plugin storage
1198
+ * Load training data from plugin storage (reconstructs specific version from incremental data)
1199
+ * @param {string} modelName - Model name
1200
+ * @param {number} version - Version number (optional, defaults to latest)
1201
+ * @returns {Object|null} Training data or null if not found
1202
+ */
1203
+ async getTrainingData(modelName, version = null) {
1204
+ try {
1205
+ const storage = this.getStorage();
1206
+ const modelConfig = this.config.models[modelName];
1207
+ const resourceName = modelConfig.resource;
1208
+ const enableVersioning = this.config.enableVersioning;
1209
+
1210
+ if (!enableVersioning) {
1211
+ // Legacy: Load non-versioned training data
1212
+ const [ok, err, record] = await tryFn(() =>
1213
+ storage.get(storage.getPluginKey(resourceName, 'training', 'data', modelName, 'latest'))
1214
+ );
1215
+
1216
+ if (!ok || !record) {
1217
+ if (this.config.verbose) {
1218
+ console.log(`[MLPlugin] No saved training data found for "${modelName}"`);
1219
+ }
1220
+ return null;
1221
+ }
1222
+
1223
+ return {
1224
+ modelName: record.modelName,
1225
+ samples: record.samples,
1226
+ features: record.features,
1227
+ target: record.target,
1228
+ data: record.samples,
1229
+ savedAt: record.savedAt
1230
+ };
1231
+ }
1232
+
1233
+ // Versioned: Reconstruct dataset from incremental versions
1234
+ const [okHistory, errHistory, historyData] = await tryFn(() =>
1235
+ storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName))
1236
+ );
1237
+
1238
+ if (!okHistory || !historyData || !historyData.history) {
1239
+ if (this.config.verbose) {
1240
+ console.log(`[MLPlugin] No training history found for "${modelName}"`);
1241
+ }
1242
+ return null;
1243
+ }
1244
+
1245
+ const targetVersion = version || historyData.latestVersion;
1246
+ const reconstructedSamples = [];
1247
+
1248
+ // Load and combine all versions up to target version
1249
+ for (const entry of historyData.history) {
1250
+ if (entry.version > targetVersion) break;
1251
+
1252
+ if (entry.storageKey && entry.newSamples > 0) {
1253
+ const [ok, err, versionData] = await tryFn(() =>
1254
+ storage.get(storage.getPluginKey(resourceName, 'training', 'data', modelName, `v${entry.version}`))
1255
+ );
1256
+
1257
+ if (ok && versionData && versionData.samples) {
1258
+ reconstructedSamples.push(...versionData.samples);
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ const targetEntry = historyData.history.find(e => e.version === targetVersion);
1264
+
1265
+ return {
1266
+ modelName,
1267
+ version: targetVersion,
1268
+ samples: reconstructedSamples,
1269
+ totalSamples: reconstructedSamples.length,
1270
+ features: modelConfig.features,
1271
+ target: modelConfig.target,
1272
+ metrics: targetEntry?.metrics,
1273
+ savedAt: targetEntry?.trainedAt
1274
+ };
1275
+ } catch (error) {
1276
+ console.error(`[MLPlugin] Failed to load training data for "${modelName}":`, error.message);
1277
+ return null;
1278
+ }
1279
+ }
1280
+
1281
+ /**
1282
+ * Delete model from plugin storage (all versions)
636
1283
  * @private
637
1284
  */
638
1285
  async _deleteModel(modelName) {
639
1286
  try {
640
1287
  const storage = this.getStorage();
641
- await storage.delete(`model_${modelName}`);
1288
+ const modelConfig = this.config.models[modelName];
1289
+ const resourceName = modelConfig.resource;
1290
+ const enableVersioning = this.config.enableVersioning;
1291
+
1292
+ if (enableVersioning) {
1293
+ // Delete all versions
1294
+ const versionInfo = this.modelVersions.get(modelName);
1295
+ if (versionInfo && versionInfo.latestVersion > 0) {
1296
+ for (let v = 1; v <= versionInfo.latestVersion; v++) {
1297
+ await storage.delete(storage.getPluginKey(resourceName, 'models', modelName, `v${v}`));
1298
+ }
1299
+ }
1300
+
1301
+ // Delete metadata
1302
+ await storage.delete(storage.getPluginKey(resourceName, 'metadata', modelName, 'active'));
1303
+ await storage.delete(storage.getPluginKey(resourceName, 'metadata', modelName, 'versions'));
1304
+ } else {
1305
+ // Delete non-versioned model
1306
+ await storage.delete(storage.getPluginKey(resourceName, 'models', modelName, 'latest'));
1307
+ }
642
1308
 
643
1309
  if (this.config.verbose) {
644
- console.log(`[MLPlugin] Deleted model "${modelName}" from plugin storage`);
1310
+ console.log(`[MLPlugin] Deleted model "${modelName}" from S3 (resource=${resourceName}/plugin=ml/models/${modelName}/)`);
645
1311
  }
646
1312
  } catch (error) {
647
1313
  // Ignore errors (model might not exist)
@@ -650,6 +1316,302 @@ export class MLPlugin extends Plugin {
650
1316
  }
651
1317
  }
652
1318
  }
653
- }
654
1319
 
655
- export default MLPlugin;
1320
+ /**
1321
+ * Delete training data from plugin storage (all versions)
1322
+ * @private
1323
+ */
1324
+ async _deleteTrainingData(modelName) {
1325
+ try {
1326
+ const storage = this.getStorage();
1327
+ const modelConfig = this.config.models[modelName];
1328
+ const resourceName = modelConfig.resource;
1329
+ const enableVersioning = this.config.enableVersioning;
1330
+
1331
+ if (enableVersioning) {
1332
+ // Delete all version data
1333
+ const [ok, err, historyData] = await tryFn(() =>
1334
+ storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName))
1335
+ );
1336
+
1337
+ if (ok && historyData && historyData.history) {
1338
+ for (const entry of historyData.history) {
1339
+ if (entry.storageKey) {
1340
+ await storage.delete(storage.getPluginKey(resourceName, 'training', 'data', modelName, `v${entry.version}`));
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ // Delete history
1346
+ await storage.delete(storage.getPluginKey(resourceName, 'training', 'history', modelName));
1347
+ } else {
1348
+ // Delete non-versioned training data
1349
+ await storage.delete(storage.getPluginKey(resourceName, 'training', 'data', modelName, 'latest'));
1350
+ }
1351
+
1352
+ if (this.config.verbose) {
1353
+ console.log(`[MLPlugin] Deleted training data for "${modelName}" from S3 (resource=${resourceName}/plugin=ml/training/)`);
1354
+ }
1355
+ } catch (error) {
1356
+ // Ignore errors (training data might not exist)
1357
+ if (this.config.verbose) {
1358
+ console.log(`[MLPlugin] Could not delete training data "${modelName}": ${error.message}`);
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * List all versions of a model
1365
+ * @param {string} modelName - Model name
1366
+ * @returns {Array} List of version info
1367
+ */
1368
+ async listModelVersions(modelName) {
1369
+ if (!this.config.enableVersioning) {
1370
+ throw new MLError('Versioning is not enabled', { modelName });
1371
+ }
1372
+
1373
+ try {
1374
+ const storage = this.getStorage();
1375
+ const modelConfig = this.config.models[modelName];
1376
+ const resourceName = modelConfig.resource;
1377
+ const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
1378
+ const versions = [];
1379
+
1380
+ // Load each version
1381
+ for (let v = 1; v <= versionInfo.latestVersion; v++) {
1382
+ const [ok, err, versionData] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${v}`)));
1383
+
1384
+ if (ok && versionData) {
1385
+ versions.push({
1386
+ version: v,
1387
+ savedAt: versionData.savedAt,
1388
+ isCurrent: v === versionInfo.currentVersion,
1389
+ metrics: versionData.metrics
1390
+ });
1391
+ }
1392
+ }
1393
+
1394
+ return versions;
1395
+ } catch (error) {
1396
+ console.error(`[MLPlugin] Failed to list versions for "${modelName}":`, error.message);
1397
+ return [];
1398
+ }
1399
+ }
1400
+
1401
+ /**
1402
+ * Load a specific version of a model
1403
+ * @param {string} modelName - Model name
1404
+ * @param {number} version - Version number
1405
+ */
1406
+ async loadModelVersion(modelName, version) {
1407
+ if (!this.config.enableVersioning) {
1408
+ throw new MLError('Versioning is not enabled', { modelName });
1409
+ }
1410
+
1411
+ if (!this.models[modelName]) {
1412
+ throw new ModelNotFoundError(`Model "${modelName}" not found`, { modelName });
1413
+ }
1414
+
1415
+ try {
1416
+ const storage = this.getStorage();
1417
+ const modelConfig = this.config.models[modelName];
1418
+ const resourceName = modelConfig.resource;
1419
+ const [ok, err, versionData] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version}`)));
1420
+
1421
+ if (!ok || !versionData) {
1422
+ throw new MLError(`Version ${version} not found for model "${modelName}"`, { modelName, version });
1423
+ }
1424
+
1425
+ if (!versionData.modelData) {
1426
+ throw new MLError(`Model data not found in version ${version}`, { modelName, version });
1427
+ }
1428
+
1429
+ await this.models[modelName].import(versionData.modelData);
1430
+
1431
+ // Update current version in memory (don't save to storage yet)
1432
+ const versionInfo = this.modelVersions.get(modelName);
1433
+ if (versionInfo) {
1434
+ versionInfo.currentVersion = version;
1435
+ this.modelVersions.set(modelName, versionInfo);
1436
+ }
1437
+
1438
+ if (this.config.verbose) {
1439
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version}`);
1440
+ }
1441
+
1442
+ return {
1443
+ version,
1444
+ metrics: versionData.metrics ? JSON.parse(versionData.metrics) : {},
1445
+ savedAt: versionData.savedAt
1446
+ };
1447
+ } catch (error) {
1448
+ console.error(`[MLPlugin] Failed to load version ${version} for "${modelName}":`, error.message);
1449
+ throw error;
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Set active version for a model (used for predictions)
1455
+ * @param {string} modelName - Model name
1456
+ * @param {number} version - Version number
1457
+ */
1458
+ async setActiveVersion(modelName, version) {
1459
+ if (!this.config.enableVersioning) {
1460
+ throw new MLError('Versioning is not enabled', { modelName });
1461
+ }
1462
+
1463
+ const modelConfig = this.config.models[modelName];
1464
+ const resourceName = modelConfig.resource;
1465
+
1466
+ // Load the version into the model
1467
+ await this.loadModelVersion(modelName, version);
1468
+
1469
+ // Update version info in storage
1470
+ await this._updateVersionInfo(modelName, version);
1471
+
1472
+ // Update active reference
1473
+ const storage = this.getStorage();
1474
+ await storage.set(storage.getPluginKey(resourceName, 'metadata', modelName, 'active'), {
1475
+ modelName,
1476
+ version,
1477
+ type: 'reference',
1478
+ updatedAt: new Date().toISOString()
1479
+ });
1480
+
1481
+ if (this.config.verbose) {
1482
+ console.log(`[MLPlugin] Set model "${modelName}" active version to v${version}`);
1483
+ }
1484
+
1485
+ return { modelName, version };
1486
+ }
1487
+
1488
+ /**
1489
+ * Get training history for a model
1490
+ * @param {string} modelName - Model name
1491
+ * @returns {Array} Training history
1492
+ */
1493
+ async getTrainingHistory(modelName) {
1494
+ if (!this.config.enableVersioning) {
1495
+ // Fallback to legacy getTrainingData
1496
+ return await this.getTrainingData(modelName);
1497
+ }
1498
+
1499
+ try {
1500
+ const storage = this.getStorage();
1501
+ const modelConfig = this.config.models[modelName];
1502
+ const resourceName = modelConfig.resource;
1503
+ const [ok, err, historyData] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName)));
1504
+
1505
+ if (!ok || !historyData) {
1506
+ return null;
1507
+ }
1508
+
1509
+ return {
1510
+ modelName: historyData.modelName,
1511
+ totalTrainings: historyData.totalTrainings,
1512
+ latestVersion: historyData.latestVersion,
1513
+ history: JSON.parse(historyData.history),
1514
+ updatedAt: historyData.updatedAt
1515
+ };
1516
+ } catch (error) {
1517
+ console.error(`[MLPlugin] Failed to load training history for "${modelName}":`, error.message);
1518
+ return null;
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * Compare metrics between two versions
1524
+ * @param {string} modelName - Model name
1525
+ * @param {number} version1 - First version
1526
+ * @param {number} version2 - Second version
1527
+ * @returns {Object} Comparison results
1528
+ */
1529
+ async compareVersions(modelName, version1, version2) {
1530
+ if (!this.config.enableVersioning) {
1531
+ throw new MLError('Versioning is not enabled', { modelName });
1532
+ }
1533
+
1534
+ try {
1535
+ const storage = this.getStorage();
1536
+ const modelConfig = this.config.models[modelName];
1537
+ const resourceName = modelConfig.resource;
1538
+
1539
+ const [ok1, err1, v1Data] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version1}`)));
1540
+ const [ok2, err2, v2Data] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version2}`)));
1541
+
1542
+ if (!ok1 || !v1Data) {
1543
+ throw new MLError(`Version ${version1} not found`, { modelName, version: version1 });
1544
+ }
1545
+
1546
+ if (!ok2 || !v2Data) {
1547
+ throw new MLError(`Version ${version2} not found`, { modelName, version: version2 });
1548
+ }
1549
+
1550
+ const metrics1 = v1Data.metrics ? JSON.parse(v1Data.metrics) : {};
1551
+ const metrics2 = v2Data.metrics ? JSON.parse(v2Data.metrics) : {};
1552
+
1553
+ return {
1554
+ modelName,
1555
+ version1: {
1556
+ version: version1,
1557
+ savedAt: v1Data.savedAt,
1558
+ metrics: metrics1
1559
+ },
1560
+ version2: {
1561
+ version: version2,
1562
+ savedAt: v2Data.savedAt,
1563
+ metrics: metrics2
1564
+ },
1565
+ improvement: {
1566
+ loss: metrics1.loss && metrics2.loss ? ((metrics1.loss - metrics2.loss) / metrics1.loss * 100).toFixed(2) + '%' : 'N/A',
1567
+ accuracy: metrics1.accuracy && metrics2.accuracy ? ((metrics2.accuracy - metrics1.accuracy) / metrics1.accuracy * 100).toFixed(2) + '%' : 'N/A'
1568
+ }
1569
+ };
1570
+ } catch (error) {
1571
+ console.error(`[MLPlugin] Failed to compare versions for "${modelName}":`, error.message);
1572
+ throw error;
1573
+ }
1574
+ }
1575
+
1576
+ /**
1577
+ * Rollback to a previous version
1578
+ * @param {string} modelName - Model name
1579
+ * @param {number} version - Version to rollback to (defaults to previous version)
1580
+ * @returns {Object} Rollback info
1581
+ */
1582
+ async rollbackVersion(modelName, version = null) {
1583
+ if (!this.config.enableVersioning) {
1584
+ throw new MLError('Versioning is not enabled', { modelName });
1585
+ }
1586
+
1587
+ const versionInfo = this.modelVersions.get(modelName);
1588
+ if (!versionInfo) {
1589
+ throw new MLError(`No version info found for model "${modelName}"`, { modelName });
1590
+ }
1591
+
1592
+ // If no version specified, rollback to previous
1593
+ const targetVersion = version !== null ? version : Math.max(1, versionInfo.currentVersion - 1);
1594
+
1595
+ if (targetVersion === versionInfo.currentVersion) {
1596
+ throw new MLError('Cannot rollback to the same version', { modelName, version: targetVersion });
1597
+ }
1598
+
1599
+ if (targetVersion < 1 || targetVersion > versionInfo.latestVersion) {
1600
+ throw new MLError(`Invalid version ${targetVersion}`, { modelName, version: targetVersion, latestVersion: versionInfo.latestVersion });
1601
+ }
1602
+
1603
+ // Load and set as active
1604
+ const result = await this.setActiveVersion(modelName, targetVersion);
1605
+
1606
+ if (this.config.verbose) {
1607
+ console.log(`[MLPlugin] Rolled back model "${modelName}" from v${versionInfo.currentVersion} to v${targetVersion}`);
1608
+ }
1609
+
1610
+ return {
1611
+ modelName,
1612
+ previousVersion: versionInfo.currentVersion,
1613
+ currentVersion: targetVersion,
1614
+ ...result
1615
+ };
1616
+ }
1617
+ }