s3db.js 6.2.0 → 7.0.1

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 +372 -469
  3. package/UNLICENSE +24 -0
  4. package/dist/s3db.cjs.js +12105 -19396
  5. package/dist/s3db.cjs.min.js +1 -1
  6. package/dist/s3db.d.ts +373 -72
  7. package/dist/s3db.es.js +12090 -19393
  8. package/dist/s3db.es.min.js +1 -1
  9. package/dist/s3db.iife.js +12103 -19398
  10. package/dist/s3db.iife.min.js +1 -1
  11. package/package.json +44 -38
  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 +159 -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,2626 @@
1
+ import { join } from "path";
2
+ import EventEmitter from "events";
3
+ import { createHash } from "crypto";
4
+ import { customAlphabet, urlAlphabet } from 'nanoid';
5
+ import jsonStableStringify from "json-stable-stringify";
6
+ import { PromisePool } from "@supercharge/promise-pool";
7
+ import { chunk, cloneDeep, merge, isEmpty, isObject } from "lodash-es";
8
+
9
+ import Schema from "./schema.class.js";
10
+ import tryFn, { tryFnSync } from "./concerns/try-fn.js";
11
+ import { streamToString } from "./stream/index.js";
12
+ import { InvalidResourceItem, ResourceError, PartitionError } from "./errors.js";
13
+ import { ResourceReader, ResourceWriter } from "./stream/index.js"
14
+ import { getBehavior, DEFAULT_BEHAVIOR } from "./behaviors/index.js";
15
+ import { idGenerator as defaultIdGenerator } from "./concerns/id.js";
16
+ import { calculateTotalSize, calculateEffectiveLimit } from "./concerns/calculator.js";
17
+ import { mapAwsError } from "./errors.js";
18
+
19
+
20
+ export class Resource extends EventEmitter {
21
+ /**
22
+ * Create a new Resource instance
23
+ * @param {Object} config - Resource configuration
24
+ * @param {string} config.name - Resource name
25
+ * @param {Object} config.client - S3 client instance
26
+ * @param {string} [config.version='v0'] - Resource version
27
+ * @param {Object} [config.attributes={}] - Resource attributes schema
28
+ * @param {string} [config.behavior='user-managed'] - Resource behavior strategy
29
+ * @param {string} [config.passphrase='secret'] - Encryption passphrase
30
+ * @param {number} [config.parallelism=10] - Parallelism for bulk operations
31
+ * @param {Array} [config.observers=[]] - Observer instances
32
+ * @param {boolean} [config.cache=false] - Enable caching
33
+ * @param {boolean} [config.autoDecrypt=true] - Auto-decrypt secret fields
34
+ * @param {boolean} [config.timestamps=false] - Enable automatic timestamps
35
+ * @param {Object} [config.partitions={}] - Partition definitions
36
+ * @param {boolean} [config.paranoid=true] - Security flag for dangerous operations
37
+ * @param {boolean} [config.allNestedObjectsOptional=false] - Make nested objects optional
38
+ * @param {Object} [config.hooks={}] - Custom hooks
39
+ * @param {Object} [config.options={}] - Additional options
40
+ * @param {Function} [config.idGenerator] - Custom ID generator function
41
+ * @param {number} [config.idSize=22] - Size for auto-generated IDs
42
+ * @param {boolean} [config.versioningEnabled=false] - Enable versioning for this resource
43
+ * @example
44
+ * const users = new Resource({
45
+ * name: 'users',
46
+ * client: s3Client,
47
+ * attributes: {
48
+ * name: 'string|required',
49
+ * email: 'string|required',
50
+ * password: 'secret|required'
51
+ * },
52
+ * behavior: 'user-managed',
53
+ * passphrase: 'my-secret-key',
54
+ * timestamps: true,
55
+ * partitions: {
56
+ * byRegion: {
57
+ * fields: { region: 'string' }
58
+ * }
59
+ * },
60
+ * hooks: {
61
+ * beforeInsert: [async (data) => {
62
+ * return data;
63
+ * }]
64
+ * }
65
+ * });
66
+ *
67
+ * // With custom ID size
68
+ * const shortIdUsers = new Resource({
69
+ * name: 'users',
70
+ * client: s3Client,
71
+ * attributes: { name: 'string|required' },
72
+ * idSize: 8 // Generate 8-character IDs
73
+ * });
74
+ *
75
+ * // With custom ID generator function
76
+ * const customIdUsers = new Resource({
77
+ * name: 'users',
78
+ * client: s3Client,
79
+ * attributes: { name: 'string|required' },
80
+ * idGenerator: () => `user_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`
81
+ * });
82
+ *
83
+ * // With custom ID generator using size parameter
84
+ * const longIdUsers = new Resource({
85
+ * name: 'users',
86
+ * client: s3Client,
87
+ * attributes: { name: 'string|required' },
88
+ * idGenerator: 32 // Generate 32-character IDs (same as idSize: 32)
89
+ * });
90
+ */
91
+ constructor(config = {}) {
92
+ super();
93
+ this._instanceId = Math.random().toString(36).slice(2, 8);
94
+
95
+ // Validate configuration
96
+ const validation = validateResourceConfig(config);
97
+ if (!validation.isValid) {
98
+ throw new ResourceError(`Invalid Resource ${config.name} configuration`, { resourceName: config.name, validation: validation.errors, operation: 'constructor', suggestion: 'Check resource config and attributes.' });
99
+ }
100
+
101
+ // Extract configuration with defaults - all at root level
102
+ const {
103
+ name,
104
+ client,
105
+ version = '1',
106
+ attributes = {},
107
+ behavior = DEFAULT_BEHAVIOR,
108
+ passphrase = 'secret',
109
+ parallelism = 10,
110
+ observers = [],
111
+ cache = false,
112
+ autoDecrypt = true,
113
+ timestamps = false,
114
+ partitions = {},
115
+ paranoid = true,
116
+ allNestedObjectsOptional = true,
117
+ hooks = {},
118
+ idGenerator: customIdGenerator,
119
+ idSize = 22,
120
+ versioningEnabled = false
121
+ } = config;
122
+
123
+ // Set instance properties
124
+ this.name = name;
125
+ this.client = client;
126
+ this.version = version;
127
+ this.behavior = behavior;
128
+ this.observers = observers;
129
+ this.parallelism = parallelism;
130
+ this.passphrase = passphrase ?? 'secret';
131
+ this.versioningEnabled = versioningEnabled;
132
+
133
+ // Configure ID generator
134
+ this.idGenerator = this.configureIdGenerator(customIdGenerator, idSize);
135
+
136
+ // Store configuration - all at root level
137
+ this.config = {
138
+ cache,
139
+ hooks,
140
+ paranoid,
141
+ timestamps,
142
+ partitions,
143
+ autoDecrypt,
144
+ allNestedObjectsOptional,
145
+ };
146
+
147
+ // Initialize hooks system
148
+ this.hooks = {
149
+ beforeInsert: [],
150
+ afterInsert: [],
151
+ beforeUpdate: [],
152
+ afterUpdate: [],
153
+ beforeDelete: [],
154
+ afterDelete: []
155
+ };
156
+
157
+ // Store attributes
158
+ this.attributes = attributes || {};
159
+
160
+ // Store map before applying configuration
161
+ this.map = config.map;
162
+
163
+ // Apply configuration settings (timestamps, partitions, hooks)
164
+ this.applyConfiguration({ map: this.map });
165
+
166
+ // Merge user-provided hooks (added last, after internal hooks)
167
+ if (hooks) {
168
+ for (const [event, hooksArr] of Object.entries(hooks)) {
169
+ if (Array.isArray(hooksArr) && this.hooks[event]) {
170
+ for (const fn of hooksArr) {
171
+ if (typeof fn === 'function') {
172
+ this.hooks[event].push(fn.bind(this));
173
+ }
174
+ // Se não for função, ignore silenciosamente
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // --- MIDDLEWARE SYSTEM ---
181
+ this._initMiddleware();
182
+ // Debug: print method names and typeof update at construction
183
+ const ownProps = Object.getOwnPropertyNames(this);
184
+ const proto = Object.getPrototypeOf(this);
185
+ const protoProps = Object.getOwnPropertyNames(proto);
186
+ }
187
+
188
+ /**
189
+ * Configure ID generator based on provided options
190
+ * @param {Function|number} customIdGenerator - Custom ID generator function or size
191
+ * @param {number} idSize - Size for auto-generated IDs
192
+ * @returns {Function} Configured ID generator function
193
+ * @private
194
+ */
195
+ configureIdGenerator(customIdGenerator, idSize) {
196
+ // If a custom function is provided, use it
197
+ if (typeof customIdGenerator === 'function') {
198
+ return customIdGenerator;
199
+ }
200
+ // If customIdGenerator is a number (size), create a generator with that size
201
+ if (typeof customIdGenerator === 'number' && customIdGenerator > 0) {
202
+ return customAlphabet(urlAlphabet, customIdGenerator);
203
+ }
204
+ // If idSize is provided, create a generator with that size
205
+ if (typeof idSize === 'number' && idSize > 0 && idSize !== 22) {
206
+ return customAlphabet(urlAlphabet, idSize);
207
+ }
208
+ // Default to the standard idGenerator (22 chars)
209
+ return defaultIdGenerator;
210
+ }
211
+
212
+ /**
213
+ * Get resource options (for backward compatibility with tests)
214
+ */
215
+ get options() {
216
+ return {
217
+ timestamps: this.config.timestamps,
218
+ partitions: this.config.partitions || {},
219
+ cache: this.config.cache,
220
+ autoDecrypt: this.config.autoDecrypt,
221
+ paranoid: this.config.paranoid,
222
+ allNestedObjectsOptional: this.config.allNestedObjectsOptional
223
+ };
224
+ }
225
+
226
+ export() {
227
+ const exported = this.schema.export();
228
+ // Add all configuration at root level
229
+ exported.behavior = this.behavior;
230
+ exported.timestamps = this.config.timestamps;
231
+ exported.partitions = this.config.partitions || {};
232
+ exported.paranoid = this.config.paranoid;
233
+ exported.allNestedObjectsOptional = this.config.allNestedObjectsOptional;
234
+ exported.autoDecrypt = this.config.autoDecrypt;
235
+ exported.cache = this.config.cache;
236
+ exported.hooks = this.hooks;
237
+ exported.map = this.map;
238
+ return exported;
239
+ }
240
+
241
+ /**
242
+ * Apply configuration settings (timestamps, partitions, hooks)
243
+ * This method ensures that all configuration-dependent features are properly set up
244
+ */
245
+ applyConfiguration({ map } = {}) {
246
+ // Handle timestamps configuration
247
+ if (this.config.timestamps) {
248
+ // Add timestamp attributes if they don't exist
249
+ if (!this.attributes.createdAt) {
250
+ this.attributes.createdAt = 'string|optional';
251
+ }
252
+ if (!this.attributes.updatedAt) {
253
+ this.attributes.updatedAt = 'string|optional';
254
+ }
255
+
256
+ // Ensure partitions object exists
257
+ if (!this.config.partitions) {
258
+ this.config.partitions = {};
259
+ }
260
+
261
+ // Add timestamp partitions if they don't exist
262
+ if (!this.config.partitions.byCreatedDate) {
263
+ this.config.partitions.byCreatedDate = {
264
+ fields: {
265
+ createdAt: 'date|maxlength:10'
266
+ }
267
+ };
268
+ }
269
+ if (!this.config.partitions.byUpdatedDate) {
270
+ this.config.partitions.byUpdatedDate = {
271
+ fields: {
272
+ updatedAt: 'date|maxlength:10'
273
+ }
274
+ };
275
+ }
276
+ }
277
+
278
+ // Setup automatic partition hooks
279
+ this.setupPartitionHooks();
280
+
281
+ // Add automatic "byVersion" partition if versioning is enabled
282
+ if (this.versioningEnabled) {
283
+ if (!this.config.partitions.byVersion) {
284
+ this.config.partitions.byVersion = {
285
+ fields: {
286
+ _v: 'string'
287
+ }
288
+ };
289
+ }
290
+ }
291
+
292
+ // Rebuild schema with current attributes
293
+ this.schema = new Schema({
294
+ name: this.name,
295
+ attributes: this.attributes,
296
+ passphrase: this.passphrase,
297
+ version: this.version,
298
+ options: {
299
+ autoDecrypt: this.config.autoDecrypt,
300
+ allNestedObjectsOptional: this.config.allNestedObjectsOptional
301
+ },
302
+ map: map || this.map
303
+ });
304
+
305
+ // Validate partitions against current attributes
306
+ this.validatePartitions();
307
+ }
308
+
309
+ /**
310
+ * Update resource attributes and rebuild schema
311
+ * @param {Object} newAttributes - New attributes definition
312
+ */
313
+ updateAttributes(newAttributes) {
314
+ // Store old attributes for comparison
315
+ const oldAttributes = this.attributes;
316
+ this.attributes = newAttributes;
317
+
318
+ // Apply configuration to ensure timestamps and hooks are set up
319
+ this.applyConfiguration({ map: this.schema?.map });
320
+
321
+ return { oldAttributes, newAttributes };
322
+ }
323
+
324
+ /**
325
+ * Add a hook function for a specific event
326
+ * @param {string} event - Hook event (beforeInsert, afterInsert, etc.)
327
+ * @param {Function} fn - Hook function
328
+ */
329
+ addHook(event, fn) {
330
+ if (this.hooks[event]) {
331
+ this.hooks[event].push(fn.bind(this));
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Execute hooks for a specific event
337
+ * @param {string} event - Hook event
338
+ * @param {*} data - Data to pass to hooks
339
+ * @returns {*} Modified data
340
+ */
341
+ async executeHooks(event, data) {
342
+ if (!this.hooks[event]) return data;
343
+
344
+ let result = data;
345
+ for (const hook of this.hooks[event]) {
346
+ result = await hook(result);
347
+ }
348
+
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Setup automatic partition hooks
354
+ */
355
+ setupPartitionHooks() {
356
+ if (!this.config.partitions) {
357
+ return;
358
+ }
359
+
360
+ const partitions = this.config.partitions;
361
+ if (Object.keys(partitions).length === 0) {
362
+ return;
363
+ }
364
+
365
+ // Add afterInsert hook to create partition references
366
+ if (!this.hooks.afterInsert) {
367
+ this.hooks.afterInsert = [];
368
+ }
369
+ this.hooks.afterInsert.push(async (data) => {
370
+ await this.createPartitionReferences(data);
371
+ return data;
372
+ });
373
+
374
+ // Add afterDelete hook to clean up partition references
375
+ if (!this.hooks.afterDelete) {
376
+ this.hooks.afterDelete = [];
377
+ }
378
+ this.hooks.afterDelete.push(async (data) => {
379
+ await this.deletePartitionReferences(data);
380
+ return data;
381
+ });
382
+ }
383
+
384
+ async validate(data) {
385
+ const result = {
386
+ original: cloneDeep(data),
387
+ isValid: false,
388
+ errors: [],
389
+ };
390
+
391
+ const check = await this.schema.validate(data, { mutateOriginal: false });
392
+
393
+ if (check === true) {
394
+ result.isValid = true;
395
+ } else {
396
+ result.errors = check;
397
+ }
398
+
399
+ result.data = data;
400
+ return result
401
+ }
402
+
403
+ /**
404
+ * Validate that all partition fields exist in current resource attributes
405
+ * @throws {Error} If partition fields don't exist in current schema
406
+ */
407
+ validatePartitions() {
408
+ if (!this.config.partitions) {
409
+ return; // No partitions to validate
410
+ }
411
+
412
+ const partitions = this.config.partitions;
413
+ if (Object.keys(partitions).length === 0) {
414
+ return; // No partitions to validate
415
+ }
416
+
417
+ const currentAttributes = Object.keys(this.attributes || {});
418
+
419
+ for (const [partitionName, partitionDef] of Object.entries(partitions)) {
420
+ if (!partitionDef.fields) {
421
+ continue; // Skip invalid partition definitions
422
+ }
423
+
424
+ for (const fieldName of Object.keys(partitionDef.fields)) {
425
+ if (!this.fieldExistsInAttributes(fieldName)) {
426
+ throw new PartitionError(`Partition '${partitionName}' uses field '${fieldName}' which does not exist in resource attributes. Available fields: ${currentAttributes.join(', ')}.`, { resourceName: this.name, partitionName, fieldName, availableFields: currentAttributes, operation: 'validatePartitions' });
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Check if a field (including nested fields) exists in the current attributes
434
+ * @param {string} fieldName - Field name (can be nested like 'utm.source')
435
+ * @returns {boolean} True if field exists
436
+ */
437
+ fieldExistsInAttributes(fieldName) {
438
+ // Allow system metadata fields (those starting with _)
439
+ if (fieldName.startsWith('_')) {
440
+ return true;
441
+ }
442
+
443
+ // Handle simple field names (no dots)
444
+ if (!fieldName.includes('.')) {
445
+ return Object.keys(this.attributes || {}).includes(fieldName);
446
+ }
447
+
448
+ // Handle nested field names using dot notation
449
+ const keys = fieldName.split('.');
450
+ let currentLevel = this.attributes || {};
451
+
452
+ for (const key of keys) {
453
+ if (!currentLevel || typeof currentLevel !== 'object' || !(key in currentLevel)) {
454
+ return false;
455
+ }
456
+ currentLevel = currentLevel[key];
457
+ }
458
+
459
+ return true;
460
+ }
461
+
462
+ /**
463
+ * Apply a single partition rule to a field value
464
+ * @param {*} value - The field value
465
+ * @param {string} rule - The partition rule
466
+ * @returns {*} Transformed value
467
+ */
468
+ applyPartitionRule(value, rule) {
469
+ if (value === undefined || value === null) {
470
+ return value;
471
+ }
472
+
473
+ let transformedValue = value;
474
+
475
+ // Apply maxlength rule manually
476
+ if (typeof rule === 'string' && rule.includes('maxlength:')) {
477
+ const maxLengthMatch = rule.match(/maxlength:(\d+)/);
478
+ if (maxLengthMatch) {
479
+ const maxLength = parseInt(maxLengthMatch[1]);
480
+ if (typeof transformedValue === 'string' && transformedValue.length > maxLength) {
481
+ transformedValue = transformedValue.substring(0, maxLength);
482
+ }
483
+ }
484
+ }
485
+
486
+ // Format date values
487
+ if (rule.includes('date')) {
488
+ if (transformedValue instanceof Date) {
489
+ transformedValue = transformedValue.toISOString().split('T')[0]; // YYYY-MM-DD format
490
+ } else if (typeof transformedValue === 'string') {
491
+ // Handle ISO8601 timestamp strings (e.g., from timestamps)
492
+ if (transformedValue.includes('T') && transformedValue.includes('Z')) {
493
+ transformedValue = transformedValue.split('T')[0]; // Extract date part from ISO8601
494
+ } else {
495
+ // Try to parse as date
496
+ const date = new Date(transformedValue);
497
+ if (!isNaN(date.getTime())) {
498
+ transformedValue = date.toISOString().split('T')[0];
499
+ }
500
+ // If parsing fails, keep original value
501
+ }
502
+ }
503
+ }
504
+
505
+ return transformedValue;
506
+ }
507
+
508
+ /**
509
+ * Get the main resource key (new format without version in path)
510
+ * @param {string} id - Resource ID
511
+ * @returns {string} The main S3 key path
512
+ */
513
+ getResourceKey(id) {
514
+ const key = join('resource=' + this.name, 'data', `id=${id}`);
515
+ // eslint-disable-next-line no-console
516
+ return key;
517
+ }
518
+
519
+ /**
520
+ * Generate partition key for a resource in a specific partition
521
+ * @param {Object} params - Partition key parameters
522
+ * @param {string} params.partitionName - Name of the partition
523
+ * @param {string} params.id - Resource ID
524
+ * @param {Object} params.data - Resource data for partition value extraction
525
+ * @returns {string|null} The partition key path or null if required fields are missing
526
+ * @example
527
+ * const partitionKey = resource.getPartitionKey({
528
+ * partitionName: 'byUtmSource',
529
+ * id: 'user-123',
530
+ * data: { utm: { source: 'google' } }
531
+ * });
532
+ * // Returns: 'resource=users/partition=byUtmSource/utm.source=google/id=user-123'
533
+ *
534
+ * // Returns null if required field is missing
535
+ * const nullKey = resource.getPartitionKey({
536
+ * partitionName: 'byUtmSource',
537
+ * id: 'user-123',
538
+ * data: { name: 'John' } // Missing utm.source
539
+ * });
540
+ * // Returns: null
541
+ */
542
+ getPartitionKey({ partitionName, id, data }) {
543
+ if (!this.config.partitions || !this.config.partitions[partitionName]) {
544
+ throw new PartitionError(`Partition '${partitionName}' not found`, { resourceName: this.name, partitionName, operation: 'getPartitionKey' });
545
+ }
546
+
547
+ const partition = this.config.partitions[partitionName];
548
+ const partitionSegments = [];
549
+
550
+ // Process each field in the partition (sorted by field name for consistency)
551
+ const sortedFields = Object.entries(partition.fields).sort(([a], [b]) => a.localeCompare(b));
552
+ for (const [fieldName, rule] of sortedFields) {
553
+ // Handle nested fields using dot notation (e.g., "utm.source", "address.city")
554
+ const fieldValue = this.getNestedFieldValue(data, fieldName);
555
+ const transformedValue = this.applyPartitionRule(fieldValue, rule);
556
+
557
+ if (transformedValue === undefined || transformedValue === null) {
558
+ return null; // Skip if any required field is missing
559
+ }
560
+
561
+ partitionSegments.push(`${fieldName}=${transformedValue}`);
562
+ }
563
+
564
+ if (partitionSegments.length === 0) {
565
+ return null;
566
+ }
567
+
568
+ // Ensure id is never undefined
569
+ const finalId = id || data?.id;
570
+ if (!finalId) {
571
+ return null; // Cannot create partition key without id
572
+ }
573
+
574
+ return join(`resource=${this.name}`, `partition=${partitionName}`, ...partitionSegments, `id=${finalId}`);
575
+ }
576
+
577
+ /**
578
+ * Get nested field value from data object using dot notation
579
+ * @param {Object} data - Data object
580
+ * @param {string} fieldPath - Field path (e.g., "utm.source", "address.city")
581
+ * @returns {*} Field value
582
+ */
583
+ getNestedFieldValue(data, fieldPath) {
584
+ // Handle simple field names (no dots)
585
+ if (!fieldPath.includes('.')) {
586
+ return data[fieldPath];
587
+ }
588
+
589
+ // Handle nested field names using dot notation
590
+ const keys = fieldPath.split('.');
591
+ let currentLevel = data;
592
+
593
+ for (const key of keys) {
594
+ if (!currentLevel || typeof currentLevel !== 'object' || !(key in currentLevel)) {
595
+ return undefined;
596
+ }
597
+ currentLevel = currentLevel[key];
598
+ }
599
+
600
+ return currentLevel;
601
+ }
602
+
603
+ /**
604
+ * Calculate estimated content length for body data
605
+ * @param {string|Buffer} body - Body content
606
+ * @returns {number} Estimated content length in bytes
607
+ */
608
+ calculateContentLength(body) {
609
+ if (!body) return 0;
610
+ if (Buffer.isBuffer(body)) return body.length;
611
+ if (typeof body === 'string') return Buffer.byteLength(body, 'utf8');
612
+ if (typeof body === 'object') return Buffer.byteLength(JSON.stringify(body), 'utf8');
613
+ return Buffer.byteLength(String(body), 'utf8');
614
+ }
615
+
616
+ /**
617
+ * Insert a new resource object
618
+ * @param {Object} attributes - Resource attributes
619
+ * @param {string} [attributes.id] - Custom ID (optional, auto-generated if not provided)
620
+ * @returns {Promise<Object>} The created resource object with all attributes
621
+ * @example
622
+ * // Insert with auto-generated ID
623
+ * const user = await resource.insert({
624
+ * name: 'John Doe',
625
+ * email: 'john@example.com',
626
+ * age: 30
627
+ * });
628
+ *
629
+ * // Insert with custom ID
630
+ * const user = await resource.insert({
631
+ * id: 'user-123',
632
+ * name: 'John Doe',
633
+ * email: 'john@example.com'
634
+ * });
635
+ */
636
+ async insert({ id, ...attributes }) {
637
+ const exists = await this.exists(id);
638
+ if (exists) throw new Error(`Resource with id '${id}' already exists`);
639
+ const keyDebug = this.getResourceKey(id || '(auto)');
640
+ if (this.options.timestamps) {
641
+ attributes.createdAt = new Date().toISOString();
642
+ attributes.updatedAt = new Date().toISOString();
643
+ }
644
+
645
+ // Aplica defaults antes de tudo
646
+ const attributesWithDefaults = this.applyDefaults(attributes);
647
+ // Reconstruct the complete data for validation
648
+ const completeData = { id, ...attributesWithDefaults };
649
+
650
+ // Execute beforeInsert hooks
651
+ const preProcessedData = await this.executeHooks('beforeInsert', completeData);
652
+
653
+ // Capture extra properties added by beforeInsert
654
+ const extraProps = Object.keys(preProcessedData).filter(
655
+ k => !(k in completeData) || preProcessedData[k] !== completeData[k]
656
+ );
657
+ const extraData = {};
658
+ for (const k of extraProps) extraData[k] = preProcessedData[k];
659
+
660
+ const {
661
+ errors,
662
+ isValid,
663
+ data: validated,
664
+ } = await this.validate(preProcessedData);
665
+
666
+ if (!isValid) {
667
+ const errorMsg = (errors && errors.length && errors[0].message) ? errors[0].message : 'Insert failed';
668
+ throw new InvalidResourceItem({
669
+ bucket: this.client.config.bucket,
670
+ resourceName: this.name,
671
+ attributes: preProcessedData,
672
+ validation: errors,
673
+ message: errorMsg
674
+ })
675
+ }
676
+
677
+ // Extract id and attributes from validated data
678
+ const { id: validatedId, ...validatedAttributes } = validated;
679
+ // Reinjetar propriedades extras do beforeInsert
680
+ Object.assign(validatedAttributes, extraData);
681
+ const finalId = validatedId || id || this.idGenerator();
682
+
683
+ const mappedData = await this.schema.mapper(validatedAttributes);
684
+ mappedData._v = String(this.version);
685
+
686
+ // Apply behavior strategy
687
+ const behaviorImpl = getBehavior(this.behavior);
688
+ const { mappedData: processedMetadata, body } = await behaviorImpl.handleInsert({
689
+ resource: this,
690
+ data: validatedAttributes,
691
+ mappedData,
692
+ originalData: completeData
693
+ });
694
+
695
+ // Add version metadata (required for all objects)
696
+ const finalMetadata = processedMetadata;
697
+ const key = this.getResourceKey(finalId);
698
+ // Determine content type based on body content
699
+ let contentType = undefined;
700
+ if (body && body !== "") {
701
+ const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(body)));
702
+ if (okParse) contentType = 'application/json';
703
+ }
704
+ // LOG: body e contentType antes do putObject
705
+ // Only throw if behavior is 'body-only' and body is empty
706
+ if (this.behavior === 'body-only' && (!body || body === "")) {
707
+ throw new Error(`[Resource.insert] Tentativa de gravar objeto sem body! Dados: id=${finalId}, resource=${this.name}`);
708
+ }
709
+ // For other behaviors, allow empty body (all data in metadata)
710
+ // Before putObject in insert
711
+ // eslint-disable-next-line no-console
712
+ const [okPut, errPut, putResult] = await tryFn(() => this.client.putObject({
713
+ key,
714
+ body,
715
+ contentType,
716
+ metadata: finalMetadata,
717
+ }));
718
+ if (!okPut) {
719
+ const msg = errPut && errPut.message ? errPut.message : '';
720
+ if (msg.includes('metadata headers exceed') || msg.includes('Insert failed')) {
721
+ const totalSize = calculateTotalSize(finalMetadata);
722
+ const effectiveLimit = calculateEffectiveLimit({
723
+ s3Limit: 2047,
724
+ systemConfig: {
725
+ version: this.version,
726
+ timestamps: this.config.timestamps,
727
+ id: finalId
728
+ }
729
+ });
730
+ const excess = totalSize - effectiveLimit;
731
+ errPut.totalSize = totalSize;
732
+ errPut.limit = 2047;
733
+ errPut.effectiveLimit = effectiveLimit;
734
+ errPut.excess = excess;
735
+ throw new ResourceError('metadata headers exceed', { resourceName: this.name, operation: 'insert', id: finalId, totalSize, effectiveLimit, excess, suggestion: 'Reduce metadata size or number of fields.' });
736
+ }
737
+ throw mapAwsError(errPut, {
738
+ bucket: this.client.config.bucket,
739
+ key,
740
+ resourceName: this.name,
741
+ operation: 'insert',
742
+ id: finalId
743
+ });
744
+ }
745
+
746
+ // Compose the full object sem reinjetar extras
747
+ let insertedData = await this.composeFullObjectFromWrite({
748
+ id: finalId,
749
+ metadata: finalMetadata,
750
+ body,
751
+ behavior: this.behavior
752
+ });
753
+
754
+ // Execute afterInsert hooks
755
+ const finalResult = await this.executeHooks('afterInsert', insertedData);
756
+ // Emit event com dados antes dos hooks afterInsert
757
+ this.emit("insert", {
758
+ ...insertedData,
759
+ $before: { ...completeData },
760
+ $after: { ...finalResult }
761
+ });
762
+ return finalResult;
763
+ }
764
+
765
+ /**
766
+ * Retrieve a resource object by ID
767
+ * @param {string} id - Resource ID
768
+ * @returns {Promise<Object>} The resource object with all attributes and metadata
769
+ * @example
770
+ * const user = await resource.get('user-123');
771
+ */
772
+ async get(id) {
773
+ if (isObject(id)) throw new Error(`id cannot be an object`);
774
+ if (isEmpty(id)) throw new Error('id cannot be empty');
775
+
776
+ const key = this.getResourceKey(id);
777
+ // LOG: início do get
778
+ // eslint-disable-next-line no-console
779
+ const [ok, err, request] = await tryFn(() => this.client.getObject(key));
780
+ // LOG: resultado do headObject
781
+ // eslint-disable-next-line no-console
782
+ if (!ok) {
783
+ throw mapAwsError(err, {
784
+ bucket: this.client.config.bucket,
785
+ key,
786
+ resourceName: this.name,
787
+ operation: 'get',
788
+ id
789
+ });
790
+ }
791
+ // Se o objeto existe mas não tem conteúdo, lançar erro NoSuchKey
792
+ if (request.ContentLength === 0) {
793
+ const noContentErr = new Error(`No such key: ${key} [bucket:${this.client.config.bucket}]`);
794
+ noContentErr.name = 'NoSuchKey';
795
+ throw mapAwsError(noContentErr, {
796
+ bucket: this.client.config.bucket,
797
+ key,
798
+ resourceName: this.name,
799
+ operation: 'get',
800
+ id
801
+ });
802
+ }
803
+
804
+ // Get the correct schema version for unmapping (from _v metadata)
805
+ const objectVersionRaw = request.Metadata?._v || this.version;
806
+ const objectVersion = typeof objectVersionRaw === 'string' && objectVersionRaw.startsWith('v') ? objectVersionRaw.slice(1) : objectVersionRaw;
807
+ const schema = await this.getSchemaForVersion(objectVersion);
808
+
809
+ let metadata = await schema.unmapper(request.Metadata);
810
+
811
+ // Apply behavior strategy for reading (important for body-overflow)
812
+ const behaviorImpl = getBehavior(this.behavior);
813
+ let body = "";
814
+
815
+ // Get body content if needed (for body-overflow behavior)
816
+ if (request.ContentLength > 0) {
817
+ const [okBody, errBody, fullObject] = await tryFn(() => this.client.getObject(key));
818
+ if (okBody) {
819
+ body = await streamToString(fullObject.Body);
820
+ } else {
821
+ // Body read failed, continue with metadata only
822
+ body = "";
823
+ }
824
+ }
825
+
826
+ const { metadata: processedMetadata } = await behaviorImpl.handleGet({
827
+ resource: this,
828
+ metadata,
829
+ body
830
+ });
831
+
832
+ // Use composeFullObjectFromWrite to ensure proper field preservation
833
+ let data = await this.composeFullObjectFromWrite({
834
+ id,
835
+ metadata: processedMetadata,
836
+ body,
837
+ behavior: this.behavior
838
+ });
839
+
840
+ data._contentLength = request.ContentLength;
841
+ data._lastModified = request.LastModified;
842
+ data._hasContent = request.ContentLength > 0;
843
+ data._mimeType = request.ContentType || null;
844
+ data._v = objectVersion;
845
+
846
+ // Add version info to returned data
847
+
848
+ if (request.VersionId) data._versionId = request.VersionId;
849
+ if (request.Expiration) data._expiresAt = request.Expiration;
850
+
851
+ data._definitionHash = this.getDefinitionHash();
852
+
853
+ // Apply version mapping if object is from a different version
854
+ if (objectVersion !== this.version) {
855
+ data = await this.applyVersionMapping(data, objectVersion, this.version);
856
+ }
857
+
858
+ this.emit("get", data);
859
+ const value = data;
860
+ return value;
861
+ }
862
+
863
+ /**
864
+ * Check if a resource exists by ID
865
+ * @returns {Promise<boolean>} True if resource exists, false otherwise
866
+ */
867
+ async exists(id) {
868
+ const key = this.getResourceKey(id);
869
+ const [ok, err] = await tryFn(() => this.client.headObject(key));
870
+ return ok;
871
+ }
872
+
873
+ /**
874
+ * Update an existing resource object
875
+ * @param {string} id - Resource ID
876
+ * @param {Object} attributes - Attributes to update (partial update supported)
877
+ * @returns {Promise<Object>} The updated resource object with all attributes
878
+ * @example
879
+ * // Update specific fields
880
+ * const updatedUser = await resource.update('user-123', {
881
+ * name: 'John Updated',
882
+ * age: 31
883
+ * });
884
+ *
885
+ * // Update with timestamps (if enabled)
886
+ * const updatedUser = await resource.update('user-123', {
887
+ * email: 'newemail@example.com'
888
+ * });
889
+ */
890
+ async update(id, attributes) {
891
+ if (isEmpty(id)) {
892
+ throw new Error('id cannot be empty');
893
+ }
894
+ // Garante que o recurso existe antes de atualizar
895
+ const exists = await this.exists(id);
896
+ if (!exists) {
897
+ throw new Error(`Resource with id '${id}' does not exist`);
898
+ }
899
+ const originalData = await this.get(id);
900
+ const attributesClone = cloneDeep(attributes);
901
+ let mergedData = cloneDeep(originalData);
902
+ for (const [key, value] of Object.entries(attributesClone)) {
903
+ if (key.includes('.')) {
904
+ let ref = mergedData;
905
+ const parts = key.split('.');
906
+ for (let i = 0; i < parts.length - 1; i++) {
907
+ if (typeof ref[parts[i]] !== 'object' || ref[parts[i]] === null) {
908
+ ref[parts[i]] = {};
909
+ }
910
+ ref = ref[parts[i]];
911
+ }
912
+ ref[parts[parts.length - 1]] = cloneDeep(value);
913
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
914
+ mergedData[key] = merge({}, mergedData[key], value);
915
+ } else {
916
+ mergedData[key] = cloneDeep(value);
917
+ }
918
+ }
919
+ // Debug: print mergedData and attributes
920
+ if (this.config.timestamps) {
921
+ const now = new Date().toISOString();
922
+ mergedData.updatedAt = now;
923
+ if (!mergedData.metadata) mergedData.metadata = {};
924
+ mergedData.metadata.updatedAt = now;
925
+ }
926
+ const preProcessedData = await this.executeHooks('beforeUpdate', cloneDeep(mergedData));
927
+ const completeData = { ...originalData, ...preProcessedData, id };
928
+ const { isValid, errors, data } = await this.validate(cloneDeep(completeData));
929
+ if (!isValid) {
930
+ throw new InvalidResourceItem({
931
+ bucket: this.client.config.bucket,
932
+ resourceName: this.name,
933
+ attributes: preProcessedData,
934
+ validation: errors,
935
+ message: 'validation: ' + ((errors && errors.length) ? JSON.stringify(errors) : 'unknown')
936
+ });
937
+ }
938
+ const mappedDataDebug = await this.schema.mapper(data);
939
+ const earlyBehaviorImpl = getBehavior(this.behavior);
940
+ const tempMappedData = await this.schema.mapper({ ...originalData, ...preProcessedData });
941
+ tempMappedData._v = String(this.version);
942
+ await earlyBehaviorImpl.handleUpdate({
943
+ resource: this,
944
+ id,
945
+ data: { ...originalData, ...preProcessedData },
946
+ mappedData: tempMappedData,
947
+ originalData: { ...attributesClone, id }
948
+ });
949
+ const { id: validatedId, ...validatedAttributes } = data;
950
+ const oldData = { ...originalData, id };
951
+ const newData = { ...validatedAttributes, id };
952
+ await this.handlePartitionReferenceUpdates(oldData, newData);
953
+ const mappedData = await this.schema.mapper(validatedAttributes);
954
+ mappedData._v = String(this.version);
955
+ const behaviorImpl = getBehavior(this.behavior);
956
+ const { mappedData: processedMetadata, body } = await behaviorImpl.handleUpdate({
957
+ resource: this,
958
+ id,
959
+ data: validatedAttributes,
960
+ mappedData,
961
+ originalData: { ...attributesClone, id }
962
+ });
963
+ const finalMetadata = processedMetadata;
964
+ const key = this.getResourceKey(id);
965
+ // eslint-disable-next-line no-console
966
+ let existingContentType = undefined;
967
+ let finalBody = body;
968
+ if (body === "" && this.behavior !== 'body-overflow') {
969
+ // eslint-disable-next-line no-console
970
+ const [ok, err, existingObject] = await tryFn(() => this.client.getObject(key));
971
+ // eslint-disable-next-line no-console
972
+ if (ok && existingObject.ContentLength > 0) {
973
+ const existingBodyBuffer = Buffer.from(await existingObject.Body.transformToByteArray());
974
+ const existingBodyString = existingBodyBuffer.toString();
975
+ const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(existingBodyString)));
976
+ if (!okParse) {
977
+ finalBody = existingBodyBuffer;
978
+ existingContentType = existingObject.ContentType;
979
+ }
980
+ }
981
+ }
982
+ let finalContentType = existingContentType;
983
+ if (finalBody && finalBody !== "" && !finalContentType) {
984
+ const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(finalBody)));
985
+ if (okParse) finalContentType = 'application/json';
986
+ }
987
+ if (this.versioningEnabled && originalData._v !== this.version) {
988
+ await this.createHistoricalVersion(id, originalData);
989
+ }
990
+ const [ok, err] = await tryFn(() => this.client.putObject({
991
+ key,
992
+ body: finalBody,
993
+ contentType: finalContentType,
994
+ metadata: finalMetadata,
995
+ }));
996
+ if (!ok && err && err.message && err.message.includes('metadata headers exceed')) {
997
+ const totalSize = calculateTotalSize(finalMetadata);
998
+ const effectiveLimit = calculateEffectiveLimit({
999
+ s3Limit: 2047,
1000
+ systemConfig: {
1001
+ version: this.version,
1002
+ timestamps: this.config.timestamps,
1003
+ id: id
1004
+ }
1005
+ });
1006
+ const excess = totalSize - effectiveLimit;
1007
+ err.totalSize = totalSize;
1008
+ err.limit = 2047;
1009
+ err.effectiveLimit = effectiveLimit;
1010
+ err.excess = excess;
1011
+ this.emit('exceedsLimit', {
1012
+ operation: 'update',
1013
+ totalSize,
1014
+ limit: 2047,
1015
+ effectiveLimit,
1016
+ excess,
1017
+ data: validatedAttributes
1018
+ });
1019
+ throw new ResourceError('metadata headers exceed', { resourceName: this.name, operation: 'update', id, totalSize, effectiveLimit, excess, suggestion: 'Reduce metadata size or number of fields.' });
1020
+ } else if (!ok) {
1021
+ throw mapAwsError(err, {
1022
+ bucket: this.client.config.bucket,
1023
+ key,
1024
+ resourceName: this.name,
1025
+ operation: 'update',
1026
+ id
1027
+ });
1028
+ }
1029
+ const updatedData = await this.composeFullObjectFromWrite({
1030
+ id,
1031
+ metadata: finalMetadata,
1032
+ body: finalBody,
1033
+ behavior: this.behavior
1034
+ });
1035
+ const finalResult = await this.executeHooks('afterUpdate', updatedData);
1036
+ this.emit('update', {
1037
+ ...updatedData,
1038
+ $before: { ...originalData },
1039
+ $after: { ...finalResult }
1040
+ });
1041
+ return finalResult;
1042
+ }
1043
+
1044
+ /**
1045
+ * Delete a resource object by ID
1046
+ * @param {string} id - Resource ID
1047
+ * @returns {Promise<Object>} S3 delete response
1048
+ * @example
1049
+ * await resource.delete('user-123');
1050
+ */
1051
+ async delete(id) {
1052
+ if (isEmpty(id)) {
1053
+ throw new Error('id cannot be empty');
1054
+ }
1055
+
1056
+ let objectData;
1057
+ let deleteError = null;
1058
+
1059
+ // Try to get the object data first
1060
+ const [ok, err, data] = await tryFn(() => this.get(id));
1061
+ if (ok) {
1062
+ objectData = data;
1063
+ } else {
1064
+ objectData = { id };
1065
+ deleteError = err; // Store the error for later
1066
+ }
1067
+
1068
+ await this.executeHooks('beforeDelete', objectData);
1069
+ const key = this.getResourceKey(id);
1070
+ const [ok2, err2, response] = await tryFn(() => this.client.deleteObject(key));
1071
+
1072
+ // Always emit delete event for audit purposes, even if delete fails
1073
+ this.emit("delete", {
1074
+ ...objectData,
1075
+ $before: { ...objectData },
1076
+ $after: null
1077
+ });
1078
+
1079
+ // If we had an error getting the object, throw it now (after emitting the event)
1080
+ if (deleteError) {
1081
+ throw mapAwsError(deleteError, {
1082
+ bucket: this.client.config.bucket,
1083
+ key,
1084
+ resourceName: this.name,
1085
+ operation: 'delete',
1086
+ id
1087
+ });
1088
+ }
1089
+
1090
+ if (!ok2) throw mapAwsError(err2, {
1091
+ key,
1092
+ resourceName: this.name,
1093
+ operation: 'delete',
1094
+ id
1095
+ });
1096
+
1097
+ const afterDeleteData = await this.executeHooks('afterDelete', objectData);
1098
+ return response;
1099
+ }
1100
+
1101
+ /**
1102
+ * Insert or update a resource object (upsert operation)
1103
+ * @param {Object} params - Upsert parameters
1104
+ * @param {string} params.id - Resource ID (required for upsert)
1105
+ * @param {...Object} params - Resource attributes (any additional properties)
1106
+ * @returns {Promise<Object>} The inserted or updated resource object
1107
+ * @example
1108
+ * // Will insert if doesn't exist, update if exists
1109
+ * const user = await resource.upsert({
1110
+ * id: 'user-123',
1111
+ * name: 'John Doe',
1112
+ * email: 'john@example.com'
1113
+ * });
1114
+ */
1115
+ async upsert({ id, ...attributes }) {
1116
+ const exists = await this.exists(id);
1117
+
1118
+ if (exists) {
1119
+ return this.update(id, attributes);
1120
+ }
1121
+
1122
+ return this.insert({ id, ...attributes });
1123
+ }
1124
+
1125
+ /**
1126
+ * Count resources with optional partition filtering
1127
+ * @param {Object} [params] - Count parameters
1128
+ * @param {string} [params.partition] - Partition name to count in
1129
+ * @param {Object} [params.partitionValues] - Partition field values to filter by
1130
+ * @returns {Promise<number>} Total count of matching resources
1131
+ * @example
1132
+ * // Count all resources
1133
+ * const total = await resource.count();
1134
+ *
1135
+ * // Count in specific partition
1136
+ * const googleUsers = await resource.count({
1137
+ * partition: 'byUtmSource',
1138
+ * partitionValues: { 'utm.source': 'google' }
1139
+ * });
1140
+ *
1141
+ * // Count in multi-field partition
1142
+ * const usElectronics = await resource.count({
1143
+ * partition: 'byCategoryRegion',
1144
+ * partitionValues: { category: 'electronics', region: 'US' }
1145
+ * });
1146
+ */
1147
+ async count({ partition = null, partitionValues = {} } = {}) {
1148
+ let prefix;
1149
+
1150
+ if (partition && Object.keys(partitionValues).length > 0) {
1151
+ // Count in specific partition
1152
+ const partitionDef = this.config.partitions[partition];
1153
+ if (!partitionDef) {
1154
+ throw new PartitionError(`Partition '${partition}' not found`, { resourceName: this.name, partitionName: partition, operation: 'count' });
1155
+ }
1156
+
1157
+ // Build partition segments (sorted by field name for consistency)
1158
+ const partitionSegments = [];
1159
+ const sortedFields = Object.entries(partitionDef.fields).sort(([a], [b]) => a.localeCompare(b));
1160
+ for (const [fieldName, rule] of sortedFields) {
1161
+ const value = partitionValues[fieldName];
1162
+ if (value !== undefined && value !== null) {
1163
+ const transformedValue = this.applyPartitionRule(value, rule);
1164
+ partitionSegments.push(`${fieldName}=${transformedValue}`);
1165
+ }
1166
+ }
1167
+
1168
+ if (partitionSegments.length > 0) {
1169
+ prefix = `resource=${this.name}/partition=${partition}/${partitionSegments.join('/')}`;
1170
+ } else {
1171
+ prefix = `resource=${this.name}/partition=${partition}`;
1172
+ }
1173
+ } else {
1174
+ // Count all in main resource (new format)
1175
+ prefix = `resource=${this.name}/data`;
1176
+ }
1177
+
1178
+ const count = await this.client.count({ prefix });
1179
+ this.emit("count", count);
1180
+ return count;
1181
+ }
1182
+
1183
+ /**
1184
+ * Insert multiple resources in parallel
1185
+ * @param {Object[]} objects - Array of resource objects to insert
1186
+ * @returns {Promise<Object[]>} Array of inserted resource objects
1187
+ * @example
1188
+ * const users = [
1189
+ * { name: 'John', email: 'john@example.com' },
1190
+ * { name: 'Jane', email: 'jane@example.com' },
1191
+ * { name: 'Bob', email: 'bob@example.com' }
1192
+ * ];
1193
+ * const insertedUsers = await resource.insertMany(users);
1194
+ */
1195
+ async insertMany(objects) {
1196
+ const { results } = await PromisePool.for(objects)
1197
+ .withConcurrency(this.parallelism)
1198
+ .handleError(async (error, content) => {
1199
+ this.emit("error", error, content);
1200
+ this.observers.map((x) => x.emit("error", this.name, error, content));
1201
+ })
1202
+ .process(async (attributes) => {
1203
+ const result = await this.insert(attributes);
1204
+ return result;
1205
+ });
1206
+
1207
+ this.emit("insertMany", objects.length);
1208
+ return results;
1209
+ }
1210
+
1211
+ /**
1212
+ * Delete multiple resources by their IDs in parallel
1213
+ * @param {string[]} ids - Array of resource IDs to delete
1214
+ * @returns {Promise<Object[]>} Array of S3 delete responses
1215
+ * @example
1216
+ * const deletedIds = ['user-1', 'user-2', 'user-3'];
1217
+ * const results = await resource.deleteMany(deletedIds);
1218
+ */
1219
+ async deleteMany(ids) {
1220
+ const packages = chunk(
1221
+ ids.map((id) => this.getResourceKey(id)),
1222
+ 1000
1223
+ );
1224
+
1225
+ // Debug log: print all keys to be deleted
1226
+ const allKeys = ids.map((id) => this.getResourceKey(id));
1227
+
1228
+ const { results } = await PromisePool.for(packages)
1229
+ .withConcurrency(this.parallelism)
1230
+ .handleError(async (error, content) => {
1231
+ this.emit("error", error, content);
1232
+ this.observers.map((x) => x.emit("error", this.name, error, content));
1233
+ })
1234
+ .process(async (keys) => {
1235
+ const response = await this.client.deleteObjects(keys);
1236
+
1237
+ keys.forEach((key) => {
1238
+ // Extract ID from key path
1239
+ const parts = key.split('/');
1240
+ const idPart = parts.find(part => part.startsWith('id='));
1241
+ const id = idPart ? idPart.replace('id=', '') : null;
1242
+ if (id) {
1243
+ this.emit("deleted", id);
1244
+ this.observers.map((x) => x.emit("deleted", this.name, id));
1245
+ }
1246
+ });
1247
+
1248
+ return response;
1249
+ });
1250
+
1251
+ this.emit("deleteMany", ids.length);
1252
+ return results;
1253
+ }
1254
+
1255
+ async deleteAll() {
1256
+ // Security check: only allow if paranoid mode is disabled
1257
+ if (this.config.paranoid !== false) {
1258
+ throw new ResourceError('deleteAll() is a dangerous operation and requires paranoid: false option.', { resourceName: this.name, operation: 'deleteAll', paranoid: this.config.paranoid, suggestion: 'Set paranoid: false to allow deleteAll.' });
1259
+ }
1260
+
1261
+ // Use deleteAll to efficiently delete all objects (new format)
1262
+ const prefix = `resource=${this.name}/data`;
1263
+ const deletedCount = await this.client.deleteAll({ prefix });
1264
+
1265
+ this.emit("deleteAll", {
1266
+ version: this.version,
1267
+ prefix,
1268
+ deletedCount
1269
+ });
1270
+
1271
+ return { deletedCount, version: this.version };
1272
+ }
1273
+
1274
+ /**
1275
+ * Delete all data for this resource across ALL versions
1276
+ * @returns {Promise<Object>} Deletion report
1277
+ */
1278
+ async deleteAllData() {
1279
+ // Security check: only allow if paranoid mode is disabled
1280
+ if (this.config.paranoid !== false) {
1281
+ throw new ResourceError('deleteAllData() is a dangerous operation and requires paranoid: false option.', { resourceName: this.name, operation: 'deleteAllData', paranoid: this.config.paranoid, suggestion: 'Set paranoid: false to allow deleteAllData.' });
1282
+ }
1283
+
1284
+ // Use deleteAll to efficiently delete everything for this resource
1285
+ const prefix = `resource=${this.name}`;
1286
+ const deletedCount = await this.client.deleteAll({ prefix });
1287
+
1288
+ this.emit("deleteAllData", {
1289
+ resource: this.name,
1290
+ prefix,
1291
+ deletedCount
1292
+ });
1293
+
1294
+ return { deletedCount, resource: this.name };
1295
+ }
1296
+
1297
+ /**
1298
+ * List resource IDs with optional partition filtering and pagination
1299
+ * @param {Object} [params] - List parameters
1300
+ * @param {string} [params.partition] - Partition name to list from
1301
+ * @param {Object} [params.partitionValues] - Partition field values to filter by
1302
+ * @param {number} [params.limit] - Maximum number of results to return
1303
+ * @param {number} [params.offset=0] - Offset for pagination
1304
+ * @returns {Promise<string[]>} Array of resource IDs (strings)
1305
+ * @example
1306
+ * // List all IDs
1307
+ * const allIds = await resource.listIds();
1308
+ *
1309
+ * // List IDs with pagination
1310
+ * const firstPageIds = await resource.listIds({ limit: 10, offset: 0 });
1311
+ * const secondPageIds = await resource.listIds({ limit: 10, offset: 10 });
1312
+ *
1313
+ * // List IDs from specific partition
1314
+ * const googleUserIds = await resource.listIds({
1315
+ * partition: 'byUtmSource',
1316
+ * partitionValues: { 'utm.source': 'google' }
1317
+ * });
1318
+ *
1319
+ * // List IDs from multi-field partition
1320
+ * const usElectronicsIds = await resource.listIds({
1321
+ * partition: 'byCategoryRegion',
1322
+ * partitionValues: { category: 'electronics', region: 'US' }
1323
+ * });
1324
+ */
1325
+ async listIds({ partition = null, partitionValues = {}, limit, offset = 0 } = {}) {
1326
+ let prefix;
1327
+ if (partition && Object.keys(partitionValues).length > 0) {
1328
+ // List from specific partition
1329
+ if (!this.config.partitions || !this.config.partitions[partition]) {
1330
+ throw new PartitionError(`Partition '${partition}' not found`, { resourceName: this.name, partitionName: partition, operation: 'listIds' });
1331
+ }
1332
+ const partitionDef = this.config.partitions[partition];
1333
+ // Build partition segments (sorted by field name for consistency)
1334
+ const partitionSegments = [];
1335
+ const sortedFields = Object.entries(partitionDef.fields).sort(([a], [b]) => a.localeCompare(b));
1336
+ for (const [fieldName, rule] of sortedFields) {
1337
+ const value = partitionValues[fieldName];
1338
+ if (value !== undefined && value !== null) {
1339
+ const transformedValue = this.applyPartitionRule(value, rule);
1340
+ partitionSegments.push(`${fieldName}=${transformedValue}`);
1341
+ }
1342
+ }
1343
+ if (partitionSegments.length > 0) {
1344
+ prefix = `resource=${this.name}/partition=${partition}/${partitionSegments.join('/')}`;
1345
+ } else {
1346
+ prefix = `resource=${this.name}/partition=${partition}`;
1347
+ }
1348
+ } else {
1349
+ // List from main resource (sem versão no path)
1350
+ prefix = `resource=${this.name}/data`;
1351
+ }
1352
+ // Use getKeysPage for real pagination support
1353
+ const keys = await this.client.getKeysPage({
1354
+ prefix,
1355
+ offset: offset,
1356
+ amount: limit || 1000, // Default to 1000 if no limit specified
1357
+ });
1358
+ const ids = keys.map((key) => {
1359
+ // Extract ID from different path patterns:
1360
+ // /resource={name}/v={version}/id={id}
1361
+ // /resource={name}/partition={name}/{field}={value}/id={id}
1362
+ const parts = key.split('/');
1363
+ const idPart = parts.find(part => part.startsWith('id='));
1364
+ return idPart ? idPart.replace('id=', '') : null;
1365
+ }).filter(Boolean);
1366
+ this.emit("listIds", ids.length);
1367
+ return ids;
1368
+ }
1369
+
1370
+ /**
1371
+ * List resources with optional partition filtering and pagination
1372
+ * @param {Object} [params] - List parameters
1373
+ * @param {string} [params.partition] - Partition name to list from
1374
+ * @param {Object} [params.partitionValues] - Partition field values to filter by
1375
+ * @param {number} [params.limit] - Maximum number of results
1376
+ * @param {number} [params.offset=0] - Number of results to skip
1377
+ * @returns {Promise<Object[]>} Array of resource objects
1378
+ * @example
1379
+ * // List all resources
1380
+ * const allUsers = await resource.list();
1381
+ *
1382
+ * // List with pagination
1383
+ * const first10 = await resource.list({ limit: 10, offset: 0 });
1384
+ *
1385
+ * // List from specific partition
1386
+ * const usUsers = await resource.list({
1387
+ * partition: 'byCountry',
1388
+ * partitionValues: { 'profile.country': 'US' }
1389
+ * });
1390
+ */
1391
+ async list({ partition = null, partitionValues = {}, limit, offset = 0 } = {}) {
1392
+ const [ok, err, result] = await tryFn(async () => {
1393
+ if (!partition) {
1394
+ return await this.listMain({ limit, offset });
1395
+ }
1396
+ return await this.listPartition({ partition, partitionValues, limit, offset });
1397
+ });
1398
+ if (!ok) {
1399
+ return this.handleListError(err, { partition, partitionValues });
1400
+ }
1401
+ return result;
1402
+ }
1403
+
1404
+ async listMain({ limit, offset = 0 }) {
1405
+ const [ok, err, ids] = await tryFn(() => this.listIds({ limit, offset }));
1406
+ if (!ok) throw err;
1407
+ const results = await this.processListResults(ids, 'main');
1408
+ this.emit("list", { count: results.length, errors: 0 });
1409
+ return results;
1410
+ }
1411
+
1412
+ async listPartition({ partition, partitionValues, limit, offset = 0 }) {
1413
+ if (!this.config.partitions?.[partition]) {
1414
+ this.emit("list", { partition, partitionValues, count: 0, errors: 0 });
1415
+ return [];
1416
+ }
1417
+ const partitionDef = this.config.partitions[partition];
1418
+ const prefix = this.buildPartitionPrefix(partition, partitionDef, partitionValues);
1419
+ const [ok, err, keys] = await tryFn(() => this.client.getAllKeys({ prefix }));
1420
+ if (!ok) throw err;
1421
+ const ids = this.extractIdsFromKeys(keys).slice(offset);
1422
+ const filteredIds = limit ? ids.slice(0, limit) : ids;
1423
+ const results = await this.processPartitionResults(filteredIds, partition, partitionDef, keys);
1424
+ this.emit("list", { partition, partitionValues, count: results.length, errors: 0 });
1425
+ return results;
1426
+ }
1427
+
1428
+ /**
1429
+ * Build partition prefix from partition definition and values
1430
+ */
1431
+ buildPartitionPrefix(partition, partitionDef, partitionValues) {
1432
+ const partitionSegments = [];
1433
+ const sortedFields = Object.entries(partitionDef.fields).sort(([a], [b]) => a.localeCompare(b));
1434
+
1435
+ for (const [fieldName, rule] of sortedFields) {
1436
+ const value = partitionValues[fieldName];
1437
+ if (value !== undefined && value !== null) {
1438
+ const transformedValue = this.applyPartitionRule(value, rule);
1439
+ partitionSegments.push(`${fieldName}=${transformedValue}`);
1440
+ }
1441
+ }
1442
+
1443
+ if (partitionSegments.length > 0) {
1444
+ return `resource=${this.name}/partition=${partition}/${partitionSegments.join('/')}`;
1445
+ }
1446
+
1447
+ return `resource=${this.name}/partition=${partition}`;
1448
+ }
1449
+
1450
+ /**
1451
+ * Extract IDs from S3 keys
1452
+ */
1453
+ extractIdsFromKeys(keys) {
1454
+ return keys
1455
+ .map(key => {
1456
+ const parts = key.split('/');
1457
+ const idPart = parts.find(part => part.startsWith('id='));
1458
+ return idPart ? idPart.replace('id=', '') : null;
1459
+ })
1460
+ .filter(Boolean);
1461
+ }
1462
+
1463
+ /**
1464
+ * Process list results with error handling
1465
+ */
1466
+ async processListResults(ids, context = 'main') {
1467
+ const { results, errors } = await PromisePool.for(ids)
1468
+ .withConcurrency(this.parallelism)
1469
+ .handleError(async (error, id) => {
1470
+ this.emit("error", error, content);
1471
+ this.observers.map((x) => x.emit("error", this.name, error, content));
1472
+ })
1473
+ .process(async (id) => {
1474
+ const [ok, err, result] = await tryFn(() => this.get(id));
1475
+ if (ok) {
1476
+ return result;
1477
+ }
1478
+ return this.handleResourceError(err, id, context);
1479
+ });
1480
+ this.emit("list", { count: results.length, errors: 0 });
1481
+ return results;
1482
+ }
1483
+
1484
+ /**
1485
+ * Process partition results with error handling
1486
+ */
1487
+ async processPartitionResults(ids, partition, partitionDef, keys) {
1488
+ const sortedFields = Object.entries(partitionDef.fields).sort(([a], [b]) => a.localeCompare(b));
1489
+ const { results, errors } = await PromisePool.for(ids)
1490
+ .withConcurrency(this.parallelism)
1491
+ .handleError(async (error, id) => {
1492
+ this.emit("error", error, content);
1493
+ this.observers.map((x) => x.emit("error", this.name, error, content));
1494
+ })
1495
+ .process(async (id) => {
1496
+ const [ok, err, result] = await tryFn(async () => {
1497
+ const actualPartitionValues = this.extractPartitionValuesFromKey(id, keys, sortedFields);
1498
+ return await this.getFromPartition({
1499
+ id,
1500
+ partitionName: partition,
1501
+ partitionValues: actualPartitionValues
1502
+ });
1503
+ });
1504
+ if (ok) return result;
1505
+ return this.handleResourceError(err, id, 'partition');
1506
+ });
1507
+ return results.filter(item => item !== null);
1508
+ }
1509
+
1510
+ /**
1511
+ * Extract partition values from S3 key for specific ID
1512
+ */
1513
+ extractPartitionValuesFromKey(id, keys, sortedFields) {
1514
+ const keyForId = keys.find(key => key.includes(`id=${id}`));
1515
+ if (!keyForId) {
1516
+ throw new PartitionError(`Partition key not found for ID ${id}`, { resourceName: this.name, id, operation: 'extractPartitionValuesFromKey' });
1517
+ }
1518
+
1519
+ const keyParts = keyForId.split('/');
1520
+ const actualPartitionValues = {};
1521
+
1522
+ for (const [fieldName] of sortedFields) {
1523
+ const fieldPart = keyParts.find(part => part.startsWith(`${fieldName}=`));
1524
+ if (fieldPart) {
1525
+ const value = fieldPart.replace(`${fieldName}=`, '');
1526
+ actualPartitionValues[fieldName] = value;
1527
+ }
1528
+ }
1529
+
1530
+ return actualPartitionValues;
1531
+ }
1532
+
1533
+ /**
1534
+ * Handle resource-specific errors
1535
+ */
1536
+ handleResourceError(error, id, context) {
1537
+ if (error.message.includes('Cipher job failed') || error.message.includes('OperationError')) {
1538
+ return {
1539
+ id,
1540
+ _decryptionFailed: true,
1541
+ _error: error.message,
1542
+ ...(context === 'partition' && { _partition: context })
1543
+ };
1544
+ }
1545
+ throw error;
1546
+ }
1547
+
1548
+ /**
1549
+ * Handle list method errors
1550
+ */
1551
+ handleListError(error, { partition, partitionValues }) {
1552
+ if (error.message.includes("Partition '") && error.message.includes("' not found")) {
1553
+ this.emit("list", { partition, partitionValues, count: 0, errors: 1 });
1554
+ return [];
1555
+ }
1556
+
1557
+ this.emit("list", { partition, partitionValues, count: 0, errors: 1 });
1558
+ return [];
1559
+ }
1560
+
1561
+ /**
1562
+ * Get multiple resources by their IDs
1563
+ * @param {string[]} ids - Array of resource IDs
1564
+ * @returns {Promise<Object[]>} Array of resource objects
1565
+ * @example
1566
+ * const users = await resource.getMany(['user-1', 'user-2', 'user-3']);
1567
+ */
1568
+ async getMany(ids) {
1569
+ const { results, errors } = await PromisePool.for(ids)
1570
+ .withConcurrency(this.client.parallelism)
1571
+ .handleError(async (error, id) => {
1572
+ this.emit("error", error, content);
1573
+ this.observers.map((x) => x.emit("error", this.name, error, content));
1574
+ return {
1575
+ id,
1576
+ _error: error.message,
1577
+ _decryptionFailed: error.message.includes('Cipher job failed') || error.message.includes('OperationError')
1578
+ };
1579
+ })
1580
+ .process(async (id) => {
1581
+ const [ok, err, data] = await tryFn(() => this.get(id));
1582
+ if (ok) return data;
1583
+ if (err.message.includes('Cipher job failed') || err.message.includes('OperationError')) {
1584
+ return {
1585
+ id,
1586
+ _decryptionFailed: true,
1587
+ _error: err.message
1588
+ };
1589
+ }
1590
+ throw err;
1591
+ });
1592
+
1593
+ this.emit("getMany", ids.length);
1594
+ return results;
1595
+ }
1596
+
1597
+ /**
1598
+ * Get all resources (equivalent to list() without pagination)
1599
+ * @returns {Promise<Object[]>} Array of all resource objects
1600
+ * @example
1601
+ * const allUsers = await resource.getAll();
1602
+ */
1603
+ async getAll() {
1604
+ const [ok, err, ids] = await tryFn(() => this.listIds());
1605
+ if (!ok) throw err;
1606
+ const results = [];
1607
+ for (const id of ids) {
1608
+ const [ok2, err2, item] = await tryFn(() => this.get(id));
1609
+ if (ok2) {
1610
+ results.push(item);
1611
+ } else {
1612
+ // Log error but continue
1613
+ }
1614
+ }
1615
+ return results;
1616
+ }
1617
+
1618
+ /**
1619
+ * Get a page of resources with pagination metadata
1620
+ * @param {Object} [params] - Page parameters
1621
+ * @param {number} [params.offset=0] - Offset for pagination
1622
+ * @param {number} [params.size=100] - Page size
1623
+ * @param {string} [params.partition] - Partition name to page from
1624
+ * @param {Object} [params.partitionValues] - Partition field values to filter by
1625
+ * @param {boolean} [params.skipCount=false] - Skip total count for performance (useful for large collections)
1626
+ * @returns {Promise<Object>} Page result with items and pagination info
1627
+ * @example
1628
+ * // Get first page of all resources
1629
+ * const page = await resource.page({ offset: 0, size: 10 });
1630
+ *
1631
+ * // Get page from specific partition
1632
+ * const googlePage = await resource.page({
1633
+ * partition: 'byUtmSource',
1634
+ * partitionValues: { 'utm.source': 'google' },
1635
+ * offset: 0,
1636
+ * size: 5
1637
+ * });
1638
+ *
1639
+ * // Skip count for performance in large collections
1640
+ * const fastPage = await resource.page({
1641
+ * offset: 0,
1642
+ * size: 100,
1643
+ * skipCount: true
1644
+ * });
1645
+ */
1646
+ async page({ offset = 0, size = 100, partition = null, partitionValues = {}, skipCount = false } = {}) {
1647
+ const [ok, err, result] = await tryFn(async () => {
1648
+ // Get total count only if not skipped (for performance)
1649
+ let totalItems = null;
1650
+ let totalPages = null;
1651
+ if (!skipCount) {
1652
+ const [okCount, errCount, count] = await tryFn(() => this.count({ partition, partitionValues }));
1653
+ if (okCount) {
1654
+ totalItems = count;
1655
+ totalPages = Math.ceil(totalItems / size);
1656
+ } else {
1657
+ totalItems = null;
1658
+ totalPages = null;
1659
+ }
1660
+ }
1661
+ const page = Math.floor(offset / size);
1662
+ let items = [];
1663
+ if (size <= 0) {
1664
+ items = [];
1665
+ } else {
1666
+ const [okList, errList, listResult] = await tryFn(() => this.list({ partition, partitionValues, limit: size, offset: offset }));
1667
+ items = okList ? listResult : [];
1668
+ }
1669
+ const result = {
1670
+ items,
1671
+ totalItems,
1672
+ page,
1673
+ pageSize: size,
1674
+ totalPages,
1675
+ hasMore: items.length === size && (offset + size) < (totalItems || Infinity),
1676
+ _debug: {
1677
+ requestedSize: size,
1678
+ requestedOffset: offset,
1679
+ actualItemsReturned: items.length,
1680
+ skipCount: skipCount,
1681
+ hasTotalItems: totalItems !== null
1682
+ }
1683
+ };
1684
+ this.emit("page", result);
1685
+ return result;
1686
+ });
1687
+ if (ok) return result;
1688
+ // Final fallback - return a safe result even if everything fails
1689
+ return {
1690
+ items: [],
1691
+ totalItems: null,
1692
+ page: Math.floor(offset / size),
1693
+ pageSize: size,
1694
+ totalPages: null,
1695
+ _debug: {
1696
+ requestedSize: size,
1697
+ requestedOffset: offset,
1698
+ actualItemsReturned: 0,
1699
+ skipCount: skipCount,
1700
+ hasTotalItems: false,
1701
+ error: err.message
1702
+ }
1703
+ };
1704
+ }
1705
+
1706
+ readable() {
1707
+ const stream = new ResourceReader({ resource: this });
1708
+ return stream.build()
1709
+ }
1710
+
1711
+ writable() {
1712
+ const stream = new ResourceWriter({ resource: this });
1713
+ return stream.build()
1714
+ }
1715
+
1716
+ /**
1717
+ * Set binary content for a resource
1718
+ * @param {Object} params - Content parameters
1719
+ * @param {string} params.id - Resource ID
1720
+ * @param {Buffer|string} params.buffer - Content buffer or string
1721
+ * @param {string} [params.contentType='application/octet-stream'] - Content type
1722
+ * @returns {Promise<Object>} Updated resource data
1723
+ * @example
1724
+ * // Set image content
1725
+ * const imageBuffer = fs.readFileSync('image.jpg');
1726
+ * await resource.setContent({
1727
+ * id: 'user-123',
1728
+ * buffer: imageBuffer,
1729
+ * contentType: 'image/jpeg'
1730
+ * });
1731
+ *
1732
+ * // Set text content
1733
+ * await resource.setContent({
1734
+ * id: 'document-456',
1735
+ * buffer: 'Hello World',
1736
+ * contentType: 'text/plain'
1737
+ * });
1738
+ */
1739
+ async setContent({ id, buffer, contentType = 'application/octet-stream' }) {
1740
+ const [ok, err, currentData] = await tryFn(() => this.get(id));
1741
+ if (!ok || !currentData) {
1742
+ throw new ResourceError(`Resource with id '${id}' not found`, { resourceName: this.name, id, operation: 'setContent' });
1743
+ }
1744
+ const updatedData = {
1745
+ ...currentData,
1746
+ _hasContent: true,
1747
+ _contentLength: buffer.length,
1748
+ _mimeType: contentType
1749
+ };
1750
+ const mappedMetadata = await this.schema.mapper(updatedData);
1751
+ const [ok2, err2] = await tryFn(() => this.client.putObject({
1752
+ key: this.getResourceKey(id),
1753
+ metadata: mappedMetadata,
1754
+ body: buffer,
1755
+ contentType
1756
+ }));
1757
+ if (!ok2) throw err2;
1758
+ this.emit("setContent", { id, contentType, contentLength: buffer.length });
1759
+ return updatedData;
1760
+ }
1761
+
1762
+ /**
1763
+ * Retrieve binary content associated with a resource
1764
+ * @param {string} id - Resource ID
1765
+ * @returns {Promise<Object>} Object with buffer and contentType
1766
+ * @example
1767
+ * const content = await resource.content('user-123');
1768
+ * if (content.buffer) {
1769
+ * // Save to file
1770
+ * fs.writeFileSync('output.jpg', content.buffer);
1771
+ * } else {
1772
+ * }
1773
+ */
1774
+ async content(id) {
1775
+ const key = this.getResourceKey(id);
1776
+ const [ok, err, response] = await tryFn(() => this.client.getObject(key));
1777
+ if (!ok) {
1778
+ if (err.name === "NoSuchKey") {
1779
+ return {
1780
+ buffer: null,
1781
+ contentType: null
1782
+ };
1783
+ }
1784
+ throw err;
1785
+ }
1786
+ const buffer = Buffer.from(await response.Body.transformToByteArray());
1787
+ const contentType = response.ContentType || null;
1788
+ this.emit("content", id, buffer.length, contentType);
1789
+ return {
1790
+ buffer,
1791
+ contentType
1792
+ };
1793
+ }
1794
+
1795
+ /**
1796
+ * Check if binary content exists for a resource
1797
+ * @param {string} id - Resource ID
1798
+ * @returns {boolean}
1799
+ */
1800
+ async hasContent(id) {
1801
+ const key = this.getResourceKey(id);
1802
+ const [ok, err, response] = await tryFn(() => this.client.headObject(key));
1803
+ if (!ok) return false;
1804
+ return response.ContentLength > 0;
1805
+ }
1806
+
1807
+ /**
1808
+ * Delete binary content but preserve metadata
1809
+ * @param {string} id - Resource ID
1810
+ */
1811
+ async deleteContent(id) {
1812
+ const key = this.getResourceKey(id);
1813
+ const [ok, err, existingObject] = await tryFn(() => this.client.headObject(key));
1814
+ if (!ok) throw err;
1815
+ const existingMetadata = existingObject.Metadata || {};
1816
+ const [ok2, err2, response] = await tryFn(() => this.client.putObject({
1817
+ key,
1818
+ body: "",
1819
+ metadata: existingMetadata,
1820
+ }));
1821
+ if (!ok2) throw err2;
1822
+ this.emit("deleteContent", id);
1823
+ return response;
1824
+ }
1825
+
1826
+ /**
1827
+ * Generate definition hash for this resource
1828
+ * @returns {string} SHA256 hash of the resource definition (name + attributes)
1829
+ */
1830
+ getDefinitionHash() {
1831
+ // Create a stable object with only attributes and behavior (consistent with Database.generateDefinitionHash)
1832
+ const definition = {
1833
+ attributes: this.attributes,
1834
+ behavior: this.behavior
1835
+ };
1836
+
1837
+ // Use jsonStableStringify to ensure consistent ordering regardless of input order
1838
+ const stableString = jsonStableStringify(definition);
1839
+ return `sha256:${createHash('sha256').update(stableString).digest('hex')}`;
1840
+ }
1841
+
1842
+ /**
1843
+ * Extract version from S3 key
1844
+ * @param {string} key - S3 object key
1845
+ * @returns {string|null} Version string or null
1846
+ */
1847
+ extractVersionFromKey(key) {
1848
+ const parts = key.split('/');
1849
+ const versionPart = parts.find(part => part.startsWith('v='));
1850
+ return versionPart ? versionPart.replace('v=', '') : null;
1851
+ }
1852
+
1853
+ /**
1854
+ * Get schema for a specific version
1855
+ * @param {string} version - Version string (e.g., 'v0', 'v1')
1856
+ * @returns {Object} Schema object for the version
1857
+ */
1858
+ async getSchemaForVersion(version) {
1859
+ // If version is the same as current, return current schema
1860
+ if (version === this.version) {
1861
+ return this.schema;
1862
+ }
1863
+ // For different versions, try to create a compatible schema
1864
+ // This is especially important for v0 objects that might have different encryption
1865
+ const [ok, err, compatibleSchema] = await tryFn(() => Promise.resolve(new Schema({
1866
+ name: this.name,
1867
+ attributes: this.attributes,
1868
+ passphrase: this.passphrase,
1869
+ version: version,
1870
+ options: {
1871
+ ...this.config,
1872
+ autoDecrypt: true,
1873
+ autoEncrypt: true
1874
+ }
1875
+ })));
1876
+ if (ok) return compatibleSchema;
1877
+ // console.warn(`Failed to create compatible schema for version ${version}, using current schema:`, err.message);
1878
+ return this.schema;
1879
+ }
1880
+
1881
+ /**
1882
+ * Create partition references after insert
1883
+ * @param {Object} data - Inserted object data
1884
+ */
1885
+ async createPartitionReferences(data) {
1886
+ const partitions = this.config.partitions;
1887
+ if (!partitions || Object.keys(partitions).length === 0) {
1888
+ return;
1889
+ }
1890
+
1891
+ // Create reference in each partition
1892
+ for (const [partitionName, partition] of Object.entries(partitions)) {
1893
+ const partitionKey = this.getPartitionKey({ partitionName, id: data.id, data });
1894
+ if (partitionKey) {
1895
+ // Salvar apenas a versão como metadado, nunca atributos do objeto
1896
+ const partitionMetadata = {
1897
+ _v: String(this.version)
1898
+ };
1899
+ await this.client.putObject({
1900
+ key: partitionKey,
1901
+ metadata: partitionMetadata,
1902
+ body: '',
1903
+ contentType: undefined,
1904
+ });
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ /**
1910
+ * Delete partition references after delete
1911
+ * @param {Object} data - Deleted object data
1912
+ */
1913
+ async deletePartitionReferences(data) {
1914
+ const partitions = this.config.partitions;
1915
+ if (!partitions || Object.keys(partitions).length === 0) {
1916
+ return;
1917
+ }
1918
+ const keysToDelete = [];
1919
+ for (const [partitionName, partition] of Object.entries(partitions)) {
1920
+ const partitionKey = this.getPartitionKey({ partitionName, id: data.id, data });
1921
+ if (partitionKey) {
1922
+ keysToDelete.push(partitionKey);
1923
+ }
1924
+ }
1925
+ if (keysToDelete.length > 0) {
1926
+ const [ok, err] = await tryFn(() => this.client.deleteObjects(keysToDelete));
1927
+ if (!ok) {
1928
+ // console.warn('Some partition objects could not be deleted:', err.message);
1929
+ }
1930
+ }
1931
+ }
1932
+
1933
+ /**
1934
+ * Query resources with simple filtering and pagination
1935
+ * @param {Object} [filter={}] - Filter criteria (exact field matches)
1936
+ * @param {Object} [options] - Query options
1937
+ * @param {number} [options.limit=100] - Maximum number of results
1938
+ * @param {number} [options.offset=0] - Offset for pagination
1939
+ * @param {string} [options.partition] - Partition name to query from
1940
+ * @param {Object} [options.partitionValues] - Partition field values to filter by
1941
+ * @returns {Promise<Object[]>} Array of filtered resource objects
1942
+ * @example
1943
+ * // Query all resources (no filter)
1944
+ * const allUsers = await resource.query();
1945
+ *
1946
+ * // Query with simple filter
1947
+ * const activeUsers = await resource.query({ status: 'active' });
1948
+ *
1949
+ * // Query with multiple filters
1950
+ * const usElectronics = await resource.query({
1951
+ * category: 'electronics',
1952
+ * region: 'US'
1953
+ * });
1954
+ *
1955
+ * // Query with pagination
1956
+ * const firstPage = await resource.query(
1957
+ * { status: 'active' },
1958
+ * { limit: 10, offset: 0 }
1959
+ * );
1960
+ *
1961
+ * // Query within partition
1962
+ * const googleUsers = await resource.query(
1963
+ * { status: 'active' },
1964
+ * {
1965
+ * partition: 'byUtmSource',
1966
+ * partitionValues: { 'utm.source': 'google' },
1967
+ * limit: 5
1968
+ * }
1969
+ * );
1970
+ */
1971
+ async query(filter = {}, { limit = 100, offset = 0, partition = null, partitionValues = {} } = {}) {
1972
+ if (Object.keys(filter).length === 0) {
1973
+ // No filter, just return paginated results
1974
+ return await this.list({ partition, partitionValues, limit, offset });
1975
+ }
1976
+
1977
+ const results = [];
1978
+ let currentOffset = offset;
1979
+ const batchSize = Math.min(limit, 50); // Process in smaller batches
1980
+
1981
+ while (results.length < limit) {
1982
+ // Get a batch of objects
1983
+ const batch = await this.list({
1984
+ partition,
1985
+ partitionValues,
1986
+ limit: batchSize,
1987
+ offset: currentOffset
1988
+ });
1989
+
1990
+ if (batch.length === 0) {
1991
+ break; // No more data
1992
+ }
1993
+
1994
+ // Filter the batch
1995
+ const filteredBatch = batch.filter(doc => {
1996
+ return Object.entries(filter).every(([key, value]) => {
1997
+ return doc[key] === value;
1998
+ });
1999
+ });
2000
+
2001
+ // Add filtered results
2002
+ results.push(...filteredBatch);
2003
+ currentOffset += batchSize;
2004
+
2005
+ // If we got less than batchSize, we've reached the end
2006
+ if (batch.length < batchSize) {
2007
+ break;
2008
+ }
2009
+ }
2010
+
2011
+ // Return only up to the requested limit
2012
+ return results.slice(0, limit);
2013
+ }
2014
+
2015
+ /**
2016
+ * Handle partition reference updates with change detection
2017
+ * @param {Object} oldData - Original object data before update
2018
+ * @param {Object} newData - Updated object data
2019
+ */
2020
+ async handlePartitionReferenceUpdates(oldData, newData) {
2021
+ const partitions = this.config.partitions;
2022
+ if (!partitions || Object.keys(partitions).length === 0) {
2023
+ return;
2024
+ }
2025
+ for (const [partitionName, partition] of Object.entries(partitions)) {
2026
+ const [ok, err] = await tryFn(() => this.handlePartitionReferenceUpdate(partitionName, partition, oldData, newData));
2027
+ if (!ok) {
2028
+ // console.warn(`Failed to update partition references for ${partitionName}:`, err.message);
2029
+ }
2030
+ }
2031
+ const id = newData.id || oldData.id;
2032
+ for (const [partitionName, partition] of Object.entries(partitions)) {
2033
+ const prefix = `resource=${this.name}/partition=${partitionName}`;
2034
+ let allKeys = [];
2035
+ const [okKeys, errKeys, keys] = await tryFn(() => this.client.getAllKeys({ prefix }));
2036
+ if (okKeys) {
2037
+ allKeys = keys;
2038
+ } else {
2039
+ // console.warn(`Aggressive cleanup: could not list keys for partition ${partitionName}:`, errKeys.message);
2040
+ continue;
2041
+ }
2042
+ const validKey = this.getPartitionKey({ partitionName, id, data: newData });
2043
+ for (const key of allKeys) {
2044
+ if (key.endsWith(`/id=${id}`) && key !== validKey) {
2045
+ const [okDel, errDel] = await tryFn(() => this.client.deleteObject(key));
2046
+ if (!okDel) {
2047
+ // console.warn(`Aggressive cleanup: could not delete stale partition key ${key}:`, errDel.message);
2048
+ }
2049
+ }
2050
+ }
2051
+ }
2052
+ }
2053
+
2054
+ /**
2055
+ * Handle partition reference update for a specific partition
2056
+ * @param {string} partitionName - Name of the partition
2057
+ * @param {Object} partition - Partition definition
2058
+ * @param {Object} oldData - Original object data before update
2059
+ * @param {Object} newData - Updated object data
2060
+ */
2061
+ async handlePartitionReferenceUpdate(partitionName, partition, oldData, newData) {
2062
+ // Ensure we have the correct id
2063
+ const id = newData.id || oldData.id;
2064
+
2065
+ // Get old and new partition keys
2066
+ const oldPartitionKey = this.getPartitionKey({ partitionName, id, data: oldData });
2067
+ const newPartitionKey = this.getPartitionKey({ partitionName, id, data: newData });
2068
+
2069
+ // If partition keys are different, we need to move the reference
2070
+ if (oldPartitionKey !== newPartitionKey) {
2071
+ // Delete old partition reference if it exists
2072
+ if (oldPartitionKey) {
2073
+ const [ok, err] = await tryFn(async () => {
2074
+ await this.client.deleteObject(oldPartitionKey);
2075
+ });
2076
+ if (!ok) {
2077
+ // Log but don't fail if old partition object doesn't exist
2078
+ // console.warn(`Old partition object could not be deleted for ${partitionName}:`, err.message);
2079
+ }
2080
+ }
2081
+
2082
+ // Create new partition reference if new key exists
2083
+ if (newPartitionKey) {
2084
+ const [ok, err] = await tryFn(async () => {
2085
+ // Salvar apenas a versão como metadado
2086
+ const partitionMetadata = {
2087
+ _v: String(this.version)
2088
+ };
2089
+ await this.client.putObject({
2090
+ key: newPartitionKey,
2091
+ metadata: partitionMetadata,
2092
+ body: '',
2093
+ contentType: undefined,
2094
+ });
2095
+ });
2096
+ if (!ok) {
2097
+ // Log but don't fail if new partition object creation fails
2098
+ // console.warn(`New partition object could not be created for ${partitionName}:`, err.message);
2099
+ }
2100
+ }
2101
+ } else if (newPartitionKey) {
2102
+ // If partition keys are the same, just update the existing reference
2103
+ const [ok, err] = await tryFn(async () => {
2104
+ // Salvar apenas a versão como metadado
2105
+ const partitionMetadata = {
2106
+ _v: String(this.version)
2107
+ };
2108
+ await this.client.putObject({
2109
+ key: newPartitionKey,
2110
+ metadata: partitionMetadata,
2111
+ body: '',
2112
+ contentType: undefined,
2113
+ });
2114
+ });
2115
+ if (!ok) {
2116
+ // Log but don't fail if partition object update fails
2117
+ // console.warn(`Partition object could not be updated for ${partitionName}:`, err.message);
2118
+ }
2119
+ }
2120
+ }
2121
+
2122
+ /**
2123
+ * Update partition objects to keep them in sync (legacy method for backward compatibility)
2124
+ * @param {Object} data - Updated object data
2125
+ */
2126
+ async updatePartitionReferences(data) {
2127
+ const partitions = this.config.partitions;
2128
+ if (!partitions || Object.keys(partitions).length === 0) {
2129
+ return;
2130
+ }
2131
+
2132
+ // Update each partition object
2133
+ for (const [partitionName, partition] of Object.entries(partitions)) {
2134
+ // Validate that the partition exists and has the required structure
2135
+ if (!partition || !partition.fields || typeof partition.fields !== 'object') {
2136
+ // console.warn(`Skipping invalid partition '${partitionName}' in resource '${this.name}'`);
2137
+ continue;
2138
+ }
2139
+ const partitionKey = this.getPartitionKey({ partitionName, id: data.id, data });
2140
+ if (partitionKey) {
2141
+ // Salvar apenas a versão como metadado
2142
+ const partitionMetadata = {
2143
+ _v: String(this.version)
2144
+ };
2145
+ const [ok, err] = await tryFn(async () => {
2146
+ await this.client.putObject({
2147
+ key: partitionKey,
2148
+ metadata: partitionMetadata,
2149
+ body: '',
2150
+ contentType: undefined,
2151
+ });
2152
+ });
2153
+ if (!ok) {
2154
+ // Log but don't fail if partition object doesn't exist
2155
+ // console.warn(`Partition object could not be updated for ${partitionName}:`, err.message);
2156
+ }
2157
+ }
2158
+ }
2159
+ }
2160
+
2161
+ /**
2162
+ * Get a resource object directly from a specific partition
2163
+ * @param {Object} params - Partition parameters
2164
+ * @param {string} params.id - Resource ID
2165
+ * @param {string} params.partitionName - Name of the partition
2166
+ * @param {Object} params.partitionValues - Values for partition fields
2167
+ * @returns {Promise<Object>} The resource object with partition metadata
2168
+ * @example
2169
+ * // Get user from UTM source partition
2170
+ * const user = await resource.getFromPartition({
2171
+ * id: 'user-123',
2172
+ * partitionName: 'byUtmSource',
2173
+ * partitionValues: { 'utm.source': 'google' }
2174
+ * });
2175
+ *
2176
+ * // Get product from multi-field partition
2177
+ * const product = await resource.getFromPartition({
2178
+ * id: 'product-456',
2179
+ * partitionName: 'byCategoryRegion',
2180
+ * partitionValues: { category: 'electronics', region: 'US' }
2181
+ * });
2182
+ */
2183
+ async getFromPartition({ id, partitionName, partitionValues = {} }) {
2184
+ if (!this.config.partitions || !this.config.partitions[partitionName]) {
2185
+ throw new PartitionError(`Partition '${partitionName}' not found`, { resourceName: this.name, partitionName, operation: 'getFromPartition' });
2186
+ }
2187
+
2188
+ const partition = this.config.partitions[partitionName];
2189
+
2190
+ // Build partition key using provided values
2191
+ const partitionSegments = [];
2192
+ const sortedFields = Object.entries(partition.fields).sort(([a], [b]) => a.localeCompare(b));
2193
+ for (const [fieldName, rule] of sortedFields) {
2194
+ const value = partitionValues[fieldName];
2195
+ if (value !== undefined && value !== null) {
2196
+ const transformedValue = this.applyPartitionRule(value, rule);
2197
+ partitionSegments.push(`${fieldName}=${transformedValue}`);
2198
+ }
2199
+ }
2200
+
2201
+ if (partitionSegments.length === 0) {
2202
+ throw new PartitionError(`No partition values provided for partition '${partitionName}'`, { resourceName: this.name, partitionName, operation: 'getFromPartition' });
2203
+ }
2204
+
2205
+ const partitionKey = join(`resource=${this.name}`, `partition=${partitionName}`, ...partitionSegments, `id=${id}`);
2206
+
2207
+ // Verify partition reference exists
2208
+ const [ok, err] = await tryFn(async () => {
2209
+ await this.client.headObject(partitionKey);
2210
+ });
2211
+ if (!ok) {
2212
+ throw new ResourceError(`Resource with id '${id}' not found in partition '${partitionName}'`, { resourceName: this.name, id, partitionName, operation: 'getFromPartition' });
2213
+ }
2214
+
2215
+ // Get the actual data from the main resource object
2216
+ const data = await this.get(id);
2217
+
2218
+ // Add partition metadata
2219
+ data._partition = partitionName;
2220
+ data._partitionValues = partitionValues;
2221
+
2222
+ this.emit("getFromPartition", data);
2223
+ return data;
2224
+ }
2225
+
2226
+ /**
2227
+ * Create a historical version of an object
2228
+ * @param {string} id - Resource ID
2229
+ * @param {Object} data - Object data to store historically
2230
+ */
2231
+ async createHistoricalVersion(id, data) {
2232
+ const historicalKey = join(`resource=${this.name}`, `historical`, `id=${id}`);
2233
+
2234
+ // Ensure the historical object has the _v metadata
2235
+ const historicalData = {
2236
+ ...data,
2237
+ _v: data._v || this.version,
2238
+ _historicalTimestamp: new Date().toISOString()
2239
+ };
2240
+
2241
+ const mappedData = await this.schema.mapper(historicalData);
2242
+
2243
+ // Apply behavior strategy for historical storage
2244
+ const behaviorImpl = getBehavior(this.behavior);
2245
+ const { mappedData: processedMetadata, body } = await behaviorImpl.handleInsert({
2246
+ resource: this,
2247
+ data: historicalData,
2248
+ mappedData
2249
+ });
2250
+
2251
+ // Add version metadata for consistency
2252
+ const finalMetadata = {
2253
+ ...processedMetadata,
2254
+ _v: data._v || this.version,
2255
+ _historicalTimestamp: historicalData._historicalTimestamp
2256
+ };
2257
+
2258
+ // Determine content type based on body content
2259
+ let contentType = undefined;
2260
+ if (body && body !== "") {
2261
+ const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(body)));
2262
+ if (okParse) contentType = 'application/json';
2263
+ }
2264
+
2265
+ await this.client.putObject({
2266
+ key: historicalKey,
2267
+ metadata: finalMetadata,
2268
+ body,
2269
+ contentType,
2270
+ });
2271
+ }
2272
+
2273
+ /**
2274
+ * Apply version mapping to convert an object from one version to another
2275
+ * @param {Object} data - Object data to map
2276
+ * @param {string} fromVersion - Source version
2277
+ * @param {string} toVersion - Target version
2278
+ * @returns {Object} Mapped object data
2279
+ */
2280
+ async applyVersionMapping(data, fromVersion, toVersion) {
2281
+ // If versions are the same, no mapping needed
2282
+ if (fromVersion === toVersion) {
2283
+ return data;
2284
+ }
2285
+
2286
+ // For now, we'll implement a simple mapping strategy
2287
+ // In a full implementation, this would use sophisticated version mappers
2288
+ // based on the schema evolution history
2289
+
2290
+ // Add version info to the returned data
2291
+ const mappedData = {
2292
+ ...data,
2293
+ _v: toVersion,
2294
+ _originalVersion: fromVersion,
2295
+ _versionMapped: true
2296
+ };
2297
+
2298
+ // TODO: Implement sophisticated version mapping logic here
2299
+ // This could involve:
2300
+ // 1. Field renames
2301
+ // 2. Field type changes
2302
+ // 3. Default values for new fields
2303
+ // 4. Data transformations
2304
+
2305
+ return mappedData;
2306
+ }
2307
+
2308
+ /**
2309
+ * Compose the full object (metadata + body) as retornado por .get(),
2310
+ * usando os dados em memória após insert/update, de acordo com o behavior
2311
+ */
2312
+ async composeFullObjectFromWrite({ id, metadata, body, behavior }) {
2313
+ // Preserve behavior flags before unmapping
2314
+ const behaviorFlags = {};
2315
+ if (metadata && metadata['$truncated'] === 'true') {
2316
+ behaviorFlags.$truncated = 'true';
2317
+ }
2318
+ if (metadata && metadata['$overflow'] === 'true') {
2319
+ behaviorFlags.$overflow = 'true';
2320
+ }
2321
+ // Always unmap metadata first to get the correct field names
2322
+ let unmappedMetadata = {};
2323
+ const [ok, err, unmapped] = await tryFn(() => this.schema.unmapper(metadata));
2324
+ unmappedMetadata = ok ? unmapped : metadata;
2325
+ // Helper function to filter out internal S3DB fields
2326
+ const filterInternalFields = (obj) => {
2327
+ if (!obj || typeof obj !== 'object') return obj;
2328
+ const filtered = {};
2329
+ for (const [key, value] of Object.entries(obj)) {
2330
+ if (!key.startsWith('_')) {
2331
+ filtered[key] = value;
2332
+ }
2333
+ }
2334
+ return filtered;
2335
+ };
2336
+ const fixValue = (v) => {
2337
+ if (typeof v === 'object' && v !== null) {
2338
+ return v;
2339
+ }
2340
+ if (typeof v === 'string') {
2341
+ if (v === '[object Object]') return {};
2342
+ if ((v.startsWith('{') || v.startsWith('['))) {
2343
+ // Use tryFnSync for safe parse
2344
+ const [ok, err, parsed] = tryFnSync(() => JSON.parse(v));
2345
+ return ok ? parsed : v;
2346
+ }
2347
+ return v;
2348
+ }
2349
+ return v;
2350
+ };
2351
+ if (behavior === 'body-overflow') {
2352
+ const hasOverflow = metadata && metadata['$overflow'] === 'true';
2353
+ let bodyData = {};
2354
+ if (hasOverflow && body) {
2355
+ const [okBody, errBody, parsedBody] = await tryFn(() => Promise.resolve(JSON.parse(body)));
2356
+ if (okBody) {
2357
+ const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody));
2358
+ bodyData = okUnmap ? unmappedBody : {};
2359
+ }
2360
+ }
2361
+ const merged = { ...unmappedMetadata, ...bodyData, id };
2362
+ Object.keys(merged).forEach(k => { merged[k] = fixValue(merged[k]); });
2363
+ const result = filterInternalFields(merged);
2364
+ if (hasOverflow) {
2365
+ result.$overflow = 'true';
2366
+ }
2367
+ return result;
2368
+ }
2369
+ if (behavior === 'body-only') {
2370
+ const [okBody, errBody, parsedBody] = await tryFn(() => Promise.resolve(body ? JSON.parse(body) : {}));
2371
+ let mapFromMeta = this.schema.map;
2372
+ if (metadata && metadata._map) {
2373
+ const [okMap, errMap, parsedMap] = await tryFn(() => Promise.resolve(typeof metadata._map === 'string' ? JSON.parse(metadata._map) : metadata._map));
2374
+ mapFromMeta = okMap ? parsedMap : this.schema.map;
2375
+ }
2376
+ const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody, mapFromMeta));
2377
+ const result = okUnmap ? { ...unmappedBody, id } : { id };
2378
+ Object.keys(result).forEach(k => { result[k] = fixValue(result[k]); });
2379
+ return result;
2380
+ }
2381
+ const result = { ...unmappedMetadata, id };
2382
+ Object.keys(result).forEach(k => { result[k] = fixValue(result[k]); });
2383
+ const filtered = filterInternalFields(result);
2384
+ if (behaviorFlags.$truncated) {
2385
+ filtered.$truncated = behaviorFlags.$truncated;
2386
+ }
2387
+ if (behaviorFlags.$overflow) {
2388
+ filtered.$overflow = behaviorFlags.$overflow;
2389
+ }
2390
+ return filtered;
2391
+ }
2392
+
2393
+ emit(event, ...args) {
2394
+ return super.emit(event, ...args);
2395
+ }
2396
+
2397
+ async replace(id, attributes) {
2398
+ await this.delete(id);
2399
+ await new Promise(r => setTimeout(r, 100));
2400
+ // Polling para garantir que a key foi removida do S3
2401
+ const maxWait = 5000;
2402
+ const interval = 50;
2403
+ const start = Date.now();
2404
+ let waited = 0;
2405
+ while (Date.now() - start < maxWait) {
2406
+ const exists = await this.exists(id);
2407
+ if (!exists) {
2408
+ break;
2409
+ }
2410
+ await new Promise(r => setTimeout(r, interval));
2411
+ waited = Date.now() - start;
2412
+ }
2413
+ if (waited >= maxWait) {
2414
+ }
2415
+ try {
2416
+ const result = await this.insert({ ...attributes, id });
2417
+ return result;
2418
+ } catch (err) {
2419
+ if (err && err.message && err.message.includes('already exists')) {
2420
+ const result = await this.update(id, attributes);
2421
+ return result;
2422
+ }
2423
+ throw err;
2424
+ }
2425
+ }
2426
+
2427
+ // --- MIDDLEWARE SYSTEM ---
2428
+ _initMiddleware() {
2429
+ // Map of methodName -> array of middleware functions
2430
+ this._middlewares = new Map();
2431
+ // Supported methods for middleware
2432
+ this._middlewareMethods = [
2433
+ 'get', 'list', 'listIds', 'getAll', 'count', 'page',
2434
+ 'insert', 'update', 'delete', 'deleteMany', 'exists', 'getMany'
2435
+ ];
2436
+ for (const method of this._middlewareMethods) {
2437
+ this._middlewares.set(method, []);
2438
+ // Wrap the method if not already wrapped
2439
+ if (!this[`_original_${method}`]) {
2440
+ this[`_original_${method}`] = this[method].bind(this);
2441
+ this[method] = async (...args) => {
2442
+ const ctx = { resource: this, args, method };
2443
+ let idx = -1;
2444
+ const stack = this._middlewares.get(method);
2445
+ const dispatch = async (i) => {
2446
+ if (i <= idx) throw new Error('next() called multiple times');
2447
+ idx = i;
2448
+ if (i < stack.length) {
2449
+ return await stack[i](ctx, () => dispatch(i + 1));
2450
+ } else {
2451
+ // Final handler: call the original method
2452
+ return await this[`_original_${method}`](...ctx.args);
2453
+ }
2454
+ };
2455
+ return await dispatch(0);
2456
+ };
2457
+ }
2458
+ }
2459
+ }
2460
+
2461
+ useMiddleware(method, fn) {
2462
+ if (!this._middlewares) this._initMiddleware();
2463
+ if (!this._middlewares.has(method)) throw new ResourceError(`No such method for middleware: ${method}`, { operation: 'useMiddleware', method });
2464
+ this._middlewares.get(method).push(fn);
2465
+ }
2466
+
2467
+ // Utilitário para aplicar valores default do schema
2468
+ applyDefaults(data) {
2469
+ const out = { ...data };
2470
+ for (const [key, def] of Object.entries(this.attributes)) {
2471
+ if (out[key] === undefined) {
2472
+ if (typeof def === 'string' && def.includes('default:')) {
2473
+ const match = def.match(/default:([^|]+)/);
2474
+ if (match) {
2475
+ let val = match[1];
2476
+ // Conversão para boolean/number se necessário
2477
+ if (def.includes('boolean')) val = val === 'true';
2478
+ else if (def.includes('number')) val = Number(val);
2479
+ out[key] = val;
2480
+ }
2481
+ }
2482
+ }
2483
+ }
2484
+ return out;
2485
+ }
2486
+
2487
+ }
2488
+
2489
+ /**
2490
+ * Validate Resource configuration object
2491
+ * @param {Object} config - Configuration object to validate
2492
+ * @returns {Object} Validation result with isValid flag and errors array
2493
+ */
2494
+ function validateResourceConfig(config) {
2495
+ const errors = [];
2496
+
2497
+ // Validate required fields
2498
+ if (!config.name) {
2499
+ errors.push("Resource 'name' is required");
2500
+ } else if (typeof config.name !== 'string') {
2501
+ errors.push("Resource 'name' must be a string");
2502
+ } else if (config.name.trim() === '') {
2503
+ errors.push("Resource 'name' cannot be empty");
2504
+ }
2505
+
2506
+ if (!config.client) {
2507
+ errors.push("S3 'client' is required");
2508
+ }
2509
+
2510
+ // Validate attributes
2511
+ if (!config.attributes) {
2512
+ errors.push("Resource 'attributes' are required");
2513
+ } else if (typeof config.attributes !== 'object' || Array.isArray(config.attributes)) {
2514
+ errors.push("Resource 'attributes' must be an object");
2515
+ } else if (Object.keys(config.attributes).length === 0) {
2516
+ errors.push("Resource 'attributes' cannot be empty");
2517
+ }
2518
+
2519
+ // Validate optional fields with type checking
2520
+ if (config.version !== undefined && typeof config.version !== 'string') {
2521
+ errors.push("Resource 'version' must be a string");
2522
+ }
2523
+
2524
+ if (config.behavior !== undefined && typeof config.behavior !== 'string') {
2525
+ errors.push("Resource 'behavior' must be a string");
2526
+ }
2527
+
2528
+ if (config.passphrase !== undefined && typeof config.passphrase !== 'string') {
2529
+ errors.push("Resource 'passphrase' must be a string");
2530
+ }
2531
+
2532
+ if (config.parallelism !== undefined) {
2533
+ if (typeof config.parallelism !== 'number' || !Number.isInteger(config.parallelism)) {
2534
+ errors.push("Resource 'parallelism' must be an integer");
2535
+ } else if (config.parallelism < 1) {
2536
+ errors.push("Resource 'parallelism' must be greater than 0");
2537
+ }
2538
+ }
2539
+
2540
+ if (config.observers !== undefined && !Array.isArray(config.observers)) {
2541
+ errors.push("Resource 'observers' must be an array");
2542
+ }
2543
+
2544
+ // Validate boolean fields
2545
+ const booleanFields = ['cache', 'autoDecrypt', 'timestamps', 'paranoid', 'allNestedObjectsOptional'];
2546
+ for (const field of booleanFields) {
2547
+ if (config[field] !== undefined && typeof config[field] !== 'boolean') {
2548
+ errors.push(`Resource '${field}' must be a boolean`);
2549
+ }
2550
+ }
2551
+
2552
+ // Validate idGenerator
2553
+ if (config.idGenerator !== undefined) {
2554
+ if (typeof config.idGenerator !== 'function' && typeof config.idGenerator !== 'number') {
2555
+ errors.push("Resource 'idGenerator' must be a function or a number (size)");
2556
+ } else if (typeof config.idGenerator === 'number' && config.idGenerator <= 0) {
2557
+ errors.push("Resource 'idGenerator' size must be greater than 0");
2558
+ }
2559
+ }
2560
+
2561
+ // Validate idSize
2562
+ if (config.idSize !== undefined) {
2563
+ if (typeof config.idSize !== 'number' || !Number.isInteger(config.idSize)) {
2564
+ errors.push("Resource 'idSize' must be an integer");
2565
+ } else if (config.idSize <= 0) {
2566
+ errors.push("Resource 'idSize' must be greater than 0");
2567
+ }
2568
+ }
2569
+
2570
+ // Validate partitions
2571
+ if (config.partitions !== undefined) {
2572
+ if (typeof config.partitions !== 'object' || Array.isArray(config.partitions)) {
2573
+ errors.push("Resource 'partitions' must be an object");
2574
+ } else {
2575
+ for (const [partitionName, partitionDef] of Object.entries(config.partitions)) {
2576
+ if (typeof partitionDef !== 'object' || Array.isArray(partitionDef)) {
2577
+ errors.push(`Partition '${partitionName}' must be an object`);
2578
+ } else if (!partitionDef.fields) {
2579
+ errors.push(`Partition '${partitionName}' must have a 'fields' property`);
2580
+ } else if (typeof partitionDef.fields !== 'object' || Array.isArray(partitionDef.fields)) {
2581
+ errors.push(`Partition '${partitionName}.fields' must be an object`);
2582
+ } else {
2583
+ for (const [fieldName, fieldType] of Object.entries(partitionDef.fields)) {
2584
+ if (typeof fieldType !== 'string') {
2585
+ errors.push(`Partition '${partitionName}.fields.${fieldName}' must be a string`);
2586
+ }
2587
+ }
2588
+ }
2589
+ }
2590
+ }
2591
+ }
2592
+
2593
+ // Validate hooks
2594
+ if (config.hooks !== undefined) {
2595
+ if (typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
2596
+ errors.push("Resource 'hooks' must be an object");
2597
+ } else {
2598
+ const validHookEvents = ['beforeInsert', 'afterInsert', 'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete'];
2599
+ for (const [event, hooksArr] of Object.entries(config.hooks)) {
2600
+ if (!validHookEvents.includes(event)) {
2601
+ errors.push(`Invalid hook event '${event}'. Valid events: ${validHookEvents.join(', ')}`);
2602
+ } else if (!Array.isArray(hooksArr)) {
2603
+ errors.push(`Resource 'hooks.${event}' must be an array`);
2604
+ } else {
2605
+ for (let i = 0; i < hooksArr.length; i++) {
2606
+ const hook = hooksArr[i];
2607
+ // Only validate user-provided hooks for being functions
2608
+ if (typeof hook !== 'function') {
2609
+ // If the hook is a string (e.g., a placeholder or reference), skip error
2610
+ if (typeof hook === 'string') continue;
2611
+ // If the hook is not a function or string, skip error (system/plugin hooks)
2612
+ continue;
2613
+ }
2614
+ }
2615
+ }
2616
+ }
2617
+ }
2618
+ }
2619
+
2620
+ return {
2621
+ isValid: errors.length === 0,
2622
+ errors
2623
+ };
2624
+ }
2625
+
2626
+ export default Resource;