s3db.js 12.3.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.
@@ -437,15 +437,18 @@ export class ApiPlugin extends Plugin {
437
437
  return async (c, next) => {
438
438
  await next();
439
439
 
440
- // Note: Actual compression would require proper streaming support
441
- // For now, this is a placeholder
442
- const acceptEncoding = c.req.header('accept-encoding') || '';
443
-
444
- if (acceptEncoding.includes('gzip')) {
445
- c.header('Content-Encoding', 'gzip');
446
- } else if (acceptEncoding.includes('deflate')) {
447
- c.header('Content-Encoding', 'deflate');
448
- }
440
+ // TODO: Implement actual compression using zlib
441
+ // For now, this is a no-op placeholder to avoid ERR_CONTENT_DECODING_FAILED errors
442
+ //
443
+ // WARNING: Do NOT set Content-Encoding headers without actually compressing!
444
+ // Setting these headers without compression causes browsers to fail with:
445
+ // net::ERR_CONTENT_DECODING_FAILED 200 (OK)
446
+ //
447
+ // Real implementation would require:
448
+ // 1. Check Accept-Encoding header
449
+ // 2. Compress response body with zlib.gzip() or zlib.deflate()
450
+ // 3. Set Content-Encoding header
451
+ // 4. Update Content-Length header
449
452
  };
450
453
  }
451
454
 
@@ -8,6 +8,44 @@ import { Hono } from 'hono';
8
8
  import { asyncHandler } from '../utils/error-handler.js';
9
9
  import * as formatter from '../utils/response-formatter.js';
10
10
 
11
+ /**
12
+ * Parse custom route definition (e.g., "GET /healthcheck" or "async POST /custom")
13
+ * @param {string} routeDef - Route definition string
14
+ * @returns {Object} Parsed route { method, path, isAsync }
15
+ */
16
+ function parseCustomRoute(routeDef) {
17
+ // Remove "async" prefix if present
18
+ let def = routeDef.trim();
19
+ const isAsync = def.startsWith('async ');
20
+
21
+ if (isAsync) {
22
+ def = def.substring(6).trim(); // Remove "async "
23
+ }
24
+
25
+ // Split by space (e.g., "GET /path" -> ["GET", "/path"])
26
+ const parts = def.split(/\s+/);
27
+
28
+ if (parts.length < 2) {
29
+ throw new Error(`Invalid route definition: "${routeDef}". Expected format: "METHOD /path" or "async METHOD /path"`);
30
+ }
31
+
32
+ const method = parts[0].toUpperCase();
33
+ const path = parts.slice(1).join(' ').trim(); // Join remaining parts in case path has spaces (unlikely but possible)
34
+
35
+ // Validate HTTP method
36
+ const validMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
37
+ if (!validMethods.includes(method)) {
38
+ throw new Error(`Invalid HTTP method: "${method}". Must be one of: ${validMethods.join(', ')}`);
39
+ }
40
+
41
+ // Validate path starts with /
42
+ if (!path.startsWith('/')) {
43
+ throw new Error(`Invalid route path: "${path}". Path must start with "/"`);
44
+ }
45
+
46
+ return { method, path, isAsync };
47
+ }
48
+
11
49
  /**
12
50
  * Create routes for a resource
13
51
  * @param {Object} resource - s3db.js Resource instance
@@ -31,6 +69,46 @@ export function createResourceRoutes(resource, version, config = {}) {
31
69
  app.use('*', middleware);
32
70
  });
33
71
 
72
+ // Register custom routes from resource.config.api (if defined)
73
+ if (resource.config?.api && typeof resource.config.api === 'object') {
74
+ for (const [routeDef, handler] of Object.entries(resource.config.api)) {
75
+ try {
76
+ const { method, path } = parseCustomRoute(routeDef);
77
+
78
+ if (typeof handler !== 'function') {
79
+ throw new Error(`Handler for route "${routeDef}" must be a function`);
80
+ }
81
+
82
+ // Register the custom route
83
+ // The handler receives the full Hono context
84
+ app.on(method, path, asyncHandler(async (c) => {
85
+ // Call user's handler with Hono context
86
+ const result = await handler(c, { resource, database: resource.database });
87
+
88
+ // If handler already returned a response, use it
89
+ if (result && result.constructor && result.constructor.name === 'Response') {
90
+ return result;
91
+ }
92
+
93
+ // If handler returned data, wrap in success formatter
94
+ if (result !== undefined && result !== null) {
95
+ return c.json(formatter.success(result));
96
+ }
97
+
98
+ // If no return value, return 204 No Content
99
+ return c.json(formatter.noContent(), 204);
100
+ }));
101
+
102
+ if (config.verbose || resource.database?.verbose) {
103
+ console.log(`[API Plugin] Registered custom route for ${resourceName}: ${method} ${path}`);
104
+ }
105
+ } catch (error) {
106
+ console.error(`[API Plugin] Error registering custom route "${routeDef}" for ${resourceName}:`, error.message);
107
+ throw error;
108
+ }
109
+ }
110
+ }
111
+
34
112
  // LIST - GET /{version}/{resource}
35
113
  if (methods.includes('GET')) {
36
114
  app.get('/', asyncHandler(async (c) => {
@@ -13,6 +13,7 @@ export * from './eventual-consistency/index.js'
13
13
  export * from './fulltext.plugin.js'
14
14
  export * from './geo.plugin.js'
15
15
  export * from './metrics.plugin.js'
16
+ export * from './ml.plugin.js'
16
17
  export * from './queue-consumer.plugin.js'
17
18
  export * from './relation.plugin.js'
18
19
  export * from './replicator.plugin.js'
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Base Model Class
3
+ *
4
+ * Abstract base class for all ML models
5
+ * Provides common functionality for training, prediction, and persistence
6
+ */
7
+
8
+ import {
9
+ TrainingError,
10
+ PredictionError,
11
+ ModelNotTrainedError,
12
+ DataValidationError,
13
+ InsufficientDataError,
14
+ TensorFlowDependencyError
15
+ } from '../ml.errors.js';
16
+
17
+ export class BaseModel {
18
+ constructor(config = {}) {
19
+ if (this.constructor === BaseModel) {
20
+ throw new Error('BaseModel is an abstract class and cannot be instantiated directly');
21
+ }
22
+
23
+ this.config = {
24
+ name: config.name || 'unnamed',
25
+ resource: config.resource,
26
+ features: config.features || [],
27
+ target: config.target,
28
+ modelConfig: {
29
+ epochs: 50,
30
+ batchSize: 32,
31
+ learningRate: 0.01,
32
+ validationSplit: 0.2,
33
+ ...config.modelConfig
34
+ },
35
+ verbose: config.verbose || false
36
+ };
37
+
38
+ // Model state
39
+ this.model = null;
40
+ this.isTrained = false;
41
+ this.normalizer = {
42
+ features: {},
43
+ target: {}
44
+ };
45
+ this.stats = {
46
+ trainedAt: null,
47
+ samples: 0,
48
+ loss: null,
49
+ accuracy: null,
50
+ predictions: 0,
51
+ errors: 0
52
+ };
53
+
54
+ // Validate TensorFlow.js
55
+ this._validateTensorFlow();
56
+ }
57
+
58
+ /**
59
+ * Validate TensorFlow.js is installed
60
+ * @private
61
+ */
62
+ _validateTensorFlow() {
63
+ try {
64
+ this.tf = require('@tensorflow/tfjs-node');
65
+ } catch (error) {
66
+ throw new TensorFlowDependencyError(
67
+ 'TensorFlow.js is not installed. Run: pnpm add @tensorflow/tfjs-node',
68
+ { originalError: error.message }
69
+ );
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Abstract method: Build the model architecture
75
+ * Must be implemented by subclasses
76
+ * @abstract
77
+ */
78
+ buildModel() {
79
+ throw new Error('buildModel() must be implemented by subclass');
80
+ }
81
+
82
+ /**
83
+ * Train the model with provided data
84
+ * @param {Array} data - Training data records
85
+ * @returns {Object} Training results
86
+ */
87
+ async train(data) {
88
+ try {
89
+ if (!data || data.length === 0) {
90
+ throw new InsufficientDataError('No training data provided', {
91
+ model: this.config.name
92
+ });
93
+ }
94
+
95
+ // Validate minimum samples
96
+ const minSamples = this.config.modelConfig.batchSize || 10;
97
+ if (data.length < minSamples) {
98
+ throw new InsufficientDataError(
99
+ `Insufficient training data: ${data.length} samples (minimum: ${minSamples})`,
100
+ { model: this.config.name, samples: data.length, minimum: minSamples }
101
+ );
102
+ }
103
+
104
+ // Prepare data (extract features and target)
105
+ const { xs, ys } = this._prepareData(data);
106
+
107
+ // Build model if not already built
108
+ if (!this.model) {
109
+ this.buildModel();
110
+ }
111
+
112
+ // Train the model
113
+ const history = await this.model.fit(xs, ys, {
114
+ epochs: this.config.modelConfig.epochs,
115
+ batchSize: this.config.modelConfig.batchSize,
116
+ validationSplit: this.config.modelConfig.validationSplit,
117
+ verbose: this.config.verbose ? 1 : 0,
118
+ callbacks: {
119
+ onEpochEnd: (epoch, logs) => {
120
+ if (this.config.verbose && epoch % 10 === 0) {
121
+ console.log(`[MLPlugin] ${this.config.name} - Epoch ${epoch}: loss=${logs.loss.toFixed(4)}`);
122
+ }
123
+ }
124
+ }
125
+ });
126
+
127
+ // Update stats
128
+ this.isTrained = true;
129
+ this.stats.trainedAt = new Date().toISOString();
130
+ this.stats.samples = data.length;
131
+ this.stats.loss = history.history.loss[history.history.loss.length - 1];
132
+
133
+ // Get accuracy if available (classification models)
134
+ if (history.history.acc) {
135
+ this.stats.accuracy = history.history.acc[history.history.acc.length - 1];
136
+ }
137
+
138
+ // Cleanup tensors
139
+ xs.dispose();
140
+ ys.dispose();
141
+
142
+ if (this.config.verbose) {
143
+ console.log(`[MLPlugin] ${this.config.name} - Training completed:`, {
144
+ samples: this.stats.samples,
145
+ loss: this.stats.loss,
146
+ accuracy: this.stats.accuracy
147
+ });
148
+ }
149
+
150
+ return {
151
+ loss: this.stats.loss,
152
+ accuracy: this.stats.accuracy,
153
+ epochs: this.config.modelConfig.epochs,
154
+ samples: this.stats.samples
155
+ };
156
+ } catch (error) {
157
+ this.stats.errors++;
158
+ if (error instanceof InsufficientDataError || error instanceof DataValidationError) {
159
+ throw error;
160
+ }
161
+ throw new TrainingError(`Training failed: ${error.message}`, {
162
+ model: this.config.name,
163
+ originalError: error.message
164
+ });
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Make a prediction with the trained model
170
+ * @param {Object} input - Input features
171
+ * @returns {Object} Prediction result
172
+ */
173
+ async predict(input) {
174
+ if (!this.isTrained) {
175
+ throw new ModelNotTrainedError(`Model "${this.config.name}" is not trained yet`, {
176
+ model: this.config.name
177
+ });
178
+ }
179
+
180
+ try {
181
+ // Validate input
182
+ this._validateInput(input);
183
+
184
+ // Extract and normalize features
185
+ const features = this._extractFeatures(input);
186
+ const normalizedFeatures = this._normalizeFeatures(features);
187
+
188
+ // Convert to tensor
189
+ const inputTensor = this.tf.tensor2d([normalizedFeatures]);
190
+
191
+ // Predict
192
+ const predictionTensor = this.model.predict(inputTensor);
193
+ const predictionArray = await predictionTensor.data();
194
+
195
+ // Cleanup
196
+ inputTensor.dispose();
197
+ predictionTensor.dispose();
198
+
199
+ // Denormalize prediction
200
+ const prediction = this._denormalizePrediction(predictionArray[0]);
201
+
202
+ this.stats.predictions++;
203
+
204
+ return {
205
+ prediction,
206
+ confidence: this._calculateConfidence(predictionArray[0])
207
+ };
208
+ } catch (error) {
209
+ this.stats.errors++;
210
+ if (error instanceof ModelNotTrainedError || error instanceof DataValidationError) {
211
+ throw error;
212
+ }
213
+ throw new PredictionError(`Prediction failed: ${error.message}`, {
214
+ model: this.config.name,
215
+ input,
216
+ originalError: error.message
217
+ });
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Make predictions for multiple inputs
223
+ * @param {Array} inputs - Array of input objects
224
+ * @returns {Array} Array of prediction results
225
+ */
226
+ async predictBatch(inputs) {
227
+ if (!this.isTrained) {
228
+ throw new ModelNotTrainedError(`Model "${this.config.name}" is not trained yet`, {
229
+ model: this.config.name
230
+ });
231
+ }
232
+
233
+ const predictions = [];
234
+ for (const input of inputs) {
235
+ predictions.push(await this.predict(input));
236
+ }
237
+ return predictions;
238
+ }
239
+
240
+ /**
241
+ * Prepare training data (extract features and target)
242
+ * @private
243
+ * @param {Array} data - Raw training data
244
+ * @returns {Object} Prepared tensors {xs, ys}
245
+ */
246
+ _prepareData(data) {
247
+ const features = [];
248
+ const targets = [];
249
+
250
+ for (const record of data) {
251
+ // Validate record has required fields
252
+ const missingFeatures = this.config.features.filter(f => !(f in record));
253
+ if (missingFeatures.length > 0) {
254
+ throw new DataValidationError(
255
+ `Missing features in training data: ${missingFeatures.join(', ')}`,
256
+ { model: this.config.name, missingFeatures, record }
257
+ );
258
+ }
259
+
260
+ if (!(this.config.target in record)) {
261
+ throw new DataValidationError(
262
+ `Missing target "${this.config.target}" in training data`,
263
+ { model: this.config.name, target: this.config.target, record }
264
+ );
265
+ }
266
+
267
+ // Extract features
268
+ const featureValues = this._extractFeatures(record);
269
+ features.push(featureValues);
270
+
271
+ // Extract target
272
+ targets.push(record[this.config.target]);
273
+ }
274
+
275
+ // Calculate normalization parameters
276
+ this._calculateNormalizer(features, targets);
277
+
278
+ // Normalize data
279
+ const normalizedFeatures = features.map(f => this._normalizeFeatures(f));
280
+ const normalizedTargets = targets.map(t => this._normalizeTarget(t));
281
+
282
+ // Convert to tensors
283
+ return {
284
+ xs: this.tf.tensor2d(normalizedFeatures),
285
+ ys: this._prepareTargetTensor(normalizedTargets)
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Prepare target tensor (can be overridden by subclasses)
291
+ * @protected
292
+ * @param {Array} targets - Normalized target values
293
+ * @returns {Tensor} Target tensor
294
+ */
295
+ _prepareTargetTensor(targets) {
296
+ return this.tf.tensor2d(targets.map(t => [t]));
297
+ }
298
+
299
+ /**
300
+ * Extract feature values from a record
301
+ * @private
302
+ * @param {Object} record - Data record
303
+ * @returns {Array} Feature values
304
+ */
305
+ _extractFeatures(record) {
306
+ return this.config.features.map(feature => {
307
+ const value = record[feature];
308
+ if (typeof value !== 'number') {
309
+ throw new DataValidationError(
310
+ `Feature "${feature}" must be a number, got ${typeof value}`,
311
+ { model: this.config.name, feature, value, type: typeof value }
312
+ );
313
+ }
314
+ return value;
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Calculate normalization parameters (min-max scaling)
320
+ * @private
321
+ */
322
+ _calculateNormalizer(features, targets) {
323
+ const numFeatures = features[0].length;
324
+
325
+ // Initialize normalizer
326
+ for (let i = 0; i < numFeatures; i++) {
327
+ const featureName = this.config.features[i];
328
+ const values = features.map(f => f[i]);
329
+ this.normalizer.features[featureName] = {
330
+ min: Math.min(...values),
331
+ max: Math.max(...values)
332
+ };
333
+ }
334
+
335
+ // Normalize target
336
+ this.normalizer.target = {
337
+ min: Math.min(...targets),
338
+ max: Math.max(...targets)
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Normalize features using min-max scaling
344
+ * @private
345
+ */
346
+ _normalizeFeatures(features) {
347
+ return features.map((value, i) => {
348
+ const featureName = this.config.features[i];
349
+ const { min, max } = this.normalizer.features[featureName];
350
+ if (max === min) return 0.5; // Handle constant features
351
+ return (value - min) / (max - min);
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Normalize target value
357
+ * @private
358
+ */
359
+ _normalizeTarget(target) {
360
+ const { min, max } = this.normalizer.target;
361
+ if (max === min) return 0.5;
362
+ return (target - min) / (max - min);
363
+ }
364
+
365
+ /**
366
+ * Denormalize prediction
367
+ * @private
368
+ */
369
+ _denormalizePrediction(normalizedValue) {
370
+ const { min, max } = this.normalizer.target;
371
+ return normalizedValue * (max - min) + min;
372
+ }
373
+
374
+ /**
375
+ * Calculate confidence score (can be overridden)
376
+ * @protected
377
+ */
378
+ _calculateConfidence(value) {
379
+ // Default: simple confidence based on normalized value
380
+ // Closer to 0 or 1 = higher confidence, closer to 0.5 = lower confidence
381
+ const distanceFrom05 = Math.abs(value - 0.5);
382
+ return Math.min(0.5 + distanceFrom05, 1.0);
383
+ }
384
+
385
+ /**
386
+ * Validate input data
387
+ * @private
388
+ */
389
+ _validateInput(input) {
390
+ const missingFeatures = this.config.features.filter(f => !(f in input));
391
+ if (missingFeatures.length > 0) {
392
+ throw new DataValidationError(
393
+ `Missing features: ${missingFeatures.join(', ')}`,
394
+ { model: this.config.name, missingFeatures, input }
395
+ );
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Export model to JSON (for persistence)
401
+ * @returns {Object} Serialized model
402
+ */
403
+ async export() {
404
+ if (!this.model) {
405
+ return null;
406
+ }
407
+
408
+ const modelJSON = await this.model.toJSON();
409
+
410
+ return {
411
+ config: this.config,
412
+ normalizer: this.normalizer,
413
+ stats: this.stats,
414
+ isTrained: this.isTrained,
415
+ model: modelJSON
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Import model from JSON
421
+ * @param {Object} data - Serialized model data
422
+ */
423
+ async import(data) {
424
+ this.config = data.config;
425
+ this.normalizer = data.normalizer;
426
+ this.stats = data.stats;
427
+ this.isTrained = data.isTrained;
428
+
429
+ if (data.model) {
430
+ // Note: Actual model reconstruction depends on the model type
431
+ // This is a placeholder and should be overridden by subclasses
432
+ this.buildModel();
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Dispose model and free memory
438
+ */
439
+ dispose() {
440
+ if (this.model) {
441
+ this.model.dispose();
442
+ this.model = null;
443
+ }
444
+ this.isTrained = false;
445
+ }
446
+
447
+ /**
448
+ * Get model statistics
449
+ */
450
+ getStats() {
451
+ return {
452
+ ...this.stats,
453
+ isTrained: this.isTrained,
454
+ config: this.config
455
+ };
456
+ }
457
+ }
458
+
459
+ export default BaseModel;