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 CHANGED
@@ -31,10 +31,11 @@ var promises$2 = require('node:fs/promises');
31
31
  var node_events = require('node:events');
32
32
  var Stream = require('node:stream');
33
33
  var node_string_decoder = require('node:string_decoder');
34
- var require$$0 = require('node:crypto');
34
+ var node_module = require('node:module');
35
35
  var require$$1 = require('child_process');
36
36
  var require$$5 = require('url');
37
37
 
38
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
38
39
  function _interopNamespaceDefault(e) {
39
40
  var n = Object.create(null);
40
41
  if (e) {
@@ -1986,44 +1987,31 @@ class PluginStorage {
1986
1987
  * @returns {Promise<boolean>} True if extended, false if not found or no TTL
1987
1988
  */
1988
1989
  async touch(key, additionalSeconds) {
1989
- const [ok, err, response] = await tryFn(() => this.client.getObject(key));
1990
+ const [ok, err, response] = await tryFn(() => this.client.headObject(key));
1990
1991
  if (!ok) {
1991
1992
  return false;
1992
1993
  }
1993
1994
  const metadata = response.Metadata || {};
1994
1995
  const parsedMetadata = this._parseMetadataValues(metadata);
1995
- let data = parsedMetadata;
1996
- if (response.Body) {
1997
- const [ok2, err2, result] = await tryFn(async () => {
1998
- const bodyContent = await response.Body.transformToString();
1999
- if (bodyContent && bodyContent.trim()) {
2000
- const body = JSON.parse(bodyContent);
2001
- return { ...parsedMetadata, ...body };
2002
- }
2003
- return parsedMetadata;
2004
- });
2005
- if (!ok2) {
2006
- return false;
2007
- }
2008
- data = result;
2009
- }
2010
- const expiresAt = data._expiresat || data._expiresAt;
1996
+ const expiresAt = parsedMetadata._expiresat || parsedMetadata._expiresAt;
2011
1997
  if (!expiresAt) {
2012
1998
  return false;
2013
1999
  }
2014
- data._expiresAt = expiresAt + additionalSeconds * 1e3;
2015
- delete data._expiresat;
2016
- const { metadata: newMetadata, body: newBody } = this._applyBehavior(data, "body-overflow");
2017
- const putParams = {
2018
- key,
2019
- metadata: newMetadata,
2020
- contentType: "application/json"
2021
- };
2022
- if (newBody !== null) {
2023
- putParams.body = JSON.stringify(newBody);
2024
- }
2025
- const [putOk] = await tryFn(() => this.client.putObject(putParams));
2026
- return putOk;
2000
+ parsedMetadata._expiresAt = expiresAt + additionalSeconds * 1e3;
2001
+ delete parsedMetadata._expiresat;
2002
+ const encodedMetadata = {};
2003
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
2004
+ const { encoded } = metadataEncode(metaValue);
2005
+ encodedMetadata[metaKey] = encoded;
2006
+ }
2007
+ const [copyOk] = await tryFn(() => this.client.copyObject({
2008
+ from: key,
2009
+ to: key,
2010
+ metadata: encodedMetadata,
2011
+ metadataDirective: "REPLACE",
2012
+ contentType: response.ContentType || "application/json"
2013
+ }));
2014
+ return copyOk;
2027
2015
  }
2028
2016
  /**
2029
2017
  * Delete a single object
@@ -2158,12 +2146,41 @@ class PluginStorage {
2158
2146
  /**
2159
2147
  * Increment a counter value
2160
2148
  *
2149
+ * Optimization: Uses HEAD + COPY for existing counters to avoid body transfer.
2150
+ * Falls back to GET + PUT for non-existent counters or those with additional data.
2151
+ *
2161
2152
  * @param {string} key - S3 key
2162
2153
  * @param {number} amount - Amount to increment (default: 1)
2163
2154
  * @param {Object} options - Options (e.g., ttl)
2164
2155
  * @returns {Promise<number>} New value
2165
2156
  */
2166
2157
  async increment(key, amount = 1, options = {}) {
2158
+ const [headOk, headErr, headResponse] = await tryFn(() => this.client.headObject(key));
2159
+ if (headOk && headResponse.Metadata) {
2160
+ const metadata = headResponse.Metadata || {};
2161
+ const parsedMetadata = this._parseMetadataValues(metadata);
2162
+ const currentValue = parsedMetadata.value || 0;
2163
+ const newValue = currentValue + amount;
2164
+ parsedMetadata.value = newValue;
2165
+ if (options.ttl) {
2166
+ parsedMetadata._expiresAt = Date.now() + options.ttl * 1e3;
2167
+ }
2168
+ const encodedMetadata = {};
2169
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
2170
+ const { encoded } = metadataEncode(metaValue);
2171
+ encodedMetadata[metaKey] = encoded;
2172
+ }
2173
+ const [copyOk] = await tryFn(() => this.client.copyObject({
2174
+ from: key,
2175
+ to: key,
2176
+ metadata: encodedMetadata,
2177
+ metadataDirective: "REPLACE",
2178
+ contentType: headResponse.ContentType || "application/json"
2179
+ }));
2180
+ if (copyOk) {
2181
+ return newValue;
2182
+ }
2183
+ }
2167
2184
  const data = await this.get(key);
2168
2185
  const value = (data?.value || 0) + amount;
2169
2186
  await this.set(key, { value }, options);
@@ -19163,7 +19180,17 @@ class Client extends EventEmitter {
19163
19180
  Bucket: this.config.bucket,
19164
19181
  Key: keyPrefix ? path$1.join(keyPrefix, key) : key
19165
19182
  };
19166
- const [ok, err, response] = await tryFn(() => this.sendCommand(new clientS3.HeadObjectCommand(options)));
19183
+ const [ok, err, response] = await tryFn(async () => {
19184
+ const res = await this.sendCommand(new clientS3.HeadObjectCommand(options));
19185
+ if (res.Metadata) {
19186
+ const decodedMetadata = {};
19187
+ for (const [key2, value] of Object.entries(res.Metadata)) {
19188
+ decodedMetadata[key2] = metadataDecode(value);
19189
+ }
19190
+ res.Metadata = decodedMetadata;
19191
+ }
19192
+ return res;
19193
+ });
19167
19194
  this.emit("headObject", err || response, { key });
19168
19195
  if (!ok) {
19169
19196
  throw mapAwsError(err, {
@@ -19175,14 +19202,29 @@ class Client extends EventEmitter {
19175
19202
  }
19176
19203
  return response;
19177
19204
  }
19178
- async copyObject({ from, to }) {
19205
+ async copyObject({ from, to, metadata, metadataDirective, contentType }) {
19206
+ const keyPrefix = typeof this.config.keyPrefix === "string" ? this.config.keyPrefix : "";
19179
19207
  const options = {
19180
19208
  Bucket: this.config.bucket,
19181
- Key: this.config.keyPrefix ? path$1.join(this.config.keyPrefix, to) : to,
19182
- CopySource: path$1.join(this.config.bucket, this.config.keyPrefix ? path$1.join(this.config.keyPrefix, from) : from)
19209
+ Key: keyPrefix ? path$1.join(keyPrefix, to) : to,
19210
+ CopySource: path$1.join(this.config.bucket, keyPrefix ? path$1.join(keyPrefix, from) : from)
19183
19211
  };
19212
+ if (metadataDirective) {
19213
+ options.MetadataDirective = metadataDirective;
19214
+ }
19215
+ if (metadata && typeof metadata === "object") {
19216
+ const encodedMetadata = {};
19217
+ for (const [key, value] of Object.entries(metadata)) {
19218
+ const { encoded } = metadataEncode(value);
19219
+ encodedMetadata[key] = encoded;
19220
+ }
19221
+ options.Metadata = encodedMetadata;
19222
+ }
19223
+ if (contentType) {
19224
+ options.ContentType = contentType;
19225
+ }
19184
19226
  const [ok, err, response] = await tryFn(() => this.sendCommand(new clientS3.CopyObjectCommand(options)));
19185
- this.emit("copyObject", err || response, { from, to });
19227
+ this.emit("copyObject", err || response, { from, to, metadataDirective });
19186
19228
  if (!ok) {
19187
19229
  throw mapAwsError(err, {
19188
19230
  bucket: this.config.bucket,
@@ -22385,6 +22427,235 @@ ${errorDetails}`,
22385
22427
  return finalResult;
22386
22428
  }
22387
22429
  }
22430
+ /**
22431
+ * Patch resource (partial update optimized for metadata-only behaviors)
22432
+ *
22433
+ * This method provides an optimized update path for resources using metadata-only behaviors
22434
+ * (enforce-limits, truncate-data). It uses HeadObject + CopyObject for atomic updates without
22435
+ * body transfer, eliminating race conditions and reducing latency by ~50%.
22436
+ *
22437
+ * For behaviors that store data in body (body-overflow, body-only), it automatically falls
22438
+ * back to the standard update() method.
22439
+ *
22440
+ * @param {string} id - Resource ID
22441
+ * @param {Object} fields - Fields to update (partial data)
22442
+ * @param {Object} options - Update options
22443
+ * @param {string} options.partition - Partition name (if using partitions)
22444
+ * @param {Object} options.partitionValues - Partition values (if using partitions)
22445
+ * @returns {Promise<Object>} Updated resource data
22446
+ *
22447
+ * @example
22448
+ * // Fast atomic update (enforce-limits behavior)
22449
+ * await resource.patch('user-123', { status: 'active', loginCount: 42 });
22450
+ *
22451
+ * @example
22452
+ * // With partitions
22453
+ * await resource.patch('order-456', { status: 'shipped' }, {
22454
+ * partition: 'byRegion',
22455
+ * partitionValues: { region: 'US' }
22456
+ * });
22457
+ */
22458
+ async patch(id, fields, options = {}) {
22459
+ if (lodashEs.isEmpty(id)) {
22460
+ throw new Error("id cannot be empty");
22461
+ }
22462
+ if (!fields || typeof fields !== "object") {
22463
+ throw new Error("fields must be a non-empty object");
22464
+ }
22465
+ const behavior = this.behavior;
22466
+ const hasNestedFields = Object.keys(fields).some((key) => key.includes("."));
22467
+ if ((behavior === "enforce-limits" || behavior === "truncate-data") && !hasNestedFields) {
22468
+ return await this._patchViaCopyObject(id, fields, options);
22469
+ }
22470
+ return await this.update(id, fields, options);
22471
+ }
22472
+ /**
22473
+ * Internal helper: Optimized patch using HeadObject + CopyObject
22474
+ * Only works for metadata-only behaviors (enforce-limits, truncate-data)
22475
+ * Only for simple field updates (no nested fields with dot notation)
22476
+ * @private
22477
+ */
22478
+ async _patchViaCopyObject(id, fields, options = {}) {
22479
+ const { partition, partitionValues } = options;
22480
+ const key = this.getResourceKey(id);
22481
+ const headResponse = await this.client.headObject(key);
22482
+ const currentMetadata = headResponse.Metadata || {};
22483
+ let currentData = await this.schema.unmapper(currentMetadata);
22484
+ if (!currentData.id) {
22485
+ currentData.id = id;
22486
+ }
22487
+ const fieldsClone = lodashEs.cloneDeep(fields);
22488
+ let mergedData = lodashEs.cloneDeep(currentData);
22489
+ for (const [key2, value] of Object.entries(fieldsClone)) {
22490
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
22491
+ mergedData[key2] = lodashEs.merge({}, mergedData[key2], value);
22492
+ } else {
22493
+ mergedData[key2] = lodashEs.cloneDeep(value);
22494
+ }
22495
+ }
22496
+ if (this.config.timestamps) {
22497
+ mergedData.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
22498
+ }
22499
+ const validationResult = await this.schema.validate(mergedData);
22500
+ if (validationResult !== true) {
22501
+ throw new ValidationError("Validation failed during patch", validationResult);
22502
+ }
22503
+ const newMetadata = await this.schema.mapper(mergedData);
22504
+ newMetadata._v = String(this.version);
22505
+ await this.client.copyObject({
22506
+ from: key,
22507
+ to: key,
22508
+ metadataDirective: "REPLACE",
22509
+ metadata: newMetadata
22510
+ });
22511
+ if (this.config.partitions && Object.keys(this.config.partitions).length > 0) {
22512
+ const oldData = { ...currentData, id };
22513
+ const newData = { ...mergedData, id };
22514
+ if (this.config.asyncPartitions) {
22515
+ setImmediate(() => {
22516
+ this.handlePartitionReferenceUpdates(oldData, newData).catch((err) => {
22517
+ this.emit("partitionIndexError", {
22518
+ operation: "patch",
22519
+ id,
22520
+ error: err
22521
+ });
22522
+ });
22523
+ });
22524
+ } else {
22525
+ await this.handlePartitionReferenceUpdates(oldData, newData);
22526
+ }
22527
+ }
22528
+ return mergedData;
22529
+ }
22530
+ /**
22531
+ * Replace resource (full object replacement without GET)
22532
+ *
22533
+ * This method performs a direct PUT operation without fetching the current object.
22534
+ * Use this when you already have the complete object and want to replace it entirely,
22535
+ * saving 1 S3 request (GET).
22536
+ *
22537
+ * ⚠️ Warning: You must provide ALL required fields. Missing fields will NOT be preserved
22538
+ * from the current object. This method does not merge with existing data.
22539
+ *
22540
+ * @param {string} id - Resource ID
22541
+ * @param {Object} fullData - Complete object data (all required fields)
22542
+ * @param {Object} options - Update options
22543
+ * @param {string} options.partition - Partition name (if using partitions)
22544
+ * @param {Object} options.partitionValues - Partition values (if using partitions)
22545
+ * @returns {Promise<Object>} Replaced resource data
22546
+ *
22547
+ * @example
22548
+ * // Replace entire object (must include ALL required fields)
22549
+ * await resource.replace('user-123', {
22550
+ * name: 'John Doe',
22551
+ * email: 'john@example.com',
22552
+ * status: 'active',
22553
+ * loginCount: 42
22554
+ * });
22555
+ *
22556
+ * @example
22557
+ * // With partitions
22558
+ * await resource.replace('order-456', fullOrderData, {
22559
+ * partition: 'byRegion',
22560
+ * partitionValues: { region: 'US' }
22561
+ * });
22562
+ */
22563
+ async replace(id, fullData, options = {}) {
22564
+ if (lodashEs.isEmpty(id)) {
22565
+ throw new Error("id cannot be empty");
22566
+ }
22567
+ if (!fullData || typeof fullData !== "object") {
22568
+ throw new Error("fullData must be a non-empty object");
22569
+ }
22570
+ const { partition, partitionValues } = options;
22571
+ const dataClone = lodashEs.cloneDeep(fullData);
22572
+ const attributesWithDefaults = this.applyDefaults(dataClone);
22573
+ if (this.config.timestamps) {
22574
+ if (!attributesWithDefaults.createdAt) {
22575
+ attributesWithDefaults.createdAt = (/* @__PURE__ */ new Date()).toISOString();
22576
+ }
22577
+ attributesWithDefaults.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
22578
+ }
22579
+ const completeData = { id, ...attributesWithDefaults };
22580
+ const {
22581
+ errors,
22582
+ isValid,
22583
+ data: validated
22584
+ } = await this.validate(completeData);
22585
+ if (!isValid) {
22586
+ const errorMsg = errors && errors.length && errors[0].message ? errors[0].message : "Replace failed";
22587
+ throw new InvalidResourceItem({
22588
+ bucket: this.client.config.bucket,
22589
+ resourceName: this.name,
22590
+ attributes: completeData,
22591
+ validation: errors,
22592
+ message: errorMsg
22593
+ });
22594
+ }
22595
+ const { id: validatedId, ...validatedAttributes } = validated;
22596
+ const mappedMetadata = await this.schema.mapper(validatedAttributes);
22597
+ mappedMetadata._v = String(this.version);
22598
+ const behaviorImpl = getBehavior(this.behavior);
22599
+ const { mappedData: finalMetadata, body } = await behaviorImpl.handleInsert({
22600
+ resource: this,
22601
+ data: validatedAttributes,
22602
+ mappedData: mappedMetadata,
22603
+ originalData: completeData
22604
+ });
22605
+ const key = this.getResourceKey(id);
22606
+ let contentType = void 0;
22607
+ if (body && body !== "") {
22608
+ const [okParse] = await tryFn(() => Promise.resolve(JSON.parse(body)));
22609
+ if (okParse) contentType = "application/json";
22610
+ }
22611
+ if (this.behavior === "body-only" && (!body || body === "")) {
22612
+ throw new Error(`[Resource.replace] Attempt to save object without body! Data: id=${id}, resource=${this.name}`);
22613
+ }
22614
+ const [okPut, errPut] = await tryFn(() => this.client.putObject({
22615
+ key,
22616
+ body,
22617
+ contentType,
22618
+ metadata: finalMetadata
22619
+ }));
22620
+ if (!okPut) {
22621
+ const msg = errPut && errPut.message ? errPut.message : "";
22622
+ if (msg.includes("metadata headers exceed") || msg.includes("Replace failed")) {
22623
+ const totalSize = calculateTotalSize(finalMetadata);
22624
+ const effectiveLimit = calculateEffectiveLimit({
22625
+ s3Limit: 2047,
22626
+ systemConfig: {
22627
+ version: this.version,
22628
+ timestamps: this.config.timestamps,
22629
+ id
22630
+ }
22631
+ });
22632
+ const excess = totalSize - effectiveLimit;
22633
+ errPut.totalSize = totalSize;
22634
+ errPut.limit = 2047;
22635
+ errPut.effectiveLimit = effectiveLimit;
22636
+ errPut.excess = excess;
22637
+ throw new ResourceError("metadata headers exceed", { resourceName: this.name, operation: "replace", id, totalSize, effectiveLimit, excess, suggestion: "Reduce metadata size or number of fields." });
22638
+ }
22639
+ throw errPut;
22640
+ }
22641
+ const replacedObject = { id, ...validatedAttributes };
22642
+ if (this.config.partitions && Object.keys(this.config.partitions).length > 0) {
22643
+ if (this.config.asyncPartitions) {
22644
+ setImmediate(() => {
22645
+ this.handlePartitionReferenceUpdates({}, replacedObject).catch((err) => {
22646
+ this.emit("partitionIndexError", {
22647
+ operation: "replace",
22648
+ id,
22649
+ error: err
22650
+ });
22651
+ });
22652
+ });
22653
+ } else {
22654
+ await this.handlePartitionReferenceUpdates({}, replacedObject);
22655
+ }
22656
+ }
22657
+ return replacedObject;
22658
+ }
22388
22659
  /**
22389
22660
  * Update with conditional check (If-Match ETag)
22390
22661
  * @param {string} id - Resource ID
@@ -23733,29 +24004,6 @@ ${errorDetails}`,
23733
24004
  }
23734
24005
  return filtered;
23735
24006
  }
23736
- async replace(id, attributes) {
23737
- await this.delete(id);
23738
- await new Promise((r) => setTimeout(r, 100));
23739
- const maxWait = 5e3;
23740
- const interval = 50;
23741
- const start = Date.now();
23742
- while (Date.now() - start < maxWait) {
23743
- const exists = await this.exists(id);
23744
- if (!exists) {
23745
- break;
23746
- }
23747
- await new Promise((r) => setTimeout(r, interval));
23748
- }
23749
- const [ok, err, result] = await tryFn(() => this.insert({ ...attributes, id }));
23750
- if (!ok) {
23751
- if (err && err.message && err.message.includes("already exists")) {
23752
- const updateResult = await this.update(id, attributes);
23753
- return updateResult;
23754
- }
23755
- throw err;
23756
- }
23757
- return result;
23758
- }
23759
24007
  // --- MIDDLEWARE SYSTEM ---
23760
24008
  _initMiddleware() {
23761
24009
  this._middlewares = /* @__PURE__ */ new Map();
@@ -23957,7 +24205,7 @@ class Database extends EventEmitter {
23957
24205
  this.id = idGenerator(7);
23958
24206
  this.version = "1";
23959
24207
  this.s3dbVersion = (() => {
23960
- const [ok, err, version] = tryFn(() => true ? "12.0.0" : "latest");
24208
+ const [ok, err, version] = tryFn(() => true ? "12.0.1" : "latest");
23961
24209
  return ok ? version : "latest";
23962
24210
  })();
23963
24211
  this.resources = {};
@@ -26827,7 +27075,7 @@ class ReplicatorPlugin extends Plugin {
26827
27075
  async updateReplicatorLog(logId, updates) {
26828
27076
  if (!this.replicatorLog) return;
26829
27077
  const [ok, err] = await tryFn(async () => {
26830
- await this.replicatorLog.update(logId, {
27078
+ await this.replicatorLog.patch(logId, {
26831
27079
  ...updates,
26832
27080
  lastAttempt: (/* @__PURE__ */ new Date()).toISOString()
26833
27081
  });
@@ -39807,6 +40055,9 @@ var runner = {};
39807
40055
 
39808
40056
  var createId = {};
39809
40057
 
40058
+ const require$1 = node_module.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('s3db.cjs.js', document.baseURI).href)));
40059
+ function __require() { return require$1("node:crypto"); }
40060
+
39810
40061
  var hasRequiredCreateId;
39811
40062
 
39812
40063
  function requireCreateId () {
@@ -39817,7 +40068,7 @@ function requireCreateId () {
39817
40068
  };
39818
40069
  Object.defineProperty(createId, "__esModule", { value: true });
39819
40070
  createId.createID = createID;
39820
- const node_crypto_1 = __importDefault(require$$0);
40071
+ const node_crypto_1 = __importDefault(__require());
39821
40072
  function createID(prefix = '', length = 16) {
39822
40073
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
39823
40074
  const values = node_crypto_1.default.randomBytes(length);