s3db.js 12.2.4 → 12.4.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.
@@ -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 "./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";
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 Client extends EventEmitter {
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 S3Client(options);
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 default Client;
593
+ // Default export for backward compatibility
594
+ export default S3Client;
@@ -190,10 +190,20 @@ export async function generateTypes(database, options = {}) {
190
190
  const resourceInterfaces = [];
191
191
 
192
192
  for (const [name, resource] of Object.entries(database.resources)) {
193
- const attributes = resource.config?.attributes || resource.attributes || {};
193
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
194
194
  const timestamps = resource.config?.timestamps || false;
195
195
 
196
- const interfaceDef = generateResourceInterface(name, attributes, timestamps);
196
+ // Filter out plugin attributes - they are internal implementation details
197
+ // and should not be exposed in public TypeScript interfaces
198
+ const pluginAttrNames = resource.schema?._pluginAttributes
199
+ ? Object.values(resource.schema._pluginAttributes).flat()
200
+ : [];
201
+
202
+ const userAttributes = Object.fromEntries(
203
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
204
+ );
205
+
206
+ const interfaceDef = generateResourceInterface(name, userAttributes, timestamps);
197
207
  lines.push(interfaceDef);
198
208
 
199
209
  resourceInterfaces.push({
@@ -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 Client from "./client.class.js";
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";
@@ -98,7 +98,7 @@ export class Database extends EventEmitter {
98
98
  }
99
99
  }
100
100
 
101
- this.client = options.client || new Client({
101
+ this.client = options.client || new S3Client({
102
102
  verbose: this.verbose,
103
103
  parallelism: this.parallelism,
104
104
  connectionString: connectionString,
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'
@@ -98,7 +98,17 @@ function generateResourceSchema(resource) {
98
98
  const properties = {};
99
99
  const required = [];
100
100
 
101
- const attributes = resource.config?.attributes || resource.attributes || {};
101
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
102
+
103
+ // Filter out plugin attributes - they are internal implementation details
104
+ // and should not be exposed in public API documentation
105
+ const pluginAttrNames = resource.schema?._pluginAttributes
106
+ ? Object.values(resource.schema._pluginAttributes).flat()
107
+ : [];
108
+
109
+ const attributes = Object.fromEntries(
110
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
111
+ );
102
112
 
103
113
  // Extract resource description (supports both string and object format)
104
114
  const resourceDescription = resource.config?.description;
@@ -254,7 +264,16 @@ function generateResourcePaths(resource, version, config = {}) {
254
264
  }
255
265
 
256
266
  // Create query parameters only for partition fields
257
- const attributes = resource.config?.attributes || resource.attributes || {};
267
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
268
+
269
+ // Filter out plugin attributes
270
+ const pluginAttrNames = resource.schema?._pluginAttributes
271
+ ? Object.values(resource.schema._pluginAttributes).flat()
272
+ : [];
273
+
274
+ const attributes = Object.fromEntries(
275
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
276
+ );
258
277
 
259
278
  for (const fieldName of partitionFieldsSet) {
260
279
  const fieldDef = attributes[fieldName];