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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "12.4.0",
3
+ "version": "13.0.0",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -88,6 +88,7 @@
88
88
  "@google-cloud/bigquery": "^7.0.0",
89
89
  "@hono/node-server": "^1.0.0",
90
90
  "@hono/swagger-ui": "^0.5.0",
91
+ "@tensorflow/tfjs-node": "^4.0.0",
91
92
  "amqplib": "^0.10.8",
92
93
  "hono": "^4.0.0",
93
94
  "node-cron": "^4.0.0",
@@ -106,6 +107,9 @@
106
107
  "@hono/swagger-ui": {
107
108
  "optional": true
108
109
  },
110
+ "@tensorflow/tfjs-node": {
111
+ "optional": true
112
+ },
109
113
  "pg": {
110
114
  "optional": true
111
115
  },
@@ -640,41 +640,58 @@ export class MemoryClient extends EventEmitter {
640
640
  for (const [resourceName, keys] of resourceMap.entries()) {
641
641
  const records = [];
642
642
 
643
- for (const key of keys) {
644
- const obj = await this.getObject(key);
643
+ // Get resource from database if available (for proper field decoding)
644
+ const resource = database && database.resources && database.resources[resourceName];
645
645
 
646
+ for (const key of keys) {
646
647
  // Extract id from key (e.g., resource=products/id=pr1 -> pr1)
647
648
  const idMatch = key.match(/\/id=([^/]+)/);
648
649
  const recordId = idMatch ? idMatch[1] : null;
649
650
 
650
- // Reconstruct record from metadata and body
651
- const record = { ...obj.Metadata };
651
+ let record;
652
652
 
653
- // Include id in record if extracted from key
654
- if (recordId && !record.id) {
655
- record.id = recordId;
653
+ // If resource is available, use its get() method for proper field name decoding
654
+ if (resource && recordId) {
655
+ try {
656
+ record = await resource.get(recordId);
657
+ } catch (err) {
658
+ // Fallback to manual reconstruction if get() fails
659
+ console.warn(`Failed to get record ${recordId} from resource ${resourceName}, using fallback`);
660
+ record = null;
661
+ }
656
662
  }
657
663
 
658
- // If body exists, parse it
659
- if (obj.Body) {
660
- const chunks = [];
661
- for await (const chunk of obj.Body) {
662
- chunks.push(chunk);
664
+ // Fallback: manually reconstruct from metadata and body
665
+ if (!record) {
666
+ const obj = await this.getObject(key);
667
+ record = { ...obj.Metadata };
668
+
669
+ // Include id in record if extracted from key
670
+ if (recordId && !record.id) {
671
+ record.id = recordId;
663
672
  }
664
- const bodyBuffer = Buffer.concat(chunks);
665
-
666
- // Try to parse as JSON if it looks like JSON
667
- const bodyStr = bodyBuffer.toString('utf-8');
668
- if (bodyStr.startsWith('{') || bodyStr.startsWith('[')) {
669
- try {
670
- const bodyData = JSON.parse(bodyStr);
671
- Object.assign(record, bodyData);
672
- } catch {
673
- // If not JSON, store as _body field
673
+
674
+ // If body exists, parse it
675
+ if (obj.Body) {
676
+ const chunks = [];
677
+ for await (const chunk of obj.Body) {
678
+ chunks.push(chunk);
679
+ }
680
+ const bodyBuffer = Buffer.concat(chunks);
681
+
682
+ // Try to parse as JSON if it looks like JSON
683
+ const bodyStr = bodyBuffer.toString('utf-8');
684
+ if (bodyStr.startsWith('{') || bodyStr.startsWith('[')) {
685
+ try {
686
+ const bodyData = JSON.parse(bodyStr);
687
+ Object.assign(record, bodyData);
688
+ } catch {
689
+ // If not JSON, store as _body field
690
+ record._body = bodyStr;
691
+ }
692
+ } else if (bodyStr) {
674
693
  record._body = bodyStr;
675
694
  }
676
- } else if (bodyStr) {
677
- record._body = bodyStr;
678
695
  }
679
696
  }
680
697
 
@@ -14,7 +14,12 @@ export class Database extends EventEmitter {
14
14
  constructor(options) {
15
15
  super();
16
16
 
17
- this.id = idGenerator(7)
17
+ // Generate database ID with fallback for reliability
18
+ this.id = (() => {
19
+ const [ok, err, id] = tryFn(() => idGenerator(7));
20
+ return ok && id ? id : `db-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
21
+ })();
22
+
18
23
  this.version = "1";
19
24
  // Version is injected during build, fallback to "latest" for development
20
25
  this.s3dbVersion = (() => {
@@ -64,6 +69,7 @@ export class Database extends EventEmitter {
64
69
  this.versioningEnabled = options.versioningEnabled || false;
65
70
  this.persistHooks = options.persistHooks || false; // New configuration for hook persistence
66
71
  this.strictValidation = options.strictValidation !== false; // Enable strict validation by default
72
+ this.strictHooks = options.strictHooks || false; // Throw on first hook error instead of continuing
67
73
 
68
74
  // Initialize hooks system
69
75
  this._initHooks();
@@ -110,21 +116,32 @@ export class Database extends EventEmitter {
110
116
  this.bucket = this.client.bucket;
111
117
  this.keyPrefix = this.client.keyPrefix;
112
118
 
113
- // Add process exit listener for cleanup
114
- if (!this._exitListenerRegistered) {
119
+ // Register exit listener for cleanup
120
+ this._registerExitListener();
121
+ }
122
+
123
+ /**
124
+ * Register process exit listener for automatic cleanup
125
+ * @private
126
+ */
127
+ _registerExitListener() {
128
+ if (!this._exitListenerRegistered && typeof process !== 'undefined') {
115
129
  this._exitListenerRegistered = true;
116
- if (typeof process !== 'undefined') {
117
- process.on('exit', async () => {
118
- if (this.isConnected()) {
119
- // Silently ignore errors on exit
120
- await tryFn(() => this.disconnect());
121
- }
122
- });
123
- }
130
+ // Store listener reference for cleanup
131
+ this._exitListener = async () => {
132
+ if (this.isConnected()) {
133
+ // Silently ignore errors on exit
134
+ await tryFn(() => this.disconnect());
135
+ }
136
+ };
137
+ process.on('exit', this._exitListener);
124
138
  }
125
139
  }
126
-
140
+
127
141
  async connect() {
142
+ // Re-register exit listener if it was cleaned up
143
+ this._registerExitListener();
144
+
128
145
  await this.startPlugins();
129
146
 
130
147
  let metadata = null;
@@ -1271,15 +1288,24 @@ export class Database extends EventEmitter {
1271
1288
  this.client.removeAllListeners();
1272
1289
  }
1273
1290
 
1274
- // 4. Remove all listeners from the database itself
1291
+ // 4. Emit disconnected event BEFORE removing database listeners (race condition fix)
1292
+ // This ensures listeners can actually receive the event
1293
+ await this.emit('disconnected', new Date());
1294
+
1295
+ // 5. Remove all listeners from the database itself
1275
1296
  this.removeAllListeners();
1276
1297
 
1277
- // 5. Clear saved metadata and plugin lists
1298
+ // 6. Cleanup process exit listener (memory leak fix)
1299
+ if (this._exitListener && typeof process !== 'undefined') {
1300
+ process.off('exit', this._exitListener);
1301
+ this._exitListener = null;
1302
+ this._exitListenerRegistered = false;
1303
+ }
1304
+
1305
+ // 7. Clear saved metadata and plugin lists
1278
1306
  this.savedMetadata = null;
1279
1307
  this.plugins = {};
1280
1308
  this.pluginList = [];
1281
-
1282
- this.emit('disconnected', new Date());
1283
1309
  });
1284
1310
  }
1285
1311
 
@@ -1400,8 +1426,17 @@ export class Database extends EventEmitter {
1400
1426
  for (const hook of hooks) {
1401
1427
  const [ok, error] = await tryFn(() => hook({ database: this, ...context }));
1402
1428
  if (!ok) {
1403
- // Emit error but don't stop hook execution
1429
+ // Emit error event
1404
1430
  this.emit('hookError', { event, error, context });
1431
+
1432
+ // In strict mode, throw on first error instead of continuing
1433
+ if (this.strictHooks) {
1434
+ throw new DatabaseError(`Hook execution failed for event '${event}': ${error.message}`, {
1435
+ event,
1436
+ originalError: error,
1437
+ context
1438
+ });
1439
+ }
1405
1440
  }
1406
1441
  }
1407
1442
  }
@@ -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'