s3db.js 6.1.0 → 7.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.
Files changed (60) hide show
  1. package/PLUGINS.md +2724 -0
  2. package/README.md +377 -492
  3. package/UNLICENSE +24 -0
  4. package/dist/s3db.cjs.js +30054 -18189
  5. package/dist/s3db.cjs.min.js +1 -1
  6. package/dist/s3db.d.ts +373 -72
  7. package/dist/s3db.es.js +30040 -18186
  8. package/dist/s3db.es.min.js +1 -1
  9. package/dist/s3db.iife.js +29727 -17863
  10. package/dist/s3db.iife.min.js +1 -1
  11. package/package.json +44 -69
  12. package/src/behaviors/body-only.js +110 -0
  13. package/src/behaviors/body-overflow.js +153 -0
  14. package/src/behaviors/enforce-limits.js +195 -0
  15. package/src/behaviors/index.js +39 -0
  16. package/src/behaviors/truncate-data.js +204 -0
  17. package/src/behaviors/user-managed.js +147 -0
  18. package/src/client.class.js +515 -0
  19. package/src/concerns/base62.js +61 -0
  20. package/src/concerns/calculator.js +204 -0
  21. package/src/concerns/crypto.js +142 -0
  22. package/src/concerns/id.js +8 -0
  23. package/src/concerns/index.js +5 -0
  24. package/src/concerns/try-fn.js +151 -0
  25. package/src/connection-string.class.js +75 -0
  26. package/src/database.class.js +599 -0
  27. package/src/errors.js +261 -0
  28. package/src/index.js +17 -0
  29. package/src/plugins/audit.plugin.js +442 -0
  30. package/src/plugins/cache/cache.class.js +53 -0
  31. package/src/plugins/cache/index.js +6 -0
  32. package/src/plugins/cache/memory-cache.class.js +164 -0
  33. package/src/plugins/cache/s3-cache.class.js +189 -0
  34. package/src/plugins/cache.plugin.js +275 -0
  35. package/src/plugins/consumers/index.js +24 -0
  36. package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
  37. package/src/plugins/consumers/sqs-consumer.js +102 -0
  38. package/src/plugins/costs.plugin.js +81 -0
  39. package/src/plugins/fulltext.plugin.js +473 -0
  40. package/src/plugins/index.js +12 -0
  41. package/src/plugins/metrics.plugin.js +603 -0
  42. package/src/plugins/plugin.class.js +210 -0
  43. package/src/plugins/plugin.obj.js +13 -0
  44. package/src/plugins/queue-consumer.plugin.js +134 -0
  45. package/src/plugins/replicator.plugin.js +769 -0
  46. package/src/plugins/replicators/base-replicator.class.js +85 -0
  47. package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
  48. package/src/plugins/replicators/index.js +44 -0
  49. package/src/plugins/replicators/postgres-replicator.class.js +427 -0
  50. package/src/plugins/replicators/s3db-replicator.class.js +352 -0
  51. package/src/plugins/replicators/sqs-replicator.class.js +427 -0
  52. package/src/resource.class.js +2626 -0
  53. package/src/s3db.d.ts +1263 -0
  54. package/src/schema.class.js +706 -0
  55. package/src/stream/index.js +16 -0
  56. package/src/stream/resource-ids-page-reader.class.js +10 -0
  57. package/src/stream/resource-ids-reader.class.js +63 -0
  58. package/src/stream/resource-reader.class.js +81 -0
  59. package/src/stream/resource-writer.class.js +92 -0
  60. package/src/validator.class.js +97 -0
@@ -0,0 +1,515 @@
1
+ import path from "path";
2
+ import EventEmitter from "events";
3
+ import { chunk } from "lodash-es";
4
+ import { PromisePool } from "@supercharge/promise-pool";
5
+
6
+ import { idGenerator } from "./concerns/id.js";
7
+ import tryFn from "./concerns/try-fn.js";
8
+
9
+ import {
10
+ S3Client,
11
+ PutObjectCommand,
12
+ GetObjectCommand,
13
+ CopyObjectCommand,
14
+ HeadObjectCommand,
15
+ DeleteObjectCommand,
16
+ DeleteObjectsCommand,
17
+ ListObjectsV2Command,
18
+ } from '@aws-sdk/client-s3';
19
+ import { md5 } from 'hash-wasm';
20
+
21
+ import { mapAwsError, UnknownError, NoSuchKey, NotFound } from "./errors.js";
22
+ import { ConnectionString } from "./connection-string.class.js";
23
+
24
+ export class Client extends EventEmitter {
25
+ constructor({
26
+ verbose = false,
27
+ id = null,
28
+ AwsS3Client,
29
+ connectionString,
30
+ parallelism = 10,
31
+ }) {
32
+ super();
33
+ this.verbose = verbose;
34
+ this.id = id ?? idGenerator();
35
+ this.parallelism = parallelism;
36
+ this.config = new ConnectionString(connectionString);
37
+ this.client = AwsS3Client || this.createClient()
38
+ }
39
+
40
+ createClient() {
41
+ let options = {
42
+ region: this.config.region,
43
+ endpoint: this.config.endpoint,
44
+ }
45
+
46
+ if (this.config.forcePathStyle) options.forcePathStyle = true
47
+
48
+ if (this.config.accessKeyId) {
49
+ options.credentials = {
50
+ accessKeyId: this.config.accessKeyId,
51
+ secretAccessKey: this.config.secretAccessKey,
52
+ }
53
+ }
54
+
55
+ const client = new S3Client(options);
56
+
57
+ // Adiciona middleware para Content-MD5 em DeleteObjectsCommand
58
+ client.middlewareStack.add(
59
+ (next, context) => async (args) => {
60
+ if (context.commandName === 'DeleteObjectsCommand') {
61
+ const body = args.request.body;
62
+ if (body && typeof body === 'string') {
63
+ const contentMd5 = Buffer.from(await md5(body), 'hex').toString('base64');
64
+ args.request.headers['Content-MD5'] = contentMd5;
65
+ }
66
+ }
67
+ return next(args);
68
+ },
69
+ {
70
+ step: 'build',
71
+ name: 'addContentMd5ForDeleteObjects',
72
+ priority: 'high',
73
+ }
74
+ );
75
+
76
+ return client;
77
+ }
78
+
79
+ async sendCommand(command) {
80
+ this.emit("command.request", command.constructor.name, command.input);
81
+ const [ok, err, response] = await tryFn(() => this.client.send(command));
82
+ if (!ok) {
83
+ const bucket = this.config.bucket;
84
+ const key = command.input && command.input.Key;
85
+ throw mapAwsError(err, {
86
+ bucket,
87
+ key,
88
+ commandName: command.constructor.name,
89
+ commandInput: command.input,
90
+ });
91
+ }
92
+ this.emit("command.response", command.constructor.name, response, command.input);
93
+ return response;
94
+ }
95
+
96
+ async putObject({ key, metadata, contentType, body, contentEncoding, contentLength }) {
97
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
98
+ const fullKey = keyPrefix ? path.join(keyPrefix, key) : key;
99
+
100
+ // Ensure all metadata values are strings and keys are valid
101
+ const stringMetadata = {};
102
+ if (metadata) {
103
+ for (const [k, v] of Object.entries(metadata)) {
104
+ // Ensure key is a valid string and value is a string
105
+ const validKey = String(k).replace(/[^a-zA-Z0-9\-_]/g, '_');
106
+ stringMetadata[validKey] = String(v);
107
+ }
108
+ }
109
+
110
+ const options = {
111
+ Bucket: this.config.bucket,
112
+ Key: keyPrefix ? path.join(keyPrefix, key) : key,
113
+ Metadata: stringMetadata,
114
+ Body: body || Buffer.alloc(0),
115
+ };
116
+
117
+ if (contentType !== undefined) options.ContentType = contentType
118
+ if (contentEncoding !== undefined) options.ContentEncoding = contentEncoding
119
+ if (contentLength !== undefined) options.ContentLength = contentLength
120
+
121
+ let response, error;
122
+ try {
123
+ response = await this.sendCommand(new PutObjectCommand(options));
124
+ return response;
125
+ } catch (err) {
126
+ error = err;
127
+ throw mapAwsError(err, {
128
+ bucket: this.config.bucket,
129
+ key,
130
+ commandName: 'PutObjectCommand',
131
+ commandInput: options,
132
+ });
133
+ } finally {
134
+ this.emit('putObject', error || response, { key, metadata, contentType, body, contentEncoding, contentLength });
135
+ }
136
+ }
137
+
138
+ async getObject(key) {
139
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
140
+ const options = {
141
+ Bucket: this.config.bucket,
142
+ Key: keyPrefix ? path.join(keyPrefix, key) : key,
143
+ };
144
+ let response, error;
145
+ try {
146
+ response = await this.sendCommand(new GetObjectCommand(options));
147
+ return response;
148
+ } catch (err) {
149
+ error = err;
150
+ throw mapAwsError(err, {
151
+ bucket: this.config.bucket,
152
+ key,
153
+ commandName: 'GetObjectCommand',
154
+ commandInput: options,
155
+ });
156
+ } finally {
157
+ this.emit('getObject', error || response, { key });
158
+ }
159
+ }
160
+
161
+ async headObject(key) {
162
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
163
+ const options = {
164
+ Bucket: this.config.bucket,
165
+ Key: keyPrefix ? path.join(keyPrefix, key) : key,
166
+ };
167
+ let response, error;
168
+ try {
169
+ response = await this.sendCommand(new HeadObjectCommand(options));
170
+ return response;
171
+ } catch (err) {
172
+ error = err;
173
+ throw mapAwsError(err, {
174
+ bucket: this.config.bucket,
175
+ key,
176
+ commandName: 'HeadObjectCommand',
177
+ commandInput: options,
178
+ });
179
+ } finally {
180
+ this.emit('headObject', error || response, { key });
181
+ }
182
+ }
183
+
184
+ async copyObject({ from, to }) {
185
+ const options = {
186
+ Bucket: this.config.bucket,
187
+ Key: this.config.keyPrefix ? path.join(this.config.keyPrefix, to) : to,
188
+ CopySource: path.join(this.config.bucket, this.config.keyPrefix ? path.join(this.config.keyPrefix, from) : from),
189
+ };
190
+
191
+ let response, error;
192
+ try {
193
+ response = await this.sendCommand(new CopyObjectCommand(options));
194
+ return response;
195
+ } catch (err) {
196
+ error = err;
197
+ throw mapAwsError(err, {
198
+ bucket: this.config.bucket,
199
+ key: to,
200
+ commandName: 'CopyObjectCommand',
201
+ commandInput: options,
202
+ });
203
+ } finally {
204
+ this.emit('copyObject', error || response, { from, to });
205
+ }
206
+ }
207
+
208
+ async exists(key) {
209
+ const [ok, err] = await tryFn(() => this.headObject(key));
210
+ if (ok) return true;
211
+ if (err.name === "NoSuchKey" || err.name === "NotFound") return false;
212
+ throw err;
213
+ }
214
+
215
+ async deleteObject(key) {
216
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
217
+ const fullKey = keyPrefix ? path.join(keyPrefix, key) : key;
218
+ const options = {
219
+ Bucket: this.config.bucket,
220
+ Key: keyPrefix ? path.join(keyPrefix, key) : key,
221
+ };
222
+
223
+ let response, error;
224
+ try {
225
+ response = await this.sendCommand(new DeleteObjectCommand(options));
226
+ return response;
227
+ } catch (err) {
228
+ error = err;
229
+ throw mapAwsError(err, {
230
+ bucket: this.config.bucket,
231
+ key,
232
+ commandName: 'DeleteObjectCommand',
233
+ commandInput: options,
234
+ });
235
+ } finally {
236
+ this.emit('deleteObject', error || response, { key });
237
+ }
238
+ }
239
+
240
+ async deleteObjects(keys) {
241
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
242
+ const packages = chunk(keys, 1000);
243
+
244
+ const { results, errors } = await PromisePool.for(packages)
245
+ .withConcurrency(this.parallelism)
246
+ .process(async (keys) => {
247
+ // Log existence before deletion
248
+ for (const key of keys) {
249
+ const resolvedKey = keyPrefix ? path.join(keyPrefix, key) : key;
250
+ const bucket = this.config.bucket;
251
+ const existsBefore = await this.exists(key);
252
+ }
253
+ const options = {
254
+ Bucket: this.config.bucket,
255
+ Delete: {
256
+ Objects: keys.map((key) => ({
257
+ Key: keyPrefix ? path.join(keyPrefix, key) : key,
258
+ })),
259
+ },
260
+ };
261
+
262
+ // Debug log
263
+ let response;
264
+ const [ok, err, res] = await tryFn(() => this.sendCommand(new DeleteObjectsCommand(options)));
265
+ if (!ok) throw err;
266
+ response = res;
267
+ if (response && response.Errors && response.Errors.length > 0) {
268
+ // console.error('[Client][ERROR] DeleteObjectsCommand errors:', response.Errors);
269
+ }
270
+ if (response && response.Deleted && response.Deleted.length !== keys.length) {
271
+ // console.error('[Client][ERROR] Nem todos os objetos foram deletados:', response.Deleted, 'esperado:', keys);
272
+ }
273
+ return response;
274
+ });
275
+
276
+ const report = {
277
+ deleted: results,
278
+ notFound: errors,
279
+ }
280
+
281
+ this.emit("deleteObjects", report, keys);
282
+ return report;
283
+ }
284
+
285
+ /**
286
+ * Delete all objects under a specific prefix using efficient pagination
287
+ * @param {Object} options - Delete options
288
+ * @param {string} options.prefix - S3 prefix to delete
289
+ * @returns {Promise<number>} Number of objects deleted
290
+ */
291
+ async deleteAll({ prefix } = {}) {
292
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
293
+ let continuationToken;
294
+ let totalDeleted = 0;
295
+
296
+ do {
297
+ const listCommand = new ListObjectsV2Command({
298
+ Bucket: this.config.bucket,
299
+ Prefix: keyPrefix ? path.join(keyPrefix, prefix || "") : prefix || "",
300
+ ContinuationToken: continuationToken,
301
+ });
302
+
303
+ const listResponse = await this.client.send(listCommand);
304
+
305
+ if (listResponse.Contents && listResponse.Contents.length > 0) {
306
+ const deleteCommand = new DeleteObjectsCommand({
307
+ Bucket: this.config.bucket,
308
+ Delete: {
309
+ Objects: listResponse.Contents.map(obj => ({ Key: obj.Key }))
310
+ }
311
+ });
312
+
313
+ const deleteResponse = await this.client.send(deleteCommand);
314
+ const deletedCount = deleteResponse.Deleted ? deleteResponse.Deleted.length : 0;
315
+ totalDeleted += deletedCount;
316
+
317
+ this.emit("deleteAll", {
318
+ prefix,
319
+ batch: deletedCount,
320
+ total: totalDeleted
321
+ });
322
+ }
323
+
324
+ continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
325
+ } while (continuationToken);
326
+
327
+ this.emit("deleteAllComplete", {
328
+ prefix,
329
+ totalDeleted
330
+ });
331
+
332
+ return totalDeleted;
333
+ }
334
+
335
+ async moveObject({ from, to }) {
336
+ const [ok, err] = await tryFn(async () => {
337
+ await this.copyObject({ from, to });
338
+ await this.deleteObject(from);
339
+ });
340
+ if (!ok) {
341
+ throw new UnknownError("Unknown error in moveObject", { bucket: this.config.bucket, from, to, original: err });
342
+ }
343
+ return true;
344
+ }
345
+
346
+ async listObjects({
347
+ prefix,
348
+ maxKeys = 1000,
349
+ continuationToken,
350
+ } = {}) {
351
+ const options = {
352
+ Bucket: this.config.bucket,
353
+ MaxKeys: maxKeys,
354
+ ContinuationToken: continuationToken,
355
+ Prefix: this.config.keyPrefix
356
+ ? path.join(this.config.keyPrefix, prefix || "")
357
+ : prefix || "",
358
+ };
359
+ const [ok, err, response] = await tryFn(() => this.sendCommand(new ListObjectsV2Command(options)));
360
+ if (!ok) {
361
+ throw new UnknownError("Unknown error in listObjects", { prefix, bucket: this.config.bucket, original: err });
362
+ }
363
+ this.emit("listObjects", response, options);
364
+ return response;
365
+ }
366
+
367
+ async count({ prefix } = {}) {
368
+ let count = 0;
369
+ let truncated = true;
370
+ let continuationToken;
371
+ while (truncated) {
372
+ const options = {
373
+ prefix,
374
+ continuationToken,
375
+ };
376
+ const response = await this.listObjects(options);
377
+ count += response.KeyCount || 0;
378
+ truncated = response.IsTruncated || false;
379
+ continuationToken = response.NextContinuationToken;
380
+ }
381
+ this.emit("count", count, { prefix });
382
+ return count;
383
+ }
384
+
385
+ async getAllKeys({ prefix } = {}) {
386
+ let keys = [];
387
+ let truncated = true;
388
+ let continuationToken;
389
+ while (truncated) {
390
+ const options = {
391
+ prefix,
392
+ continuationToken,
393
+ };
394
+ const response = await this.listObjects(options);
395
+ if (response.Contents) {
396
+ keys = keys.concat(response.Contents.map((x) => x.Key));
397
+ }
398
+ truncated = response.IsTruncated || false;
399
+ continuationToken = response.NextContinuationToken;
400
+ }
401
+ if (this.config.keyPrefix) {
402
+ keys = keys
403
+ .map((x) => x.replace(this.config.keyPrefix, ""))
404
+ .map((x) => (x.startsWith("/") ? x.replace(`/`, "") : x));
405
+ }
406
+ this.emit("getAllKeys", keys, { prefix });
407
+ return keys;
408
+ }
409
+
410
+ async getContinuationTokenAfterOffset(params = {}) {
411
+ const {
412
+ prefix,
413
+ offset = 1000,
414
+ } = params
415
+ if (offset === 0) return null;
416
+ let truncated = true;
417
+ let continuationToken;
418
+ let skipped = 0;
419
+ while (truncated) {
420
+ let maxKeys =
421
+ offset < 1000
422
+ ? offset
423
+ : offset - skipped > 1000
424
+ ? 1000
425
+ : offset - skipped;
426
+ const options = {
427
+ prefix,
428
+ maxKeys,
429
+ continuationToken,
430
+ };
431
+ const res = await this.listObjects(options);
432
+ if (res.Contents) {
433
+ skipped += res.Contents.length;
434
+ }
435
+ truncated = res.IsTruncated || false;
436
+ continuationToken = res.NextContinuationToken;
437
+ if (skipped >= offset) {
438
+ break;
439
+ }
440
+ }
441
+ this.emit("getContinuationTokenAfterOffset", continuationToken || null, params);
442
+ return continuationToken || null;
443
+ }
444
+
445
+ async getKeysPage(params = {}) {
446
+ const {
447
+ prefix,
448
+ offset = 0,
449
+ amount = 100,
450
+ } = params
451
+ let keys = [];
452
+ let truncated = true;
453
+ let continuationToken;
454
+ if (offset > 0) {
455
+ continuationToken = await this.getContinuationTokenAfterOffset({
456
+ prefix,
457
+ offset,
458
+ });
459
+ if (!continuationToken) {
460
+ this.emit("getKeysPage", [], params);
461
+ return [];
462
+ }
463
+ }
464
+ while (truncated) {
465
+ const options = {
466
+ prefix,
467
+ continuationToken,
468
+ };
469
+ const res = await this.listObjects(options);
470
+ if (res.Contents) {
471
+ keys = keys.concat(res.Contents.map((x) => x.Key));
472
+ }
473
+ truncated = res.IsTruncated || false;
474
+ continuationToken = res.NextContinuationToken;
475
+ if (keys.length >= amount) {
476
+ keys = keys.slice(0, amount);
477
+ break;
478
+ }
479
+ }
480
+ if (this.config.keyPrefix) {
481
+ keys = keys
482
+ .map((x) => x.replace(this.config.keyPrefix, ""))
483
+ .map((x) => (x.startsWith("/") ? x.replace(`/`, "") : x));
484
+ }
485
+ this.emit("getKeysPage", keys, params);
486
+ return keys;
487
+ }
488
+
489
+ async moveAllObjects({ prefixFrom, prefixTo }) {
490
+ const keys = await this.getAllKeys({ prefix: prefixFrom });
491
+ const { results, errors } = await PromisePool
492
+ .for(keys)
493
+ .withConcurrency(this.parallelism)
494
+ .process(async (key) => {
495
+ const to = key.replace(prefixFrom, prefixTo)
496
+ const [ok, err] = await tryFn(async () => {
497
+ await this.moveObject({
498
+ from: key,
499
+ to,
500
+ });
501
+ });
502
+ if (!ok) {
503
+ throw new UnknownError("Unknown error in moveAllObjects", { bucket: this.config.bucket, from: key, to, original: err });
504
+ }
505
+ return to;
506
+ });
507
+ this.emit("moveAllObjects", { results, errors }, { prefixFrom, prefixTo });
508
+ if (errors.length > 0) {
509
+ throw new Error("Some objects could not be moved");
510
+ }
511
+ return results;
512
+ }
513
+ }
514
+
515
+ export default Client;
@@ -0,0 +1,61 @@
1
+ const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
2
+ const base = alphabet.length;
3
+ const charToValue = Object.fromEntries([...alphabet].map((c, i) => [c, i]));
4
+
5
+ export const encode = n => {
6
+ if (typeof n !== 'number' || isNaN(n)) return 'undefined';
7
+ if (!isFinite(n)) return 'undefined';
8
+ if (n === 0) return alphabet[0];
9
+ if (n < 0) return '-' + encode(-Math.floor(n));
10
+ n = Math.floor(n);
11
+ let s = '';
12
+ while (n) {
13
+ s = alphabet[n % base] + s;
14
+ n = Math.floor(n / base);
15
+ }
16
+ return s;
17
+ };
18
+
19
+ export const decode = s => {
20
+ if (typeof s !== 'string') return NaN;
21
+ if (s === '') return 0;
22
+ let negative = false;
23
+ if (s[0] === '-') {
24
+ negative = true;
25
+ s = s.slice(1);
26
+ }
27
+ let r = 0;
28
+ for (let i = 0; i < s.length; i++) {
29
+ const idx = charToValue[s[i]];
30
+ if (idx === undefined) return NaN;
31
+ r = r * base + idx;
32
+ }
33
+ return negative ? -r : r;
34
+ };
35
+
36
+ export const encodeDecimal = n => {
37
+ if (typeof n !== 'number' || isNaN(n)) return 'undefined';
38
+ if (!isFinite(n)) return 'undefined';
39
+ const negative = n < 0;
40
+ n = Math.abs(n);
41
+ const [intPart, decPart] = n.toString().split('.');
42
+ const encodedInt = encode(Number(intPart));
43
+ if (decPart) {
44
+ return (negative ? '-' : '') + encodedInt + '.' + decPart;
45
+ }
46
+ return (negative ? '-' : '') + encodedInt;
47
+ };
48
+
49
+ export const decodeDecimal = s => {
50
+ if (typeof s !== 'string') return NaN;
51
+ let negative = false;
52
+ if (s[0] === '-') {
53
+ negative = true;
54
+ s = s.slice(1);
55
+ }
56
+ const [intPart, decPart] = s.split('.');
57
+ const decodedInt = decode(intPart);
58
+ if (isNaN(decodedInt)) return NaN;
59
+ const num = decPart ? Number(decodedInt + '.' + decPart) : decodedInt;
60
+ return negative ? -num : num;
61
+ };