s3db.js 12.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
package/src/s3db.d.ts CHANGED
@@ -723,6 +723,8 @@ declare module 's3db.js' {
723
723
  get(id: string): Promise<any>;
724
724
  exists(id: string): Promise<boolean>;
725
725
  update(id: string, attributes: any): Promise<any>;
726
+ patch(id: string, fields: any, options?: { partition?: string; partitionValues?: Record<string, any> }): Promise<any>;
727
+ replace(id: string, fullData: any, options?: { partition?: string; partitionValues?: Record<string, any> }): Promise<any>;
726
728
  upsert(data: any): Promise<any>;
727
729
  delete(id: string): Promise<void>;
728
730
  deleteMany(ids: string[]): Promise<void>;
@@ -841,7 +843,13 @@ declare module 's3db.js' {
841
843
  }): Promise<any>;
842
844
  getObject(key: string): Promise<any>;
843
845
  headObject(key: string): Promise<any>;
844
- copyObject(options: { from: string; to: string }): Promise<any>;
846
+ copyObject(options: {
847
+ from: string;
848
+ to: string;
849
+ metadata?: Record<string, any>;
850
+ metadataDirective?: 'COPY' | 'REPLACE';
851
+ contentType?: string;
852
+ }): Promise<any>;
845
853
  exists(key: string): Promise<boolean>;
846
854
  deleteObject(key: string): Promise<any>;
847
855
  deleteObjects(keys: string[]): Promise<{ deleted: any[]; notFound: any[] }>;