s3db.js 12.0.0 → 12.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/dist/s3db.cjs.js +313 -62
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +9 -1
- package/dist/s3db.es.js +312 -62
- package/dist/s3db.es.js.map +1 -1
- package/package.json +14 -15
- package/src/client.class.js +40 -5
- package/src/concerns/plugin-storage.js +67 -37
- package/src/plugins/api/index.js +5 -0
- package/src/plugins/api/server.js +4 -0
- package/src/plugins/replicator.plugin.js +2 -1
- package/src/resource.class.js +309 -34
- package/src/s3db.d.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s3db.js",
|
|
3
|
-
"version": "12.0.
|
|
3
|
+
"version": "12.0.1",
|
|
4
4
|
"description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
|
|
5
5
|
"main": "dist/s3db.cjs.js",
|
|
6
6
|
"module": "dist/s3db.es.js",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"UNLICENSE"
|
|
66
66
|
],
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@aws-sdk/client-s3": "^3.
|
|
69
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
70
|
-
"@smithy/node-http-handler": "^4.
|
|
68
|
+
"@aws-sdk/client-s3": "^3.914.0",
|
|
69
|
+
"@modelcontextprotocol/sdk": "^1.20.1",
|
|
70
|
+
"@smithy/node-http-handler": "^4.4.2",
|
|
71
71
|
"@supercharge/promise-pool": "^3.2.0",
|
|
72
72
|
"dotenv": "^17.2.3",
|
|
73
73
|
"fastest-validator": "^1.19.1",
|
|
@@ -114,36 +114,35 @@
|
|
|
114
114
|
}
|
|
115
115
|
},
|
|
116
116
|
"devDependencies": {
|
|
117
|
-
"@aws-sdk/client-sqs": "^3.
|
|
117
|
+
"@aws-sdk/client-sqs": "^3.914.0",
|
|
118
118
|
"@babel/core": "^7.28.4",
|
|
119
119
|
"@babel/preset-env": "^7.28.3",
|
|
120
|
-
"@google-cloud/bigquery": "^7.
|
|
121
|
-
"@rollup/plugin-commonjs": "^28.0.
|
|
120
|
+
"@google-cloud/bigquery": "^7.9.4",
|
|
121
|
+
"@rollup/plugin-commonjs": "^28.0.8",
|
|
122
122
|
"@rollup/plugin-json": "^6.1.0",
|
|
123
|
-
"@rollup/plugin-node-resolve": "^16.0.
|
|
123
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
124
124
|
"@rollup/plugin-replace": "^6.0.2",
|
|
125
125
|
"@rollup/plugin-terser": "^0.4.4",
|
|
126
126
|
"@types/node": "24.7.0",
|
|
127
127
|
"@xenova/transformers": "^2.17.2",
|
|
128
|
-
"amqplib": "^0.10.
|
|
128
|
+
"amqplib": "^0.10.9",
|
|
129
129
|
"babel-loader": "^10.0.0",
|
|
130
130
|
"chalk": "^5.6.2",
|
|
131
131
|
"cli-table3": "^0.6.5",
|
|
132
132
|
"commander": "^14.0.1",
|
|
133
|
-
"esbuild": "^0.25.
|
|
134
|
-
"inquirer": "^12.
|
|
133
|
+
"esbuild": "^0.25.11",
|
|
134
|
+
"inquirer": "^12.10.0",
|
|
135
135
|
"jest": "^30.2.0",
|
|
136
|
-
"node-cron": "^4.
|
|
136
|
+
"node-cron": "^4.2.1",
|
|
137
137
|
"node-loader": "^2.1.0",
|
|
138
138
|
"ora": "^9.0.0",
|
|
139
|
-
"pg": "^8.
|
|
139
|
+
"pg": "^8.16.3",
|
|
140
140
|
"pkg": "^5.8.1",
|
|
141
|
-
"rollup": "^4.52.
|
|
141
|
+
"rollup": "^4.52.5",
|
|
142
142
|
"rollup-plugin-copy": "^3.5.0",
|
|
143
143
|
"rollup-plugin-esbuild": "^6.2.1",
|
|
144
144
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
|
145
145
|
"rollup-plugin-shebang-bin": "^0.1.0",
|
|
146
|
-
"rollup-plugin-terser": "^7.0.2",
|
|
147
146
|
"tsx": "^4.20.6",
|
|
148
147
|
"typescript": "5.9.3",
|
|
149
148
|
"uuid": "^13.0.0",
|
package/src/client.class.js
CHANGED
|
@@ -203,7 +203,21 @@ export class Client extends EventEmitter {
|
|
|
203
203
|
Key: keyPrefix ? path.join(keyPrefix, key) : key,
|
|
204
204
|
};
|
|
205
205
|
|
|
206
|
-
const [ok, err, response] = await tryFn(() =>
|
|
206
|
+
const [ok, err, response] = await tryFn(async () => {
|
|
207
|
+
const res = await this.sendCommand(new HeadObjectCommand(options));
|
|
208
|
+
|
|
209
|
+
// Smart decode metadata values (same as getObject)
|
|
210
|
+
if (res.Metadata) {
|
|
211
|
+
const decodedMetadata = {};
|
|
212
|
+
for (const [key, value] of Object.entries(res.Metadata)) {
|
|
213
|
+
decodedMetadata[key] = metadataDecode(value);
|
|
214
|
+
}
|
|
215
|
+
res.Metadata = decodedMetadata;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return res;
|
|
219
|
+
});
|
|
220
|
+
|
|
207
221
|
this.emit('headObject', err || response, { key });
|
|
208
222
|
|
|
209
223
|
if (!ok) {
|
|
@@ -218,15 +232,36 @@ export class Client extends EventEmitter {
|
|
|
218
232
|
return response;
|
|
219
233
|
}
|
|
220
234
|
|
|
221
|
-
async copyObject({ from, to }) {
|
|
235
|
+
async copyObject({ from, to, metadata, metadataDirective, contentType }) {
|
|
236
|
+
const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
|
|
222
237
|
const options = {
|
|
223
238
|
Bucket: this.config.bucket,
|
|
224
|
-
Key:
|
|
225
|
-
CopySource: path.join(this.config.bucket,
|
|
239
|
+
Key: keyPrefix ? path.join(keyPrefix, to) : to,
|
|
240
|
+
CopySource: path.join(this.config.bucket, keyPrefix ? path.join(keyPrefix, from) : from),
|
|
226
241
|
};
|
|
227
242
|
|
|
243
|
+
// Add metadata directive if specified
|
|
244
|
+
if (metadataDirective) {
|
|
245
|
+
options.MetadataDirective = metadataDirective; // 'COPY' or 'REPLACE'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Add metadata if specified (and encode values)
|
|
249
|
+
if (metadata && typeof metadata === 'object') {
|
|
250
|
+
const encodedMetadata = {};
|
|
251
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
252
|
+
const { encoded } = metadataEncode(value);
|
|
253
|
+
encodedMetadata[key] = encoded;
|
|
254
|
+
}
|
|
255
|
+
options.Metadata = encodedMetadata;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Add content type if specified
|
|
259
|
+
if (contentType) {
|
|
260
|
+
options.ContentType = contentType;
|
|
261
|
+
}
|
|
262
|
+
|
|
228
263
|
const [ok, err, response] = await tryFn(() => this.sendCommand(new CopyObjectCommand(options)));
|
|
229
|
-
this.emit('copyObject', err || response, { from, to });
|
|
264
|
+
this.emit('copyObject', err || response, { from, to, metadataDirective });
|
|
230
265
|
|
|
231
266
|
if (!ok) {
|
|
232
267
|
throw mapAwsError(err, {
|
|
@@ -456,7 +456,9 @@ export class PluginStorage {
|
|
|
456
456
|
* @returns {Promise<boolean>} True if extended, false if not found or no TTL
|
|
457
457
|
*/
|
|
458
458
|
async touch(key, additionalSeconds) {
|
|
459
|
-
|
|
459
|
+
// Optimization: Use HEAD + COPY instead of GET + PUT for metadata-only updates
|
|
460
|
+
// This avoids transferring the body when only updating the TTL
|
|
461
|
+
const [ok, err, response] = await tryFn(() => this.client.headObject(key));
|
|
460
462
|
|
|
461
463
|
if (!ok) {
|
|
462
464
|
return false;
|
|
@@ -465,50 +467,34 @@ export class PluginStorage {
|
|
|
465
467
|
const metadata = response.Metadata || {};
|
|
466
468
|
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
467
469
|
|
|
468
|
-
let data = parsedMetadata;
|
|
469
|
-
|
|
470
|
-
if (response.Body) {
|
|
471
|
-
const [ok, err, result] = await tryFn(async () => {
|
|
472
|
-
const bodyContent = await response.Body.transformToString();
|
|
473
|
-
if (bodyContent && bodyContent.trim()) {
|
|
474
|
-
const body = JSON.parse(bodyContent);
|
|
475
|
-
return { ...parsedMetadata, ...body };
|
|
476
|
-
}
|
|
477
|
-
return parsedMetadata;
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
if (!ok) {
|
|
481
|
-
return false; // Parse error
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
data = result;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
470
|
// S3 lowercases metadata keys
|
|
488
|
-
const expiresAt =
|
|
471
|
+
const expiresAt = parsedMetadata._expiresat || parsedMetadata._expiresAt;
|
|
489
472
|
if (!expiresAt) {
|
|
490
473
|
return false; // No TTL to extend
|
|
491
474
|
}
|
|
492
475
|
|
|
493
476
|
// Extend TTL - use the standard field name (will be lowercased by S3)
|
|
494
|
-
|
|
495
|
-
delete
|
|
496
|
-
|
|
497
|
-
//
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
metadata: newMetadata,
|
|
503
|
-
contentType: 'application/json'
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
if (newBody !== null) {
|
|
507
|
-
putParams.body = JSON.stringify(newBody);
|
|
477
|
+
parsedMetadata._expiresAt = expiresAt + (additionalSeconds * 1000);
|
|
478
|
+
delete parsedMetadata._expiresat; // Remove lowercased version
|
|
479
|
+
|
|
480
|
+
// Encode metadata for S3
|
|
481
|
+
const encodedMetadata = {};
|
|
482
|
+
for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
|
|
483
|
+
const { encoded } = metadataEncode(metaValue);
|
|
484
|
+
encodedMetadata[metaKey] = encoded;
|
|
508
485
|
}
|
|
509
486
|
|
|
510
|
-
|
|
511
|
-
|
|
487
|
+
// Use COPY with MetadataDirective: REPLACE to update metadata atomically
|
|
488
|
+
// This preserves the body without re-transferring it
|
|
489
|
+
const [copyOk] = await tryFn(() => this.client.copyObject({
|
|
490
|
+
from: key,
|
|
491
|
+
to: key,
|
|
492
|
+
metadata: encodedMetadata,
|
|
493
|
+
metadataDirective: 'REPLACE',
|
|
494
|
+
contentType: response.ContentType || 'application/json'
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
return copyOk;
|
|
512
498
|
}
|
|
513
499
|
|
|
514
500
|
/**
|
|
@@ -675,12 +661,56 @@ export class PluginStorage {
|
|
|
675
661
|
/**
|
|
676
662
|
* Increment a counter value
|
|
677
663
|
*
|
|
664
|
+
* Optimization: Uses HEAD + COPY for existing counters to avoid body transfer.
|
|
665
|
+
* Falls back to GET + PUT for non-existent counters or those with additional data.
|
|
666
|
+
*
|
|
678
667
|
* @param {string} key - S3 key
|
|
679
668
|
* @param {number} amount - Amount to increment (default: 1)
|
|
680
669
|
* @param {Object} options - Options (e.g., ttl)
|
|
681
670
|
* @returns {Promise<number>} New value
|
|
682
671
|
*/
|
|
683
672
|
async increment(key, amount = 1, options = {}) {
|
|
673
|
+
// Try optimized path first: HEAD + COPY for existing counters
|
|
674
|
+
const [headOk, headErr, headResponse] = await tryFn(() => this.client.headObject(key));
|
|
675
|
+
|
|
676
|
+
if (headOk && headResponse.Metadata) {
|
|
677
|
+
// Counter exists, use optimized HEAD + COPY
|
|
678
|
+
const metadata = headResponse.Metadata || {};
|
|
679
|
+
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
680
|
+
|
|
681
|
+
const currentValue = parsedMetadata.value || 0;
|
|
682
|
+
const newValue = currentValue + amount;
|
|
683
|
+
|
|
684
|
+
// Update only the value field
|
|
685
|
+
parsedMetadata.value = newValue;
|
|
686
|
+
|
|
687
|
+
// Handle TTL if specified
|
|
688
|
+
if (options.ttl) {
|
|
689
|
+
parsedMetadata._expiresAt = Date.now() + (options.ttl * 1000);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Encode metadata
|
|
693
|
+
const encodedMetadata = {};
|
|
694
|
+
for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
|
|
695
|
+
const { encoded } = metadataEncode(metaValue);
|
|
696
|
+
encodedMetadata[metaKey] = encoded;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Atomic update via COPY
|
|
700
|
+
const [copyOk] = await tryFn(() => this.client.copyObject({
|
|
701
|
+
from: key,
|
|
702
|
+
to: key,
|
|
703
|
+
metadata: encodedMetadata,
|
|
704
|
+
metadataDirective: 'REPLACE',
|
|
705
|
+
contentType: headResponse.ContentType || 'application/json'
|
|
706
|
+
}));
|
|
707
|
+
|
|
708
|
+
if (copyOk) {
|
|
709
|
+
return newValue;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Fallback: counter doesn't exist or has body data, use traditional path
|
|
684
714
|
const data = await this.get(key);
|
|
685
715
|
const value = (data?.value || 0) + amount;
|
|
686
716
|
await this.set(key, { value }, options);
|
package/src/plugins/api/index.js
CHANGED
|
@@ -37,6 +37,11 @@ import { ApiServer } from './server.js';
|
|
|
37
37
|
import { requirePluginDependency } from '../concerns/plugin-dependencies.js';
|
|
38
38
|
import tryFn from '../../concerns/try-fn.js';
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* API Plugin class
|
|
42
|
+
* @class
|
|
43
|
+
* @extends Plugin
|
|
44
|
+
*/
|
|
40
45
|
export class ApiPlugin extends Plugin {
|
|
41
46
|
/**
|
|
42
47
|
* Create API Plugin instance
|
|
@@ -12,6 +12,10 @@ import { errorHandler } from './utils/error-handler.js';
|
|
|
12
12
|
import * as formatter from './utils/response-formatter.js';
|
|
13
13
|
import { generateOpenAPISpec } from './utils/openapi-generator.js';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* API Server class
|
|
17
|
+
* @class
|
|
18
|
+
*/
|
|
15
19
|
export class ApiServer {
|
|
16
20
|
/**
|
|
17
21
|
* Create API server
|
|
@@ -589,7 +589,8 @@ export class ReplicatorPlugin extends Plugin {
|
|
|
589
589
|
if (!this.replicatorLog) return;
|
|
590
590
|
|
|
591
591
|
const [ok, err] = await tryFn(async () => {
|
|
592
|
-
|
|
592
|
+
// Use patch() for 40-60% performance improvement (truncate-data behavior)
|
|
593
|
+
await this.replicatorLog.patch(logId, {
|
|
593
594
|
...updates,
|
|
594
595
|
lastAttempt: new Date().toISOString()
|
|
595
596
|
});
|
package/src/resource.class.js
CHANGED
|
@@ -13,7 +13,7 @@ import { ResourceReader, ResourceWriter } from "./stream/index.js"
|
|
|
13
13
|
import { getBehavior, DEFAULT_BEHAVIOR } from "./behaviors/index.js";
|
|
14
14
|
import { idGenerator as defaultIdGenerator } from "./concerns/id.js";
|
|
15
15
|
import { calculateTotalSize, calculateEffectiveLimit } from "./concerns/calculator.js";
|
|
16
|
-
import { mapAwsError, InvalidResourceItem, ResourceError, PartitionError } from "./errors.js";
|
|
16
|
+
import { mapAwsError, InvalidResourceItem, ResourceError, PartitionError, ValidationError } from "./errors.js";
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
export class Resource extends AsyncEventEmitter {
|
|
@@ -1251,6 +1251,314 @@ export class Resource extends AsyncEventEmitter {
|
|
|
1251
1251
|
}
|
|
1252
1252
|
}
|
|
1253
1253
|
|
|
1254
|
+
/**
|
|
1255
|
+
* Patch resource (partial update optimized for metadata-only behaviors)
|
|
1256
|
+
*
|
|
1257
|
+
* This method provides an optimized update path for resources using metadata-only behaviors
|
|
1258
|
+
* (enforce-limits, truncate-data). It uses HeadObject + CopyObject for atomic updates without
|
|
1259
|
+
* body transfer, eliminating race conditions and reducing latency by ~50%.
|
|
1260
|
+
*
|
|
1261
|
+
* For behaviors that store data in body (body-overflow, body-only), it automatically falls
|
|
1262
|
+
* back to the standard update() method.
|
|
1263
|
+
*
|
|
1264
|
+
* @param {string} id - Resource ID
|
|
1265
|
+
* @param {Object} fields - Fields to update (partial data)
|
|
1266
|
+
* @param {Object} options - Update options
|
|
1267
|
+
* @param {string} options.partition - Partition name (if using partitions)
|
|
1268
|
+
* @param {Object} options.partitionValues - Partition values (if using partitions)
|
|
1269
|
+
* @returns {Promise<Object>} Updated resource data
|
|
1270
|
+
*
|
|
1271
|
+
* @example
|
|
1272
|
+
* // Fast atomic update (enforce-limits behavior)
|
|
1273
|
+
* await resource.patch('user-123', { status: 'active', loginCount: 42 });
|
|
1274
|
+
*
|
|
1275
|
+
* @example
|
|
1276
|
+
* // With partitions
|
|
1277
|
+
* await resource.patch('order-456', { status: 'shipped' }, {
|
|
1278
|
+
* partition: 'byRegion',
|
|
1279
|
+
* partitionValues: { region: 'US' }
|
|
1280
|
+
* });
|
|
1281
|
+
*/
|
|
1282
|
+
async patch(id, fields, options = {}) {
|
|
1283
|
+
if (isEmpty(id)) {
|
|
1284
|
+
throw new Error('id cannot be empty');
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (!fields || typeof fields !== 'object') {
|
|
1288
|
+
throw new Error('fields must be a non-empty object');
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const behavior = this.behavior;
|
|
1292
|
+
|
|
1293
|
+
// Check if fields contain dot notation (nested fields)
|
|
1294
|
+
const hasNestedFields = Object.keys(fields).some(key => key.includes('.'));
|
|
1295
|
+
|
|
1296
|
+
// ✅ Optimization: HEAD + COPY for metadata-only behaviors WITHOUT nested fields
|
|
1297
|
+
if ((behavior === 'enforce-limits' || behavior === 'truncate-data') && !hasNestedFields) {
|
|
1298
|
+
return await this._patchViaCopyObject(id, fields, options);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// ⚠️ Fallback: GET + merge + PUT for:
|
|
1302
|
+
// - Behaviors with body storage
|
|
1303
|
+
// - Nested field updates (need full object merge)
|
|
1304
|
+
return await this.update(id, fields, options);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Internal helper: Optimized patch using HeadObject + CopyObject
|
|
1309
|
+
* Only works for metadata-only behaviors (enforce-limits, truncate-data)
|
|
1310
|
+
* Only for simple field updates (no nested fields with dot notation)
|
|
1311
|
+
* @private
|
|
1312
|
+
*/
|
|
1313
|
+
async _patchViaCopyObject(id, fields, options = {}) {
|
|
1314
|
+
const { partition, partitionValues } = options;
|
|
1315
|
+
|
|
1316
|
+
// Build S3 key
|
|
1317
|
+
const key = this.getResourceKey(id);
|
|
1318
|
+
|
|
1319
|
+
// Step 1: HEAD to get current metadata (optimization: no body transfer)
|
|
1320
|
+
const headResponse = await this.client.headObject(key);
|
|
1321
|
+
const currentMetadata = headResponse.Metadata || {};
|
|
1322
|
+
|
|
1323
|
+
// Step 2: Decode metadata to user format
|
|
1324
|
+
let currentData = await this.schema.unmapper(currentMetadata);
|
|
1325
|
+
|
|
1326
|
+
// Ensure ID is present
|
|
1327
|
+
if (!currentData.id) {
|
|
1328
|
+
currentData.id = id;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Step 3: Merge with new fields (simple merge, no nested fields)
|
|
1332
|
+
const fieldsClone = cloneDeep(fields);
|
|
1333
|
+
let mergedData = cloneDeep(currentData);
|
|
1334
|
+
|
|
1335
|
+
for (const [key, value] of Object.entries(fieldsClone)) {
|
|
1336
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1337
|
+
// Merge objects
|
|
1338
|
+
mergedData[key] = merge({}, mergedData[key], value);
|
|
1339
|
+
} else {
|
|
1340
|
+
mergedData[key] = cloneDeep(value);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Step 4: Update timestamps
|
|
1345
|
+
if (this.config.timestamps) {
|
|
1346
|
+
mergedData.updatedAt = new Date().toISOString();
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Step 5: Validate merged data
|
|
1350
|
+
const validationResult = await this.schema.validate(mergedData);
|
|
1351
|
+
if (validationResult !== true) {
|
|
1352
|
+
throw new ValidationError('Validation failed during patch', validationResult);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Step 6: Map/encode data to storage format
|
|
1356
|
+
const newMetadata = await this.schema.mapper(mergedData);
|
|
1357
|
+
|
|
1358
|
+
// Add version metadata
|
|
1359
|
+
newMetadata._v = String(this.version);
|
|
1360
|
+
|
|
1361
|
+
// Step 8: CopyObject with new metadata (atomic operation)
|
|
1362
|
+
await this.client.copyObject({
|
|
1363
|
+
from: key,
|
|
1364
|
+
to: key,
|
|
1365
|
+
metadataDirective: 'REPLACE',
|
|
1366
|
+
metadata: newMetadata
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// Step 9: Update partitions if needed
|
|
1370
|
+
if (this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
1371
|
+
const oldData = { ...currentData, id };
|
|
1372
|
+
const newData = { ...mergedData, id };
|
|
1373
|
+
|
|
1374
|
+
if (this.config.asyncPartitions) {
|
|
1375
|
+
// Async mode: update in background
|
|
1376
|
+
setImmediate(() => {
|
|
1377
|
+
this.handlePartitionReferenceUpdates(oldData, newData).catch(err => {
|
|
1378
|
+
this.emit('partitionIndexError', {
|
|
1379
|
+
operation: 'patch',
|
|
1380
|
+
id,
|
|
1381
|
+
error: err
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
});
|
|
1385
|
+
} else {
|
|
1386
|
+
// Sync mode: wait for completion
|
|
1387
|
+
await this.handlePartitionReferenceUpdates(oldData, newData);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return mergedData;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Replace resource (full object replacement without GET)
|
|
1396
|
+
*
|
|
1397
|
+
* This method performs a direct PUT operation without fetching the current object.
|
|
1398
|
+
* Use this when you already have the complete object and want to replace it entirely,
|
|
1399
|
+
* saving 1 S3 request (GET).
|
|
1400
|
+
*
|
|
1401
|
+
* ⚠️ Warning: You must provide ALL required fields. Missing fields will NOT be preserved
|
|
1402
|
+
* from the current object. This method does not merge with existing data.
|
|
1403
|
+
*
|
|
1404
|
+
* @param {string} id - Resource ID
|
|
1405
|
+
* @param {Object} fullData - Complete object data (all required fields)
|
|
1406
|
+
* @param {Object} options - Update options
|
|
1407
|
+
* @param {string} options.partition - Partition name (if using partitions)
|
|
1408
|
+
* @param {Object} options.partitionValues - Partition values (if using partitions)
|
|
1409
|
+
* @returns {Promise<Object>} Replaced resource data
|
|
1410
|
+
*
|
|
1411
|
+
* @example
|
|
1412
|
+
* // Replace entire object (must include ALL required fields)
|
|
1413
|
+
* await resource.replace('user-123', {
|
|
1414
|
+
* name: 'John Doe',
|
|
1415
|
+
* email: 'john@example.com',
|
|
1416
|
+
* status: 'active',
|
|
1417
|
+
* loginCount: 42
|
|
1418
|
+
* });
|
|
1419
|
+
*
|
|
1420
|
+
* @example
|
|
1421
|
+
* // With partitions
|
|
1422
|
+
* await resource.replace('order-456', fullOrderData, {
|
|
1423
|
+
* partition: 'byRegion',
|
|
1424
|
+
* partitionValues: { region: 'US' }
|
|
1425
|
+
* });
|
|
1426
|
+
*/
|
|
1427
|
+
async replace(id, fullData, options = {}) {
|
|
1428
|
+
if (isEmpty(id)) {
|
|
1429
|
+
throw new Error('id cannot be empty');
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (!fullData || typeof fullData !== 'object') {
|
|
1433
|
+
throw new Error('fullData must be a non-empty object');
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const { partition, partitionValues } = options;
|
|
1437
|
+
|
|
1438
|
+
// Clone data to avoid mutations
|
|
1439
|
+
const dataClone = cloneDeep(fullData);
|
|
1440
|
+
|
|
1441
|
+
// Apply defaults before timestamps
|
|
1442
|
+
const attributesWithDefaults = this.applyDefaults(dataClone);
|
|
1443
|
+
|
|
1444
|
+
// Add timestamps
|
|
1445
|
+
if (this.config.timestamps) {
|
|
1446
|
+
// Preserve createdAt if provided, otherwise set to now
|
|
1447
|
+
if (!attributesWithDefaults.createdAt) {
|
|
1448
|
+
attributesWithDefaults.createdAt = new Date().toISOString();
|
|
1449
|
+
}
|
|
1450
|
+
attributesWithDefaults.updatedAt = new Date().toISOString();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Ensure ID is set
|
|
1454
|
+
const completeData = { id, ...attributesWithDefaults };
|
|
1455
|
+
|
|
1456
|
+
// Validate data
|
|
1457
|
+
const {
|
|
1458
|
+
errors,
|
|
1459
|
+
isValid,
|
|
1460
|
+
data: validated,
|
|
1461
|
+
} = await this.validate(completeData);
|
|
1462
|
+
|
|
1463
|
+
if (!isValid) {
|
|
1464
|
+
const errorMsg = (errors && errors.length && errors[0].message) ? errors[0].message : 'Replace failed';
|
|
1465
|
+
throw new InvalidResourceItem({
|
|
1466
|
+
bucket: this.client.config.bucket,
|
|
1467
|
+
resourceName: this.name,
|
|
1468
|
+
attributes: completeData,
|
|
1469
|
+
validation: errors,
|
|
1470
|
+
message: errorMsg
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Extract id and attributes from validated data
|
|
1475
|
+
const { id: validatedId, ...validatedAttributes } = validated;
|
|
1476
|
+
|
|
1477
|
+
// Map/encode data to storage format
|
|
1478
|
+
const mappedMetadata = await this.schema.mapper(validatedAttributes);
|
|
1479
|
+
|
|
1480
|
+
// Add version metadata
|
|
1481
|
+
mappedMetadata._v = String(this.version);
|
|
1482
|
+
|
|
1483
|
+
// Use behavior to store data (like insert, not update)
|
|
1484
|
+
const behaviorImpl = getBehavior(this.behavior);
|
|
1485
|
+
const { mappedData: finalMetadata, body } = await behaviorImpl.handleInsert({
|
|
1486
|
+
resource: this,
|
|
1487
|
+
data: validatedAttributes,
|
|
1488
|
+
mappedData: mappedMetadata,
|
|
1489
|
+
originalData: completeData
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// Build S3 key
|
|
1493
|
+
const key = this.getResourceKey(id);
|
|
1494
|
+
|
|
1495
|
+
// Determine content type based on body content
|
|
1496
|
+
let contentType = undefined;
|
|
1497
|
+
if (body && body !== "") {
|
|
1498
|
+
const [okParse] = await tryFn(() => Promise.resolve(JSON.parse(body)));
|
|
1499
|
+
if (okParse) contentType = 'application/json';
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Only throw if behavior is 'body-only' and body is empty
|
|
1503
|
+
if (this.behavior === 'body-only' && (!body || body === "")) {
|
|
1504
|
+
throw new Error(`[Resource.replace] Attempt to save object without body! Data: id=${id}, resource=${this.name}`);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// Store to S3 (overwrites if exists, creates if not - true replace/upsert)
|
|
1508
|
+
const [okPut, errPut] = await tryFn(() => this.client.putObject({
|
|
1509
|
+
key,
|
|
1510
|
+
body,
|
|
1511
|
+
contentType,
|
|
1512
|
+
metadata: finalMetadata,
|
|
1513
|
+
}));
|
|
1514
|
+
|
|
1515
|
+
if (!okPut) {
|
|
1516
|
+
const msg = errPut && errPut.message ? errPut.message : '';
|
|
1517
|
+
if (msg.includes('metadata headers exceed') || msg.includes('Replace failed')) {
|
|
1518
|
+
const totalSize = calculateTotalSize(finalMetadata);
|
|
1519
|
+
const effectiveLimit = calculateEffectiveLimit({
|
|
1520
|
+
s3Limit: 2047,
|
|
1521
|
+
systemConfig: {
|
|
1522
|
+
version: this.version,
|
|
1523
|
+
timestamps: this.config.timestamps,
|
|
1524
|
+
id
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
const excess = totalSize - effectiveLimit;
|
|
1528
|
+
errPut.totalSize = totalSize;
|
|
1529
|
+
errPut.limit = 2047;
|
|
1530
|
+
errPut.effectiveLimit = effectiveLimit;
|
|
1531
|
+
errPut.excess = excess;
|
|
1532
|
+
throw new ResourceError('metadata headers exceed', { resourceName: this.name, operation: 'replace', id, totalSize, effectiveLimit, excess, suggestion: 'Reduce metadata size or number of fields.' });
|
|
1533
|
+
}
|
|
1534
|
+
throw errPut;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// Build the final object to return
|
|
1538
|
+
const replacedObject = { id, ...validatedAttributes };
|
|
1539
|
+
|
|
1540
|
+
// Update partitions if needed
|
|
1541
|
+
if (this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
1542
|
+
if (this.config.asyncPartitions) {
|
|
1543
|
+
// Async mode: update partition indexes in background
|
|
1544
|
+
setImmediate(() => {
|
|
1545
|
+
this.handlePartitionReferenceUpdates({}, replacedObject).catch(err => {
|
|
1546
|
+
this.emit('partitionIndexError', {
|
|
1547
|
+
operation: 'replace',
|
|
1548
|
+
id,
|
|
1549
|
+
error: err
|
|
1550
|
+
});
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1553
|
+
} else {
|
|
1554
|
+
// Sync mode: update partition indexes immediately
|
|
1555
|
+
await this.handlePartitionReferenceUpdates({}, replacedObject);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
return replacedObject;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1254
1562
|
/**
|
|
1255
1563
|
* Update with conditional check (If-Match ETag)
|
|
1256
1564
|
* @param {string} id - Resource ID
|
|
@@ -2867,39 +3175,6 @@ export class Resource extends AsyncEventEmitter {
|
|
|
2867
3175
|
return filtered;
|
|
2868
3176
|
}
|
|
2869
3177
|
|
|
2870
|
-
|
|
2871
|
-
async replace(id, attributes) {
|
|
2872
|
-
await this.delete(id);
|
|
2873
|
-
await new Promise(r => setTimeout(r, 100));
|
|
2874
|
-
// Polling para garantir que a key foi removida do S3
|
|
2875
|
-
const maxWait = 5000;
|
|
2876
|
-
const interval = 50;
|
|
2877
|
-
const start = Date.now();
|
|
2878
|
-
let waited = 0;
|
|
2879
|
-
while (Date.now() - start < maxWait) {
|
|
2880
|
-
const exists = await this.exists(id);
|
|
2881
|
-
if (!exists) {
|
|
2882
|
-
break;
|
|
2883
|
-
}
|
|
2884
|
-
await new Promise(r => setTimeout(r, interval));
|
|
2885
|
-
waited = Date.now() - start;
|
|
2886
|
-
}
|
|
2887
|
-
if (waited >= maxWait) {
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
const [ok, err, result] = await tryFn(() => this.insert({ ...attributes, id }));
|
|
2891
|
-
|
|
2892
|
-
if (!ok) {
|
|
2893
|
-
if (err && err.message && err.message.includes('already exists')) {
|
|
2894
|
-
const updateResult = await this.update(id, attributes);
|
|
2895
|
-
return updateResult;
|
|
2896
|
-
}
|
|
2897
|
-
throw err;
|
|
2898
|
-
}
|
|
2899
|
-
|
|
2900
|
-
return result;
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
3178
|
// --- MIDDLEWARE SYSTEM ---
|
|
2904
3179
|
_initMiddleware() {
|
|
2905
3180
|
// Map of methodName -> array of middleware functions
|