s3db.js 11.3.2 → 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/README.md +102 -8
- package/dist/s3db.cjs.js +36945 -15510
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +66 -1
- package/dist/s3db.es.js +36914 -15534
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +58 -0
- package/mcp/tools/documentation.js +434 -0
- package/mcp/tools/index.js +4 -0
- package/package.json +35 -15
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +79 -49
- package/src/concerns/base62.js +85 -0
- package/src/concerns/dictionary-encoding.js +294 -0
- package/src/concerns/geo-encoding.js +256 -0
- package/src/concerns/high-performance-inserter.js +34 -30
- package/src/concerns/ip.js +325 -0
- package/src/concerns/metadata-encoding.js +345 -66
- package/src/concerns/money.js +193 -0
- package/src/concerns/partition-queue.js +7 -4
- package/src/concerns/plugin-storage.js +97 -47
- package/src/database.class.js +76 -74
- package/src/errors.js +0 -4
- package/src/plugins/api/auth/api-key-auth.js +88 -0
- package/src/plugins/api/auth/basic-auth.js +154 -0
- package/src/plugins/api/auth/index.js +112 -0
- package/src/plugins/api/auth/jwt-auth.js +169 -0
- package/src/plugins/api/index.js +544 -0
- package/src/plugins/api/middlewares/index.js +15 -0
- package/src/plugins/api/middlewares/validator.js +185 -0
- package/src/plugins/api/routes/auth-routes.js +241 -0
- package/src/plugins/api/routes/resource-routes.js +304 -0
- package/src/plugins/api/server.js +354 -0
- package/src/plugins/api/utils/error-handler.js +147 -0
- package/src/plugins/api/utils/openapi-generator.js +1240 -0
- package/src/plugins/api/utils/response-formatter.js +218 -0
- package/src/plugins/backup/streaming-exporter.js +132 -0
- package/src/plugins/backup.plugin.js +103 -50
- package/src/plugins/cache/s3-cache.class.js +95 -47
- package/src/plugins/cache.plugin.js +107 -9
- package/src/plugins/concerns/plugin-dependencies.js +313 -0
- package/src/plugins/concerns/prometheus-formatter.js +255 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
- package/src/plugins/consumers/sqs-consumer.js +4 -0
- package/src/plugins/costs.plugin.js +255 -39
- package/src/plugins/eventual-consistency/helpers.js +15 -1
- package/src/plugins/geo.plugin.js +873 -0
- package/src/plugins/importer/index.js +1020 -0
- package/src/plugins/index.js +11 -0
- package/src/plugins/metrics.plugin.js +163 -4
- package/src/plugins/queue-consumer.plugin.js +6 -27
- package/src/plugins/relation.errors.js +139 -0
- package/src/plugins/relation.plugin.js +1242 -0
- package/src/plugins/replicator.plugin.js +2 -1
- package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
- package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
- package/src/plugins/replicators/index.js +28 -3
- package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
- package/src/plugins/replicators/mysql-replicator.class.js +558 -0
- package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
- package/src/plugins/replicators/postgres-replicator.class.js +182 -7
- package/src/plugins/replicators/s3db-replicator.class.js +1 -12
- package/src/plugins/replicators/schema-sync.helper.js +601 -0
- package/src/plugins/replicators/sqs-replicator.class.js +11 -9
- package/src/plugins/replicators/turso-replicator.class.js +416 -0
- package/src/plugins/replicators/webhook-replicator.class.js +612 -0
- package/src/plugins/state-machine.plugin.js +122 -68
- package/src/plugins/tfstate/README.md +745 -0
- package/src/plugins/tfstate/base-driver.js +80 -0
- package/src/plugins/tfstate/errors.js +112 -0
- package/src/plugins/tfstate/filesystem-driver.js +129 -0
- package/src/plugins/tfstate/index.js +2660 -0
- package/src/plugins/tfstate/s3-driver.js +192 -0
- package/src/plugins/ttl.plugin.js +536 -0
- package/src/resource.class.js +315 -36
- package/src/s3db.d.ts +66 -1
- package/src/schema.class.js +366 -32
- package/SECURITY.md +0 -76
- package/src/partition-drivers/base-partition-driver.js +0 -106
- package/src/partition-drivers/index.js +0 -66
- package/src/partition-drivers/memory-partition-driver.js +0 -289
- package/src/partition-drivers/sqs-partition-driver.js +0 -337
- package/src/partition-drivers/sync-partition-driver.js +0 -38
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 {
|
|
@@ -22,7 +22,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
22
22
|
* @param {Object} config - Resource configuration
|
|
23
23
|
* @param {string} config.name - Resource name
|
|
24
24
|
* @param {Object} config.client - S3 client instance
|
|
25
|
-
* @param {string} [config.version='
|
|
25
|
+
* @param {string} [config.version='v1'] - Resource version
|
|
26
26
|
* @param {Object} [config.attributes={}] - Resource attributes schema
|
|
27
27
|
* @param {string} [config.behavior='user-managed'] - Resource behavior strategy
|
|
28
28
|
* @param {string} [config.passphrase='secret'] - Encryption passphrase
|
|
@@ -392,7 +392,8 @@ export class Resource extends AsyncEventEmitter {
|
|
|
392
392
|
this.attributes = newAttributes;
|
|
393
393
|
|
|
394
394
|
// Apply configuration to ensure timestamps and hooks are set up
|
|
395
|
-
|
|
395
|
+
// Don't pass old map - let it regenerate with new attributes
|
|
396
|
+
this.applyConfiguration();
|
|
396
397
|
|
|
397
398
|
return { oldAttributes, newAttributes };
|
|
398
399
|
}
|
|
@@ -1250,6 +1251,314 @@ export class Resource extends AsyncEventEmitter {
|
|
|
1250
1251
|
}
|
|
1251
1252
|
}
|
|
1252
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
|
+
|
|
1253
1562
|
/**
|
|
1254
1563
|
* Update with conditional check (If-Match ETag)
|
|
1255
1564
|
* @param {string} id - Resource ID
|
|
@@ -2291,7 +2600,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
2291
2600
|
|
|
2292
2601
|
/**
|
|
2293
2602
|
* Get schema for a specific version
|
|
2294
|
-
* @param {string} version - Version string (e.g., '
|
|
2603
|
+
* @param {string} version - Version string (e.g., 'v1', 'v2')
|
|
2295
2604
|
* @returns {Object} Schema object for the version
|
|
2296
2605
|
*/
|
|
2297
2606
|
async getSchemaForVersion(version) {
|
|
@@ -2785,11 +3094,12 @@ export class Resource extends AsyncEventEmitter {
|
|
|
2785
3094
|
const [ok, err, unmapped] = await tryFn(() => this.schema.unmapper(metadata));
|
|
2786
3095
|
unmappedMetadata = ok ? unmapped : metadata;
|
|
2787
3096
|
// Helper function to filter out internal S3DB fields
|
|
3097
|
+
// Preserve geo-related fields (_geohash, _geohash_zoom*) for GeoPlugin
|
|
2788
3098
|
const filterInternalFields = (obj) => {
|
|
2789
3099
|
if (!obj || typeof obj !== 'object') return obj;
|
|
2790
3100
|
const filtered = {};
|
|
2791
3101
|
for (const [key, value] of Object.entries(obj)) {
|
|
2792
|
-
if (!key.startsWith('_')) {
|
|
3102
|
+
if (!key.startsWith('_') || key === '_geohash' || key.startsWith('_geohash_zoom')) {
|
|
2793
3103
|
filtered[key] = value;
|
|
2794
3104
|
}
|
|
2795
3105
|
}
|
|
@@ -2865,37 +3175,6 @@ export class Resource extends AsyncEventEmitter {
|
|
|
2865
3175
|
return filtered;
|
|
2866
3176
|
}
|
|
2867
3177
|
|
|
2868
|
-
|
|
2869
|
-
async replace(id, attributes) {
|
|
2870
|
-
await this.delete(id);
|
|
2871
|
-
await new Promise(r => setTimeout(r, 100));
|
|
2872
|
-
// Polling para garantir que a key foi removida do S3
|
|
2873
|
-
const maxWait = 5000;
|
|
2874
|
-
const interval = 50;
|
|
2875
|
-
const start = Date.now();
|
|
2876
|
-
let waited = 0;
|
|
2877
|
-
while (Date.now() - start < maxWait) {
|
|
2878
|
-
const exists = await this.exists(id);
|
|
2879
|
-
if (!exists) {
|
|
2880
|
-
break;
|
|
2881
|
-
}
|
|
2882
|
-
await new Promise(r => setTimeout(r, interval));
|
|
2883
|
-
waited = Date.now() - start;
|
|
2884
|
-
}
|
|
2885
|
-
if (waited >= maxWait) {
|
|
2886
|
-
}
|
|
2887
|
-
try {
|
|
2888
|
-
const result = await this.insert({ ...attributes, id });
|
|
2889
|
-
return result;
|
|
2890
|
-
} catch (err) {
|
|
2891
|
-
if (err && err.message && err.message.includes('already exists')) {
|
|
2892
|
-
const result = await this.update(id, attributes);
|
|
2893
|
-
return result;
|
|
2894
|
-
}
|
|
2895
|
-
throw err;
|
|
2896
|
-
}
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
3178
|
// --- MIDDLEWARE SYSTEM ---
|
|
2900
3179
|
_initMiddleware() {
|
|
2901
3180
|
// Map of methodName -> array of middleware functions
|
package/src/s3db.d.ts
CHANGED
|
@@ -319,6 +319,20 @@ declare module 's3db.js' {
|
|
|
319
319
|
maxResults?: number;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
/** Geo Plugin resource config */
|
|
323
|
+
export interface GeoResourceConfig {
|
|
324
|
+
latField: string;
|
|
325
|
+
lonField: string;
|
|
326
|
+
precision?: number;
|
|
327
|
+
addGeohash?: boolean;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Geo Plugin config */
|
|
331
|
+
export interface GeoPluginConfig extends PluginConfig {
|
|
332
|
+
resources?: Record<string, GeoResourceConfig>;
|
|
333
|
+
verbose?: boolean;
|
|
334
|
+
}
|
|
335
|
+
|
|
322
336
|
/** Metrics Plugin config */
|
|
323
337
|
export interface MetricsPluginConfig extends PluginConfig {
|
|
324
338
|
trackLatency?: boolean;
|
|
@@ -709,6 +723,8 @@ declare module 's3db.js' {
|
|
|
709
723
|
get(id: string): Promise<any>;
|
|
710
724
|
exists(id: string): Promise<boolean>;
|
|
711
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>;
|
|
712
728
|
upsert(data: any): Promise<any>;
|
|
713
729
|
delete(id: string): Promise<void>;
|
|
714
730
|
deleteMany(ids: string[]): Promise<void>;
|
|
@@ -827,7 +843,13 @@ declare module 's3db.js' {
|
|
|
827
843
|
}): Promise<any>;
|
|
828
844
|
getObject(key: string): Promise<any>;
|
|
829
845
|
headObject(key: string): Promise<any>;
|
|
830
|
-
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>;
|
|
831
853
|
exists(key: string): Promise<boolean>;
|
|
832
854
|
deleteObject(key: string): Promise<any>;
|
|
833
855
|
deleteObjects(keys: string[]): Promise<{ deleted: any[]; notFound: any[] }>;
|
|
@@ -1016,6 +1038,16 @@ declare module 's3db.js' {
|
|
|
1016
1038
|
getIndexStats(): any;
|
|
1017
1039
|
}
|
|
1018
1040
|
|
|
1041
|
+
/** Geo Plugin */
|
|
1042
|
+
export class GeoPlugin extends Plugin {
|
|
1043
|
+
constructor(config?: GeoPluginConfig);
|
|
1044
|
+
encodeGeohash(latitude: number, longitude: number, precision?: number): string;
|
|
1045
|
+
decodeGeohash(geohash: string): { latitude: number; longitude: number; error: { latitude: number; longitude: number } };
|
|
1046
|
+
calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
1047
|
+
getNeighbors(geohash: string): string[];
|
|
1048
|
+
getStats(): any;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1019
1051
|
/** Metrics Plugin */
|
|
1020
1052
|
export class MetricsPlugin extends Plugin {
|
|
1021
1053
|
constructor(config?: MetricsPluginConfig);
|
|
@@ -1254,6 +1286,33 @@ declare module 's3db.js' {
|
|
|
1254
1286
|
}
|
|
1255
1287
|
|
|
1256
1288
|
/** Resource extensions added by EventualConsistencyPlugin */
|
|
1289
|
+
export interface GeoResourceExtensions {
|
|
1290
|
+
/** Find locations within radius of a point */
|
|
1291
|
+
findNearby(options: {
|
|
1292
|
+
lat: number;
|
|
1293
|
+
lon: number;
|
|
1294
|
+
radius?: number;
|
|
1295
|
+
limit?: number;
|
|
1296
|
+
}): Promise<Array<any & { _distance: number }>>;
|
|
1297
|
+
|
|
1298
|
+
/** Find locations within bounding box */
|
|
1299
|
+
findInBounds(options: {
|
|
1300
|
+
north: number;
|
|
1301
|
+
south: number;
|
|
1302
|
+
east: number;
|
|
1303
|
+
west: number;
|
|
1304
|
+
limit?: number;
|
|
1305
|
+
}): Promise<any[]>;
|
|
1306
|
+
|
|
1307
|
+
/** Get distance between two records */
|
|
1308
|
+
getDistance(id1: string, id2: string): Promise<{
|
|
1309
|
+
distance: number;
|
|
1310
|
+
unit: string;
|
|
1311
|
+
from: string;
|
|
1312
|
+
to: string;
|
|
1313
|
+
}>;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1257
1316
|
export interface EventualConsistencyResourceExtensions {
|
|
1258
1317
|
/** Set field value (replaces current value) */
|
|
1259
1318
|
set(id: string, field: string, value: number): Promise<number>;
|
|
@@ -1264,6 +1323,12 @@ declare module 's3db.js' {
|
|
|
1264
1323
|
/** Decrement field value */
|
|
1265
1324
|
sub(id: string, field: string, amount: number): Promise<number>;
|
|
1266
1325
|
|
|
1326
|
+
/** Increment field value by 1 (shorthand for add(id, field, 1)) */
|
|
1327
|
+
increment(id: string, field: string): Promise<number>;
|
|
1328
|
+
|
|
1329
|
+
/** Decrement field value by 1 (shorthand for sub(id, field, 1)) */
|
|
1330
|
+
decrement(id: string, field: string): Promise<number>;
|
|
1331
|
+
|
|
1267
1332
|
/** Manually trigger consolidation */
|
|
1268
1333
|
consolidate(id: string, field: string): Promise<number>;
|
|
1269
1334
|
|