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.
- package/PLUGINS.md +2724 -0
- package/README.md +372 -469
- package/UNLICENSE +24 -0
- package/dist/s3db.cjs.js +12105 -19396
- package/dist/s3db.cjs.min.js +1 -1
- package/dist/s3db.d.ts +373 -72
- package/dist/s3db.es.js +12090 -19393
- package/dist/s3db.es.min.js +1 -1
- package/dist/s3db.iife.js +12103 -19398
- package/dist/s3db.iife.min.js +1 -1
- package/package.json +44 -38
- package/src/behaviors/body-only.js +110 -0
- package/src/behaviors/body-overflow.js +153 -0
- package/src/behaviors/enforce-limits.js +195 -0
- package/src/behaviors/index.js +39 -0
- package/src/behaviors/truncate-data.js +204 -0
- package/src/behaviors/user-managed.js +147 -0
- package/src/client.class.js +515 -0
- package/src/concerns/base62.js +61 -0
- package/src/concerns/calculator.js +204 -0
- package/src/concerns/crypto.js +159 -0
- package/src/concerns/id.js +8 -0
- package/src/concerns/index.js +5 -0
- package/src/concerns/try-fn.js +151 -0
- package/src/connection-string.class.js +75 -0
- package/src/database.class.js +599 -0
- package/src/errors.js +261 -0
- package/src/index.js +17 -0
- package/src/plugins/audit.plugin.js +442 -0
- package/src/plugins/cache/cache.class.js +53 -0
- package/src/plugins/cache/index.js +6 -0
- package/src/plugins/cache/memory-cache.class.js +164 -0
- package/src/plugins/cache/s3-cache.class.js +189 -0
- package/src/plugins/cache.plugin.js +275 -0
- package/src/plugins/consumers/index.js +24 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
- package/src/plugins/consumers/sqs-consumer.js +102 -0
- package/src/plugins/costs.plugin.js +81 -0
- package/src/plugins/fulltext.plugin.js +473 -0
- package/src/plugins/index.js +12 -0
- package/src/plugins/metrics.plugin.js +603 -0
- package/src/plugins/plugin.class.js +210 -0
- package/src/plugins/plugin.obj.js +13 -0
- package/src/plugins/queue-consumer.plugin.js +134 -0
- package/src/plugins/replicator.plugin.js +769 -0
- package/src/plugins/replicators/base-replicator.class.js +85 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
- package/src/plugins/replicators/index.js +44 -0
- package/src/plugins/replicators/postgres-replicator.class.js +427 -0
- package/src/plugins/replicators/s3db-replicator.class.js +352 -0
- package/src/plugins/replicators/sqs-replicator.class.js +427 -0
- package/src/resource.class.js +2626 -0
- package/src/s3db.d.ts +1263 -0
- package/src/schema.class.js +706 -0
- package/src/stream/index.js +16 -0
- package/src/stream/resource-ids-page-reader.class.js +10 -0
- package/src/stream/resource-ids-reader.class.js +63 -0
- package/src/stream/resource-reader.class.js +81 -0
- package/src/stream/resource-writer.class.js +92 -0
- 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;
|