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.
- package/dist/s3db.cjs.js +454 -3833
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +9 -1
- package/dist/s3db.es.js +451 -3830
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +91 -57
- package/package.json +15 -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/plugin.class.js +5 -0
- package/src/plugins/relation.plugin.js +183 -47
- package/src/plugins/replicator.plugin.js +2 -1
- package/src/plugins/ttl.plugin.js +478 -303
- package/src/resource.class.js +309 -34
- package/src/s3db.d.ts +9 -1
- package/dist/s3db-cli.js +0 -55543
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
|
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: {
|
|
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[] }>;
|