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/dist/s3db.cjs.js +1923 -57
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1923 -58
- package/dist/s3db.es.js.map +1 -1
- package/package.json +5 -1
- package/src/clients/memory-client.class.js +41 -24
- package/src/database.class.js +52 -17
- package/src/plugins/api/index.js +12 -9
- package/src/plugins/api/routes/resource-routes.js +78 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/ml/base-model.class.js +459 -0
- package/src/plugins/ml/classification-model.class.js +338 -0
- package/src/plugins/ml/neural-network-model.class.js +312 -0
- package/src/plugins/ml/regression-model.class.js +159 -0
- package/src/plugins/ml/timeseries-model.class.js +346 -0
- package/src/plugins/ml.errors.js +130 -0
- package/src/plugins/ml.plugin.js +655 -0
- package/src/resource.class.js +106 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s3db.js",
|
|
3
|
-
"version": "
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
|
|
651
|
-
const record = { ...obj.Metadata };
|
|
651
|
+
let record;
|
|
652
652
|
|
|
653
|
-
//
|
|
654
|
-
if (
|
|
655
|
-
|
|
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
|
-
//
|
|
659
|
-
if (
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
package/src/database.class.js
CHANGED
|
@@ -14,7 +14,12 @@ export class Database extends EventEmitter {
|
|
|
14
14
|
constructor(options) {
|
|
15
15
|
super();
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
//
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|
package/src/plugins/api/index.js
CHANGED
|
@@ -437,15 +437,18 @@ export class ApiPlugin extends Plugin {
|
|
|
437
437
|
return async (c, next) => {
|
|
438
438
|
await next();
|
|
439
439
|
|
|
440
|
-
//
|
|
441
|
-
// For now, this is a placeholder
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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) => {
|
package/src/plugins/index.js
CHANGED
|
@@ -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'
|