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.
@@ -5,9 +5,13 @@
5
5
  */
6
6
 
7
7
  import { createResourceRoutes, createRelationalRoutes } from './routes/resource-routes.js';
8
+ import { createAuthRoutes } from './routes/auth-routes.js';
9
+ import { mountCustomRoutes } from './utils/custom-routes.js';
8
10
  import { errorHandler } from './utils/error-handler.js';
9
11
  import * as formatter from './utils/response-formatter.js';
10
12
  import { generateOpenAPISpec } from './utils/openapi-generator.js';
13
+ import { jwtAuth } from './auth/jwt-auth.js';
14
+ import { basicAuth } from './auth/basic-auth.js';
11
15
 
12
16
  /**
13
17
  * API Server class
@@ -29,6 +33,7 @@ export class ApiServer {
29
33
  host: options.host || '0.0.0.0',
30
34
  database: options.database,
31
35
  resources: options.resources || {},
36
+ routes: options.routes || {}, // Plugin-level custom routes
32
37
  middlewares: options.middlewares || [],
33
38
  verbose: options.verbose || false,
34
39
  auth: options.auth || {},
@@ -36,6 +41,7 @@ export class ApiServer {
36
41
  docsUI: options.docsUI || 'redoc', // 'swagger' or 'redoc'
37
42
  maxBodySize: options.maxBodySize || 10 * 1024 * 1024, // 10MB default
38
43
  rootHandler: options.rootHandler, // Custom handler for root path, if not provided redirects to /docs
44
+ versionPrefix: options.versionPrefix, // Global version prefix config
39
45
  apiInfo: {
40
46
  title: options.apiTitle || 's3db.js API',
41
47
  version: options.apiVersion || '1.0.0',
@@ -198,11 +204,19 @@ export class ApiServer {
198
204
  // Setup resource routes
199
205
  this._setupResourceRoutes();
200
206
 
207
+ // Setup authentication routes if driver is configured
208
+ if (this.options.auth.driver) {
209
+ this._setupAuthRoutes();
210
+ }
211
+
201
212
  // Setup relational routes if RelationPlugin is active
202
213
  if (this.relationsPlugin) {
203
214
  this._setupRelationalRoutes();
204
215
  }
205
216
 
217
+ // Setup plugin-level custom routes
218
+ this._setupPluginRoutes();
219
+
206
220
  // Global error handler
207
221
  this.app.onError((err, c) => {
208
222
  return errorHandler(err, c);
@@ -247,22 +261,154 @@ export class ApiServer {
247
261
  // Determine version
248
262
  const version = resource.config?.currentVersion || resource.version || 'v1';
249
263
 
264
+ // Determine version prefix (resource-level overrides global)
265
+ // Priority: resource.versionPrefix > global versionPrefix > false (default - no prefix)
266
+ let versionPrefixConfig = config.versionPrefix !== undefined
267
+ ? config.versionPrefix
268
+ : this.options.versionPrefix !== undefined
269
+ ? this.options.versionPrefix
270
+ : false;
271
+
272
+ // Calculate the actual prefix to use
273
+ let prefix = '';
274
+ if (versionPrefixConfig === true) {
275
+ // true: use resource version
276
+ prefix = version;
277
+ } else if (versionPrefixConfig === false) {
278
+ // false: no prefix
279
+ prefix = '';
280
+ } else if (typeof versionPrefixConfig === 'string') {
281
+ // string: custom prefix
282
+ prefix = versionPrefixConfig;
283
+ }
284
+
285
+ // Prepare custom middleware
286
+ const middlewares = [...(config.customMiddleware || [])];
287
+
288
+ // Add authentication middleware if required
289
+ if (config.auth && this.options.auth.driver) {
290
+ const authMiddleware = this._createAuthMiddleware();
291
+ if (authMiddleware) {
292
+ middlewares.unshift(authMiddleware); // Add at beginning
293
+ }
294
+ }
295
+
250
296
  // Create resource routes
251
297
  const resourceApp = createResourceRoutes(resource, version, {
252
298
  methods: config.methods,
253
- customMiddleware: config.customMiddleware || [],
254
- enableValidation: config.validation !== false
299
+ customMiddleware: middlewares,
300
+ enableValidation: config.validation !== false,
301
+ versionPrefix: prefix
255
302
  }, this.Hono);
256
303
 
257
- // Mount resource routes
258
- this.app.route(`/${version}/${name}`, resourceApp);
304
+ // Mount resource routes (with or without prefix)
305
+ const mountPath = prefix ? `/${prefix}/${name}` : `/${name}`;
306
+ this.app.route(mountPath, resourceApp);
259
307
 
260
308
  if (this.options.verbose) {
261
- console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
309
+ console.log(`[API Plugin] Mounted routes for resource '${name}' at ${mountPath}`);
262
310
  }
311
+
312
+ // Mount custom routes for this resource (if defined)
313
+ if (config.routes) {
314
+ const routeContext = {
315
+ resource,
316
+ database,
317
+ resourceName: name,
318
+ version
319
+ };
320
+
321
+ // Mount on the resourceApp (nested under resource path)
322
+ mountCustomRoutes(resourceApp, config.routes, routeContext, this.options.verbose);
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Setup authentication routes (when auth driver is configured)
329
+ * @private
330
+ */
331
+ _setupAuthRoutes() {
332
+ const { database, auth } = this.options;
333
+ const { driver, resource: resourceName, usernameField, passwordField, config } = auth;
334
+
335
+ // Get auth resource from database
336
+ const authResource = database.resources[resourceName];
337
+ if (!authResource) {
338
+ console.error(`[API Plugin] Auth resource '${resourceName}' not found. Skipping auth routes.`);
339
+ return;
340
+ }
341
+
342
+ // Prepare auth config for routes
343
+ const authConfig = {
344
+ driver,
345
+ usernameField,
346
+ passwordField,
347
+ jwtSecret: config.jwtSecret || config.secret,
348
+ jwtExpiresIn: config.jwtExpiresIn || config.expiresIn || '7d',
349
+ passphrase: config.passphrase || 'secret',
350
+ allowRegistration: config.allowRegistration !== false
351
+ };
352
+
353
+ // Create auth routes
354
+ const authApp = createAuthRoutes(authResource, authConfig);
355
+
356
+ // Mount auth routes at /auth
357
+ this.app.route('/auth', authApp);
358
+
359
+ if (this.options.verbose) {
360
+ console.log(`[API Plugin] Mounted auth routes (driver: ${driver}) at /auth`);
263
361
  }
264
362
  }
265
363
 
364
+ /**
365
+ * Create authentication middleware based on driver
366
+ * @private
367
+ * @returns {Function|null} Auth middleware function
368
+ */
369
+ _createAuthMiddleware() {
370
+ const { database, auth } = this.options;
371
+ const { driver, resource: resourceName, usernameField, passwordField, config } = auth;
372
+
373
+ if (!driver) {
374
+ return null;
375
+ }
376
+
377
+ const authResource = database.resources[resourceName];
378
+ if (!authResource) {
379
+ console.error(`[API Plugin] Auth resource '${resourceName}' not found for middleware`);
380
+ return null;
381
+ }
382
+
383
+ if (driver === 'jwt') {
384
+ const jwtSecret = config.jwtSecret || config.secret;
385
+ if (!jwtSecret) {
386
+ console.error('[API Plugin] JWT driver requires jwtSecret in config');
387
+ return null;
388
+ }
389
+
390
+ return jwtAuth({
391
+ secret: jwtSecret,
392
+ authResource,
393
+ usernameField,
394
+ passwordField
395
+ });
396
+ }
397
+
398
+ if (driver === 'basic') {
399
+ return basicAuth({
400
+ realm: config.realm || 'API Access',
401
+ authResource,
402
+ usernameField,
403
+ passwordField,
404
+ passphrase: config.passphrase || 'secret'
405
+ });
406
+ }
407
+
408
+ console.error(`[API Plugin] Unknown auth driver: ${driver}`);
409
+ return null;
410
+ }
411
+
266
412
  /**
267
413
  * Setup relational routes (when RelationPlugin is active)
268
414
  * @private
@@ -332,6 +478,31 @@ export class ApiServer {
332
478
  }
333
479
  }
334
480
 
481
+ /**
482
+ * Setup plugin-level custom routes
483
+ * @private
484
+ */
485
+ _setupPluginRoutes() {
486
+ const { routes, database } = this.options;
487
+
488
+ if (!routes || Object.keys(routes).length === 0) {
489
+ return;
490
+ }
491
+
492
+ // Plugin-level routes context
493
+ const context = {
494
+ database,
495
+ plugins: database?.plugins || {}
496
+ };
497
+
498
+ // Mount plugin routes directly on main app (not nested)
499
+ mountCustomRoutes(this.app, routes, context, this.options.verbose);
500
+
501
+ if (this.options.verbose) {
502
+ console.log(`[API Plugin] Mounted ${Object.keys(routes).length} plugin-level custom routes`);
503
+ }
504
+ }
505
+
335
506
  /**
336
507
  * Start the server
337
508
  * @returns {Promise<void>}
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Custom Routes Utilities
3
+ *
4
+ * Parse and mount custom routes defined in resources or plugins
5
+ * Inspired by moleculer-js route syntax
6
+ */
7
+
8
+ import { asyncHandler } from './error-handler.js';
9
+
10
+ /**
11
+ * Parse route definition from key
12
+ * @param {string} key - Route key (e.g., 'GET /users', 'POST /custom/:id/action')
13
+ * @returns {Object} { method, path }
14
+ */
15
+ export function parseRouteKey(key) {
16
+ const match = key.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i);
17
+
18
+ if (!match) {
19
+ throw new Error(`Invalid route key format: "${key}". Expected format: "METHOD /path"`);
20
+ }
21
+
22
+ return {
23
+ method: match[1].toUpperCase(),
24
+ path: match[2]
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Mount custom routes on Hono app
30
+ * @param {Object} app - Hono app instance
31
+ * @param {Object} routes - Routes object { 'METHOD /path': handler }
32
+ * @param {Object} context - Context to pass to handlers (resource, database, etc.)
33
+ * @param {boolean} verbose - Enable verbose logging
34
+ */
35
+ export function mountCustomRoutes(app, routes, context = {}, verbose = false) {
36
+ if (!routes || typeof routes !== 'object') {
37
+ return;
38
+ }
39
+
40
+ for (const [key, handler] of Object.entries(routes)) {
41
+ try {
42
+ const { method, path } = parseRouteKey(key);
43
+
44
+ // Wrap handler with async error handler and context
45
+ const wrappedHandler = asyncHandler(async (c) => {
46
+ // Inject context into Hono context
47
+ c.set('customRouteContext', context);
48
+
49
+ // Call user handler with Hono context
50
+ return await handler(c);
51
+ });
52
+
53
+ // Mount route
54
+ app.on(method, path, wrappedHandler);
55
+
56
+ if (verbose) {
57
+ console.log(`[Custom Routes] Mounted ${method} ${path}`);
58
+ }
59
+ } catch (err) {
60
+ console.error(`[Custom Routes] Error mounting route "${key}":`, err.message);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Validate custom routes object
67
+ * @param {Object} routes - Routes to validate
68
+ * @returns {Array} Array of validation errors
69
+ */
70
+ export function validateCustomRoutes(routes) {
71
+ const errors = [];
72
+
73
+ if (!routes || typeof routes !== 'object') {
74
+ return errors;
75
+ }
76
+
77
+ for (const [key, handler] of Object.entries(routes)) {
78
+ // Validate key format
79
+ try {
80
+ parseRouteKey(key);
81
+ } catch (err) {
82
+ errors.push({ key, error: err.message });
83
+ continue;
84
+ }
85
+
86
+ // Validate handler is a function
87
+ if (typeof handler !== 'function') {
88
+ errors.push({
89
+ key,
90
+ error: `Handler must be a function, got ${typeof handler}`
91
+ });
92
+ }
93
+ }
94
+
95
+ return errors;
96
+ }
97
+
98
+ export default {
99
+ parseRouteKey,
100
+ mountCustomRoutes,
101
+ validateCustomRoutes
102
+ };
@@ -185,7 +185,20 @@ function generateResourceSchema(resource) {
185
185
  */
186
186
  function generateResourcePaths(resource, version, config = {}) {
187
187
  const resourceName = resource.name;
188
- const basePath = `/${version}/${resourceName}`;
188
+
189
+ // Determine version prefix (same logic as server.js)
190
+ let versionPrefixConfig = config.versionPrefix !== undefined ? config.versionPrefix : false;
191
+
192
+ let prefix = '';
193
+ if (versionPrefixConfig === true) {
194
+ prefix = version;
195
+ } else if (versionPrefixConfig === false) {
196
+ prefix = '';
197
+ } else if (typeof versionPrefixConfig === 'string') {
198
+ prefix = versionPrefixConfig;
199
+ }
200
+
201
+ const basePath = prefix ? `/${prefix}/${resourceName}` : `/${resourceName}`;
189
202
  const schema = generateResourceSchema(resource);
190
203
  const methods = config.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
191
204
  const authMethods = config.auth || [];
@@ -820,11 +833,14 @@ The response includes pagination metadata in the \`pagination\` object with tota
820
833
  * @param {Object} relationConfig - Relation configuration
821
834
  * @param {string} version - Resource version
822
835
  * @param {Object} relatedSchema - OpenAPI schema for related resource
836
+ * @param {string} versionPrefix - Version prefix to use (empty string for no prefix)
823
837
  * @returns {Object} OpenAPI paths for relation
824
838
  */
825
- function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema) {
839
+ function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema, versionPrefix = '') {
826
840
  const resourceName = resource.name;
827
- const basePath = `/${version}/${resourceName}/{id}/${relationName}`;
841
+ const basePath = versionPrefix
842
+ ? `/${versionPrefix}/${resourceName}/{id}/${relationName}`
843
+ : `/${resourceName}/{id}/${relationName}`;
828
844
  const relatedResourceName = relationConfig.resource;
829
845
  const isToMany = relationConfig.type === 'hasMany' || relationConfig.type === 'belongsToMany';
830
846
 
@@ -950,7 +966,24 @@ export function generateOpenAPISpec(database, config = {}) {
950
966
  ? resourceDescription.resource
951
967
  : resourceDescription || 'No description';
952
968
 
953
- resourcesTableRows.push(`| ${name} | ${descText} | \`/${version}/${name}\` |`);
969
+ // Check version prefix for this resource (same logic as server.js)
970
+ const config = resourceConfigs[name] || {};
971
+ let versionPrefixConfig = config.versionPrefix !== undefined
972
+ ? config.versionPrefix
973
+ : false; // Default to false (no prefix)
974
+
975
+ let prefix = '';
976
+ if (versionPrefixConfig === true) {
977
+ prefix = version;
978
+ } else if (versionPrefixConfig === false) {
979
+ prefix = '';
980
+ } else if (typeof versionPrefixConfig === 'string') {
981
+ prefix = versionPrefixConfig;
982
+ }
983
+
984
+ const basePath = prefix ? `/${prefix}/${name}` : `/${name}`;
985
+
986
+ resourcesTableRows.push(`| ${name} | ${descText} | \`${basePath}\` |`);
954
987
  }
955
988
 
956
989
  // Build enhanced description with resources table
@@ -1084,6 +1117,18 @@ For detailed information about each endpoint, see the sections below.`;
1084
1117
  // Determine version
1085
1118
  const version = resource.config?.currentVersion || resource.version || 'v1';
1086
1119
 
1120
+ // Determine version prefix (same logic as server.js)
1121
+ let versionPrefixConfig = config.versionPrefix !== undefined ? config.versionPrefix : false;
1122
+
1123
+ let prefix = '';
1124
+ if (versionPrefixConfig === true) {
1125
+ prefix = version;
1126
+ } else if (versionPrefixConfig === false) {
1127
+ prefix = '';
1128
+ } else if (typeof versionPrefixConfig === 'string') {
1129
+ prefix = versionPrefixConfig;
1130
+ }
1131
+
1087
1132
  // Generate paths
1088
1133
  const paths = generateResourcePaths(resource, version, config);
1089
1134
 
@@ -1128,13 +1173,14 @@ For detailed information about each endpoint, see the sections below.`;
1128
1173
 
1129
1174
  const relatedSchema = generateResourceSchema(relatedResource);
1130
1175
 
1131
- // Generate relational paths
1176
+ // Generate relational paths (using the same prefix calculated above)
1132
1177
  const relationalPaths = generateRelationalPaths(
1133
1178
  resource,
1134
1179
  relationName,
1135
1180
  relationConfig,
1136
1181
  version,
1137
- relatedSchema
1182
+ relatedSchema,
1183
+ prefix
1138
1184
  );
1139
1185
 
1140
1186
  // Merge relational paths
@@ -170,7 +170,7 @@ async function tryLoadPackage(packageName) {
170
170
  let version = null;
171
171
  try {
172
172
  const pkgJson = await import(`${packageName}/package.json`, {
173
- assert: { type: 'json' }
173
+ with: { type: 'json' }
174
174
  });
175
175
  version = pkgJson.default?.version || pkgJson.version || null;
176
176
  } catch (e) {
@@ -140,7 +140,7 @@ export async function runConsolidation(transactionResource, consolidateRecordFn,
140
140
  }
141
141
 
142
142
  if (emitFn) {
143
- emitFn('eventual-consistency.consolidated', {
143
+ emitFn('plg:eventual-consistency:consolidated', {
144
144
  resource: config.resource,
145
145
  field: config.field,
146
146
  recordCount: uniqueIds.length,
@@ -157,7 +157,7 @@ export async function runConsolidation(transactionResource, consolidateRecordFn,
157
157
  error
158
158
  );
159
159
  if (emitFn) {
160
- emitFn('eventual-consistency.consolidation-error', error);
160
+ emitFn('plg:eventual-consistency:consolidation-error', error);
161
161
  }
162
162
  }
163
163
  }
@@ -103,7 +103,7 @@ export async function runGarbageCollection(transactionResource, storage, config,
103
103
  }
104
104
 
105
105
  if (emitFn) {
106
- emitFn('eventual-consistency.gc-completed', {
106
+ emitFn('plg:eventual-consistency:gc-completed', {
107
107
  resource: config.resource,
108
108
  field: config.field,
109
109
  deletedCount: results.length,
@@ -115,7 +115,7 @@ export async function runGarbageCollection(transactionResource, storage, config,
115
115
  console.warn(`[EventualConsistency] GC error:`, error.message);
116
116
  }
117
117
  if (emitFn) {
118
- emitFn('eventual-consistency.gc-error', error);
118
+ emitFn('plg:eventual-consistency:gc-error', error);
119
119
  }
120
120
  } finally {
121
121
  // Always release GC lock
@@ -255,7 +255,7 @@ export async function onStart(fieldHandlers, config, runConsolidationFn, runGCFn
255
255
  }
256
256
 
257
257
  if (emitFn) {
258
- emitFn('eventual-consistency.started', {
258
+ emitFn('plg:eventual-consistency:started', {
259
259
  resource: resourceName,
260
260
  field: fieldName,
261
261
  cohort: config.cohort
@@ -295,7 +295,7 @@ export async function onStop(fieldHandlers, emitFn) {
295
295
  }
296
296
 
297
297
  if (emitFn) {
298
- emitFn('eventual-consistency.stopped', {
298
+ emitFn('plg:eventual-consistency:stopped', {
299
299
  resource: resourceName,
300
300
  field: fieldName
301
301
  });
@@ -51,22 +51,36 @@ export class BaseModel {
51
51
  errors: 0
52
52
  };
53
53
 
54
- // Validate TensorFlow.js
55
- this._validateTensorFlow();
54
+ // TensorFlow will be loaded lazily on first use
55
+ this.tf = null;
56
+ this._tfValidated = false;
56
57
  }
57
58
 
58
59
  /**
59
- * Validate TensorFlow.js is installed
60
+ * Validate and load TensorFlow.js (lazy loading)
60
61
  * @private
61
62
  */
62
- _validateTensorFlow() {
63
+ async _validateTensorFlow() {
64
+ if (this._tfValidated) {
65
+ return; // Already validated and loaded
66
+ }
67
+
63
68
  try {
69
+ // Try CommonJS require first (works in most environments)
64
70
  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
- );
71
+ this._tfValidated = true;
72
+ } catch (requireError) {
73
+ // If require fails (e.g., Jest VM modules), try dynamic import
74
+ try {
75
+ const tfModule = await import('@tensorflow/tfjs-node');
76
+ this.tf = tfModule.default || tfModule;
77
+ this._tfValidated = true;
78
+ } catch (importError) {
79
+ throw new TensorFlowDependencyError(
80
+ 'TensorFlow.js is not installed. Run: pnpm add @tensorflow/tfjs-node',
81
+ { originalError: importError.message }
82
+ );
83
+ }
70
84
  }
71
85
  }
72
86
 
@@ -85,6 +99,11 @@ export class BaseModel {
85
99
  * @returns {Object} Training results
86
100
  */
87
101
  async train(data) {
102
+ // Validate TensorFlow on first use (lazy loading)
103
+ if (!this._tfValidated) {
104
+ await this._validateTensorFlow();
105
+ }
106
+
88
107
  try {
89
108
  if (!data || data.length === 0) {
90
109
  throw new InsufficientDataError('No training data provided', {
@@ -171,6 +190,11 @@ export class BaseModel {
171
190
  * @returns {Object} Prediction result
172
191
  */
173
192
  async predict(input) {
193
+ // Validate TensorFlow on first use (lazy loading)
194
+ if (!this._tfValidated) {
195
+ await this._validateTensorFlow();
196
+ }
197
+
174
198
  if (!this.isTrained) {
175
199
  throw new ModelNotTrainedError(`Model "${this.config.name}" is not trained yet`, {
176
200
  model: this.config.name