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.
- package/README.md +117 -0
- package/dist/s3db.cjs.js +3075 -66
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +3074 -69
- package/dist/s3db.es.js.map +1 -1
- package/package.json +6 -2
- package/src/clients/index.js +14 -0
- package/src/clients/memory-client.class.js +900 -0
- package/src/clients/memory-client.md +917 -0
- package/src/clients/memory-storage.class.js +504 -0
- package/src/{client.class.js → clients/s3-client.class.js} +11 -10
- package/src/database.class.js +54 -19
- package/src/index.js +2 -1
- 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/plugins/replicators/bigquery-replicator.class.js +100 -20
- package/src/plugins/replicators/schema-sync.helper.js +34 -2
- package/src/plugins/tfstate/s3-driver.js +3 -3
- package/src/resource.class.js +106 -34
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryStorage - Internal Storage Engine for Memory Client
|
|
3
|
+
*
|
|
4
|
+
* Simulates S3 object storage in memory using Map data structure.
|
|
5
|
+
* Supports snapshot/restore, persistence, and configurable limits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { writeFile, readFile } from 'fs/promises';
|
|
10
|
+
import { Readable } from 'stream';
|
|
11
|
+
import tryFn from '../concerns/try-fn.js';
|
|
12
|
+
|
|
13
|
+
export class MemoryStorage {
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
/**
|
|
16
|
+
* Main storage: Map<key, ObjectData>
|
|
17
|
+
* ObjectData: { body, metadata, contentType, etag, lastModified, size, contentEncoding, contentLength }
|
|
18
|
+
*/
|
|
19
|
+
this.objects = new Map();
|
|
20
|
+
|
|
21
|
+
// Configuration
|
|
22
|
+
this.bucket = config.bucket || 's3db';
|
|
23
|
+
this.enforceLimits = config.enforceLimits || false;
|
|
24
|
+
this.metadataLimit = config.metadataLimit || 2048; // 2KB like S3
|
|
25
|
+
this.maxObjectSize = config.maxObjectSize || 5 * 1024 * 1024 * 1024; // 5GB
|
|
26
|
+
this.persistPath = config.persistPath;
|
|
27
|
+
this.autoPersist = config.autoPersist || false;
|
|
28
|
+
this.verbose = config.verbose || false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate ETag (MD5 hash) for object body
|
|
33
|
+
*/
|
|
34
|
+
_generateETag(body) {
|
|
35
|
+
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
|
|
36
|
+
return createHash('md5').update(buffer).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calculate metadata size in bytes
|
|
41
|
+
*/
|
|
42
|
+
_calculateMetadataSize(metadata) {
|
|
43
|
+
if (!metadata) return 0;
|
|
44
|
+
|
|
45
|
+
let size = 0;
|
|
46
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
47
|
+
// S3 counts key + value in UTF-8 bytes
|
|
48
|
+
size += Buffer.byteLength(key, 'utf8');
|
|
49
|
+
size += Buffer.byteLength(String(value), 'utf8');
|
|
50
|
+
}
|
|
51
|
+
return size;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate limits if enforceLimits is enabled
|
|
56
|
+
*/
|
|
57
|
+
_validateLimits(body, metadata) {
|
|
58
|
+
if (!this.enforceLimits) return;
|
|
59
|
+
|
|
60
|
+
// Check metadata size
|
|
61
|
+
const metadataSize = this._calculateMetadataSize(metadata);
|
|
62
|
+
if (metadataSize > this.metadataLimit) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Metadata size (${metadataSize} bytes) exceeds limit of ${this.metadataLimit} bytes`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check object size
|
|
69
|
+
const bodySize = Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || '', 'utf8');
|
|
70
|
+
if (bodySize > this.maxObjectSize) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Object size (${bodySize} bytes) exceeds limit of ${this.maxObjectSize} bytes`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Store an object
|
|
79
|
+
*/
|
|
80
|
+
async put(key, { body, metadata, contentType, contentEncoding, contentLength, ifMatch }) {
|
|
81
|
+
// Validate limits
|
|
82
|
+
this._validateLimits(body, metadata);
|
|
83
|
+
|
|
84
|
+
// Check ifMatch (conditional put)
|
|
85
|
+
if (ifMatch !== undefined) {
|
|
86
|
+
const existing = this.objects.get(key);
|
|
87
|
+
if (existing && existing.etag !== ifMatch) {
|
|
88
|
+
throw new Error(`Precondition failed: ETag mismatch for key "${key}"`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
|
|
93
|
+
const etag = this._generateETag(buffer);
|
|
94
|
+
const lastModified = new Date().toISOString();
|
|
95
|
+
const size = buffer.length;
|
|
96
|
+
|
|
97
|
+
const objectData = {
|
|
98
|
+
body: buffer,
|
|
99
|
+
metadata: metadata || {},
|
|
100
|
+
contentType: contentType || 'application/octet-stream',
|
|
101
|
+
etag,
|
|
102
|
+
lastModified,
|
|
103
|
+
size,
|
|
104
|
+
contentEncoding,
|
|
105
|
+
contentLength: contentLength || size
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.objects.set(key, objectData);
|
|
109
|
+
|
|
110
|
+
if (this.verbose) {
|
|
111
|
+
console.log(`[MemoryStorage] PUT ${key} (${size} bytes, etag: ${etag})`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Auto-persist if enabled
|
|
115
|
+
if (this.autoPersist && this.persistPath) {
|
|
116
|
+
await this.saveToDisk();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ETag: etag,
|
|
121
|
+
VersionId: null, // Memory storage doesn't support versioning
|
|
122
|
+
ServerSideEncryption: null,
|
|
123
|
+
Location: `/${this.bucket}/${key}`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Retrieve an object
|
|
129
|
+
*/
|
|
130
|
+
async get(key) {
|
|
131
|
+
const obj = this.objects.get(key);
|
|
132
|
+
|
|
133
|
+
if (!obj) {
|
|
134
|
+
const error = new Error(`Object not found: ${key}`);
|
|
135
|
+
error.name = 'NoSuchKey';
|
|
136
|
+
error.$metadata = {
|
|
137
|
+
httpStatusCode: 404,
|
|
138
|
+
requestId: 'memory-' + Date.now(),
|
|
139
|
+
attempts: 1,
|
|
140
|
+
totalRetryDelay: 0
|
|
141
|
+
};
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (this.verbose) {
|
|
146
|
+
console.log(`[MemoryStorage] GET ${key} (${obj.size} bytes)`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Convert Buffer to Readable stream (same as real S3 Client)
|
|
150
|
+
const bodyStream = Readable.from(obj.body);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
Body: bodyStream,
|
|
154
|
+
Metadata: { ...obj.metadata },
|
|
155
|
+
ContentType: obj.contentType,
|
|
156
|
+
ContentLength: obj.size,
|
|
157
|
+
ETag: obj.etag,
|
|
158
|
+
LastModified: new Date(obj.lastModified),
|
|
159
|
+
ContentEncoding: obj.contentEncoding
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get object metadata only (like S3 HeadObject)
|
|
165
|
+
*/
|
|
166
|
+
async head(key) {
|
|
167
|
+
const obj = this.objects.get(key);
|
|
168
|
+
|
|
169
|
+
if (!obj) {
|
|
170
|
+
const error = new Error(`Object not found: ${key}`);
|
|
171
|
+
error.name = 'NoSuchKey';
|
|
172
|
+
error.$metadata = {
|
|
173
|
+
httpStatusCode: 404,
|
|
174
|
+
requestId: 'memory-' + Date.now(),
|
|
175
|
+
attempts: 1,
|
|
176
|
+
totalRetryDelay: 0
|
|
177
|
+
};
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.verbose) {
|
|
182
|
+
console.log(`[MemoryStorage] HEAD ${key}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
Metadata: { ...obj.metadata },
|
|
187
|
+
ContentType: obj.contentType,
|
|
188
|
+
ContentLength: obj.size,
|
|
189
|
+
ETag: obj.etag,
|
|
190
|
+
LastModified: new Date(obj.lastModified),
|
|
191
|
+
ContentEncoding: obj.contentEncoding
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Copy an object
|
|
197
|
+
*/
|
|
198
|
+
async copy(from, to, { metadata, metadataDirective, contentType }) {
|
|
199
|
+
const source = this.objects.get(from);
|
|
200
|
+
|
|
201
|
+
if (!source) {
|
|
202
|
+
const error = new Error(`Source object not found: ${from}`);
|
|
203
|
+
error.name = 'NoSuchKey';
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Determine final metadata based on directive
|
|
208
|
+
let finalMetadata = { ...source.metadata };
|
|
209
|
+
if (metadataDirective === 'REPLACE' && metadata) {
|
|
210
|
+
finalMetadata = metadata;
|
|
211
|
+
} else if (metadata) {
|
|
212
|
+
finalMetadata = { ...finalMetadata, ...metadata };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Copy the object
|
|
216
|
+
const result = await this.put(to, {
|
|
217
|
+
body: source.body,
|
|
218
|
+
metadata: finalMetadata,
|
|
219
|
+
contentType: contentType || source.contentType,
|
|
220
|
+
contentEncoding: source.contentEncoding
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (this.verbose) {
|
|
224
|
+
console.log(`[MemoryStorage] COPY ${from} → ${to}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check if object exists
|
|
232
|
+
*/
|
|
233
|
+
exists(key) {
|
|
234
|
+
return this.objects.has(key);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Delete an object
|
|
239
|
+
*/
|
|
240
|
+
async delete(key) {
|
|
241
|
+
const existed = this.objects.has(key);
|
|
242
|
+
this.objects.delete(key);
|
|
243
|
+
|
|
244
|
+
if (this.verbose) {
|
|
245
|
+
console.log(`[MemoryStorage] DELETE ${key} (existed: ${existed})`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Auto-persist if enabled
|
|
249
|
+
if (this.autoPersist && this.persistPath) {
|
|
250
|
+
await this.saveToDisk();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
DeleteMarker: false,
|
|
255
|
+
VersionId: null
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Delete multiple objects (batch)
|
|
261
|
+
*/
|
|
262
|
+
async deleteMultiple(keys) {
|
|
263
|
+
const deleted = [];
|
|
264
|
+
const errors = [];
|
|
265
|
+
|
|
266
|
+
for (const key of keys) {
|
|
267
|
+
try {
|
|
268
|
+
await this.delete(key);
|
|
269
|
+
deleted.push({ Key: key });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
errors.push({
|
|
272
|
+
Key: key,
|
|
273
|
+
Code: error.name || 'InternalError',
|
|
274
|
+
Message: error.message
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (this.verbose) {
|
|
280
|
+
console.log(`[MemoryStorage] DELETE BATCH (${deleted.length} deleted, ${errors.length} errors)`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { Deleted: deleted, Errors: errors };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* List objects with prefix/delimiter support
|
|
288
|
+
*/
|
|
289
|
+
async list({ prefix = '', delimiter = null, maxKeys = 1000, continuationToken = null }) {
|
|
290
|
+
const allKeys = Array.from(this.objects.keys());
|
|
291
|
+
|
|
292
|
+
// Filter by prefix
|
|
293
|
+
let filteredKeys = prefix
|
|
294
|
+
? allKeys.filter(key => key.startsWith(prefix))
|
|
295
|
+
: allKeys;
|
|
296
|
+
|
|
297
|
+
// Sort keys
|
|
298
|
+
filteredKeys.sort();
|
|
299
|
+
|
|
300
|
+
// Handle continuation token (simple offset-based pagination)
|
|
301
|
+
let startIndex = 0;
|
|
302
|
+
if (continuationToken) {
|
|
303
|
+
startIndex = parseInt(continuationToken) || 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Apply pagination
|
|
307
|
+
const paginatedKeys = filteredKeys.slice(startIndex, startIndex + maxKeys);
|
|
308
|
+
const isTruncated = startIndex + maxKeys < filteredKeys.length;
|
|
309
|
+
const nextContinuationToken = isTruncated ? String(startIndex + maxKeys) : null;
|
|
310
|
+
|
|
311
|
+
// Group by common prefixes if delimiter is set
|
|
312
|
+
const commonPrefixes = new Set();
|
|
313
|
+
const contents = [];
|
|
314
|
+
|
|
315
|
+
for (const key of paginatedKeys) {
|
|
316
|
+
if (delimiter && prefix) {
|
|
317
|
+
// Find the next delimiter after prefix
|
|
318
|
+
const suffix = key.substring(prefix.length);
|
|
319
|
+
const delimiterIndex = suffix.indexOf(delimiter);
|
|
320
|
+
|
|
321
|
+
if (delimiterIndex !== -1) {
|
|
322
|
+
// This key has a delimiter - add to common prefixes
|
|
323
|
+
const commonPrefix = prefix + suffix.substring(0, delimiterIndex + 1);
|
|
324
|
+
commonPrefixes.add(commonPrefix);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Add to contents
|
|
330
|
+
const obj = this.objects.get(key);
|
|
331
|
+
contents.push({
|
|
332
|
+
Key: key,
|
|
333
|
+
Size: obj.size,
|
|
334
|
+
LastModified: new Date(obj.lastModified),
|
|
335
|
+
ETag: obj.etag,
|
|
336
|
+
StorageClass: 'STANDARD'
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (this.verbose) {
|
|
341
|
+
console.log(`[MemoryStorage] LIST prefix="${prefix}" (${contents.length} objects, ${commonPrefixes.size} prefixes)`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
Contents: contents,
|
|
346
|
+
CommonPrefixes: Array.from(commonPrefixes).map(prefix => ({ Prefix: prefix })),
|
|
347
|
+
IsTruncated: isTruncated,
|
|
348
|
+
NextContinuationToken: nextContinuationToken,
|
|
349
|
+
KeyCount: contents.length + commonPrefixes.size,
|
|
350
|
+
MaxKeys: maxKeys,
|
|
351
|
+
Prefix: prefix,
|
|
352
|
+
Delimiter: delimiter
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Create a snapshot of current state
|
|
358
|
+
*/
|
|
359
|
+
snapshot() {
|
|
360
|
+
const snapshot = {
|
|
361
|
+
timestamp: new Date().toISOString(),
|
|
362
|
+
bucket: this.bucket,
|
|
363
|
+
objectCount: this.objects.size,
|
|
364
|
+
objects: {}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
for (const [key, obj] of this.objects.entries()) {
|
|
368
|
+
snapshot.objects[key] = {
|
|
369
|
+
body: obj.body.toString('base64'),
|
|
370
|
+
metadata: obj.metadata,
|
|
371
|
+
contentType: obj.contentType,
|
|
372
|
+
etag: obj.etag,
|
|
373
|
+
lastModified: obj.lastModified,
|
|
374
|
+
size: obj.size,
|
|
375
|
+
contentEncoding: obj.contentEncoding,
|
|
376
|
+
contentLength: obj.contentLength
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return snapshot;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Restore from a snapshot
|
|
385
|
+
*/
|
|
386
|
+
restore(snapshot) {
|
|
387
|
+
if (!snapshot || !snapshot.objects) {
|
|
388
|
+
throw new Error('Invalid snapshot format');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.objects.clear();
|
|
392
|
+
|
|
393
|
+
for (const [key, obj] of Object.entries(snapshot.objects)) {
|
|
394
|
+
this.objects.set(key, {
|
|
395
|
+
body: Buffer.from(obj.body, 'base64'),
|
|
396
|
+
metadata: obj.metadata,
|
|
397
|
+
contentType: obj.contentType,
|
|
398
|
+
etag: obj.etag,
|
|
399
|
+
lastModified: obj.lastModified,
|
|
400
|
+
size: obj.size,
|
|
401
|
+
contentEncoding: obj.contentEncoding,
|
|
402
|
+
contentLength: obj.contentLength
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (this.verbose) {
|
|
407
|
+
console.log(`[MemoryStorage] Restored snapshot with ${this.objects.size} objects`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Save current state to disk
|
|
413
|
+
*/
|
|
414
|
+
async saveToDisk(customPath) {
|
|
415
|
+
const path = customPath || this.persistPath;
|
|
416
|
+
if (!path) {
|
|
417
|
+
throw new Error('No persist path configured');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const snapshot = this.snapshot();
|
|
421
|
+
const json = JSON.stringify(snapshot, null, 2);
|
|
422
|
+
|
|
423
|
+
const [ok, err] = await tryFn(() => writeFile(path, json, 'utf-8'));
|
|
424
|
+
|
|
425
|
+
if (!ok) {
|
|
426
|
+
throw new Error(`Failed to save to disk: ${err.message}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (this.verbose) {
|
|
430
|
+
console.log(`[MemoryStorage] Saved ${this.objects.size} objects to ${path}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return path;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Load state from disk
|
|
438
|
+
*/
|
|
439
|
+
async loadFromDisk(customPath) {
|
|
440
|
+
const path = customPath || this.persistPath;
|
|
441
|
+
if (!path) {
|
|
442
|
+
throw new Error('No persist path configured');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const [ok, err, json] = await tryFn(() => readFile(path, 'utf-8'));
|
|
446
|
+
|
|
447
|
+
if (!ok) {
|
|
448
|
+
throw new Error(`Failed to load from disk: ${err.message}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const snapshot = JSON.parse(json);
|
|
452
|
+
this.restore(snapshot);
|
|
453
|
+
|
|
454
|
+
if (this.verbose) {
|
|
455
|
+
console.log(`[MemoryStorage] Loaded ${this.objects.size} objects from ${path}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return snapshot;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get storage statistics
|
|
463
|
+
*/
|
|
464
|
+
getStats() {
|
|
465
|
+
let totalSize = 0;
|
|
466
|
+
const keys = [];
|
|
467
|
+
|
|
468
|
+
for (const [key, obj] of this.objects.entries()) {
|
|
469
|
+
totalSize += obj.size;
|
|
470
|
+
keys.push(key);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
objectCount: this.objects.size,
|
|
475
|
+
totalSize,
|
|
476
|
+
totalSizeFormatted: this._formatBytes(totalSize),
|
|
477
|
+
keys: keys.sort(),
|
|
478
|
+
bucket: this.bucket
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Format bytes for human reading
|
|
484
|
+
*/
|
|
485
|
+
_formatBytes(bytes) {
|
|
486
|
+
if (bytes === 0) return '0 Bytes';
|
|
487
|
+
const k = 1024;
|
|
488
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
489
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
490
|
+
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Clear all objects
|
|
495
|
+
*/
|
|
496
|
+
clear() {
|
|
497
|
+
this.objects.clear();
|
|
498
|
+
if (this.verbose) {
|
|
499
|
+
console.log(`[MemoryStorage] Cleared all objects`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export default MemoryStorage;
|
|
@@ -7,7 +7,7 @@ import { PromisePool } from "@supercharge/promise-pool";
|
|
|
7
7
|
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
S3Client,
|
|
10
|
+
S3Client as AwsS3Client,
|
|
11
11
|
PutObjectCommand,
|
|
12
12
|
GetObjectCommand,
|
|
13
13
|
CopyObjectCommand,
|
|
@@ -17,14 +17,14 @@ import {
|
|
|
17
17
|
ListObjectsV2Command,
|
|
18
18
|
} from '@aws-sdk/client-s3';
|
|
19
19
|
|
|
20
|
-
import tryFn from "
|
|
21
|
-
import { md5 } from "
|
|
22
|
-
import { idGenerator } from "
|
|
23
|
-
import { metadataEncode, metadataDecode } from "
|
|
24
|
-
import { ConnectionString } from "
|
|
25
|
-
import { mapAwsError, UnknownError, NoSuchKey, NotFound } from "
|
|
20
|
+
import tryFn from "../concerns/try-fn.js";
|
|
21
|
+
import { md5 } from "../concerns/crypto.js";
|
|
22
|
+
import { idGenerator } from "../concerns/id.js";
|
|
23
|
+
import { metadataEncode, metadataDecode } from "../concerns/metadata-encoding.js";
|
|
24
|
+
import { ConnectionString } from "../connection-string.class.js";
|
|
25
|
+
import { mapAwsError, UnknownError, NoSuchKey, NotFound } from "../errors.js";
|
|
26
26
|
|
|
27
|
-
export class
|
|
27
|
+
export class S3Client extends EventEmitter {
|
|
28
28
|
constructor({
|
|
29
29
|
verbose = false,
|
|
30
30
|
id = null,
|
|
@@ -75,7 +75,7 @@ export class Client extends EventEmitter {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const client = new
|
|
78
|
+
const client = new AwsS3Client(options);
|
|
79
79
|
|
|
80
80
|
// Adiciona middleware para Content-MD5 em DeleteObjectsCommand
|
|
81
81
|
client.middlewareStack.add(
|
|
@@ -590,4 +590,5 @@ export class Client extends EventEmitter {
|
|
|
590
590
|
}
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
-
export
|
|
593
|
+
// Default export for backward compatibility
|
|
594
|
+
export default S3Client;
|
package/src/database.class.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash } from "crypto";
|
|
|
3
3
|
import { isEmpty, isFunction } from "lodash-es";
|
|
4
4
|
import jsonStableStringify from "json-stable-stringify";
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { S3Client } from "./clients/s3-client.class.js";
|
|
7
7
|
import tryFn from "./concerns/try-fn.js";
|
|
8
8
|
import Resource from "./resource.class.js";
|
|
9
9
|
import { ResourceNotFound, DatabaseError } from "./errors.js";
|
|
@@ -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();
|
|
@@ -98,7 +104,7 @@ export class Database extends EventEmitter {
|
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
this.client = options.client || new
|
|
107
|
+
this.client = options.client || new S3Client({
|
|
102
108
|
verbose: this.verbose,
|
|
103
109
|
parallelism: this.parallelism,
|
|
104
110
|
connectionString: connectionString,
|
|
@@ -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/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// directories (keep wildcard exports for these)
|
|
2
2
|
export * from './concerns/index.js'
|
|
3
3
|
export * from './plugins/index.js'
|
|
4
|
+
export * from './clients/index.js'
|
|
4
5
|
export * from './errors.js'
|
|
5
6
|
|
|
6
7
|
// main classes (explicit named exports for better tree-shaking)
|
|
7
8
|
export { Database as S3db } from './database.class.js'
|
|
8
9
|
export { Database } from './database.class.js'
|
|
9
|
-
export { Client } from './client.class.js'
|
|
10
|
+
export { S3Client as Client } from './clients/s3-client.class.js' // backward compatibility
|
|
10
11
|
export { Resource } from './resource.class.js'
|
|
11
12
|
export { Schema } from './schema.class.js'
|
|
12
13
|
export { Validator } from './validator.class.js'
|