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.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[] }>;
package/dist/s3db.es.js CHANGED
@@ -27,7 +27,7 @@ import { realpath, readlink, readdir as readdir$1, lstat } from 'node:fs/promise
27
27
  import { EventEmitter as EventEmitter$1 } from 'node:events';
28
28
  import Stream from 'node:stream';
29
29
  import { StringDecoder } from 'node:string_decoder';
30
- import require$$0 from 'node:crypto';
30
+ import { createRequire } from 'node:module';
31
31
  import require$$1 from 'child_process';
32
32
  import require$$5 from 'url';
33
33
 
@@ -1963,44 +1963,31 @@ class PluginStorage {
1963
1963
  * @returns {Promise<boolean>} True if extended, false if not found or no TTL
1964
1964
  */
1965
1965
  async touch(key, additionalSeconds) {
1966
- const [ok, err, response] = await tryFn(() => this.client.getObject(key));
1966
+ const [ok, err, response] = await tryFn(() => this.client.headObject(key));
1967
1967
  if (!ok) {
1968
1968
  return false;
1969
1969
  }
1970
1970
  const metadata = response.Metadata || {};
1971
1971
  const parsedMetadata = this._parseMetadataValues(metadata);
1972
- let data = parsedMetadata;
1973
- if (response.Body) {
1974
- const [ok2, err2, result] = await tryFn(async () => {
1975
- const bodyContent = await response.Body.transformToString();
1976
- if (bodyContent && bodyContent.trim()) {
1977
- const body = JSON.parse(bodyContent);
1978
- return { ...parsedMetadata, ...body };
1979
- }
1980
- return parsedMetadata;
1981
- });
1982
- if (!ok2) {
1983
- return false;
1984
- }
1985
- data = result;
1986
- }
1987
- const expiresAt = data._expiresat || data._expiresAt;
1972
+ const expiresAt = parsedMetadata._expiresat || parsedMetadata._expiresAt;
1988
1973
  if (!expiresAt) {
1989
1974
  return false;
1990
1975
  }
1991
- data._expiresAt = expiresAt + additionalSeconds * 1e3;
1992
- delete data._expiresat;
1993
- const { metadata: newMetadata, body: newBody } = this._applyBehavior(data, "body-overflow");
1994
- const putParams = {
1995
- key,
1996
- metadata: newMetadata,
1997
- contentType: "application/json"
1998
- };
1999
- if (newBody !== null) {
2000
- putParams.body = JSON.stringify(newBody);
2001
- }
2002
- const [putOk] = await tryFn(() => this.client.putObject(putParams));
2003
- return putOk;
1976
+ parsedMetadata._expiresAt = expiresAt + additionalSeconds * 1e3;
1977
+ delete parsedMetadata._expiresat;
1978
+ const encodedMetadata = {};
1979
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
1980
+ const { encoded } = metadataEncode(metaValue);
1981
+ encodedMetadata[metaKey] = encoded;
1982
+ }
1983
+ const [copyOk] = await tryFn(() => this.client.copyObject({
1984
+ from: key,
1985
+ to: key,
1986
+ metadata: encodedMetadata,
1987
+ metadataDirective: "REPLACE",
1988
+ contentType: response.ContentType || "application/json"
1989
+ }));
1990
+ return copyOk;
2004
1991
  }
2005
1992
  /**
2006
1993
  * Delete a single object
@@ -2135,12 +2122,41 @@ class PluginStorage {
2135
2122
  /**
2136
2123
  * Increment a counter value
2137
2124
  *
2125
+ * Optimization: Uses HEAD + COPY for existing counters to avoid body transfer.
2126
+ * Falls back to GET + PUT for non-existent counters or those with additional data.
2127
+ *
2138
2128
  * @param {string} key - S3 key
2139
2129
  * @param {number} amount - Amount to increment (default: 1)
2140
2130
  * @param {Object} options - Options (e.g., ttl)
2141
2131
  * @returns {Promise<number>} New value
2142
2132
  */
2143
2133
  async increment(key, amount = 1, options = {}) {
2134
+ const [headOk, headErr, headResponse] = await tryFn(() => this.client.headObject(key));
2135
+ if (headOk && headResponse.Metadata) {
2136
+ const metadata = headResponse.Metadata || {};
2137
+ const parsedMetadata = this._parseMetadataValues(metadata);
2138
+ const currentValue = parsedMetadata.value || 0;
2139
+ const newValue = currentValue + amount;
2140
+ parsedMetadata.value = newValue;
2141
+ if (options.ttl) {
2142
+ parsedMetadata._expiresAt = Date.now() + options.ttl * 1e3;
2143
+ }
2144
+ const encodedMetadata = {};
2145
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
2146
+ const { encoded } = metadataEncode(metaValue);
2147
+ encodedMetadata[metaKey] = encoded;
2148
+ }
2149
+ const [copyOk] = await tryFn(() => this.client.copyObject({
2150
+ from: key,
2151
+ to: key,
2152
+ metadata: encodedMetadata,
2153
+ metadataDirective: "REPLACE",
2154
+ contentType: headResponse.ContentType || "application/json"
2155
+ }));
2156
+ if (copyOk) {
2157
+ return newValue;
2158
+ }
2159
+ }
2144
2160
  const data = await this.get(key);
2145
2161
  const value = (data?.value || 0) + amount;
2146
2162
  await this.set(key, { value }, options);
@@ -19140,7 +19156,17 @@ class Client extends EventEmitter {
19140
19156
  Bucket: this.config.bucket,
19141
19157
  Key: keyPrefix ? path$1.join(keyPrefix, key) : key
19142
19158
  };
19143
- const [ok, err, response] = await tryFn(() => this.sendCommand(new HeadObjectCommand(options)));
19159
+ const [ok, err, response] = await tryFn(async () => {
19160
+ const res = await this.sendCommand(new HeadObjectCommand(options));
19161
+ if (res.Metadata) {
19162
+ const decodedMetadata = {};
19163
+ for (const [key2, value] of Object.entries(res.Metadata)) {
19164
+ decodedMetadata[key2] = metadataDecode(value);
19165
+ }
19166
+ res.Metadata = decodedMetadata;
19167
+ }
19168
+ return res;
19169
+ });
19144
19170
  this.emit("headObject", err || response, { key });
19145
19171
  if (!ok) {
19146
19172
  throw mapAwsError(err, {
@@ -19152,14 +19178,29 @@ class Client extends EventEmitter {
19152
19178
  }
19153
19179
  return response;
19154
19180
  }
19155
- async copyObject({ from, to }) {
19181
+ async copyObject({ from, to, metadata, metadataDirective, contentType }) {
19182
+ const keyPrefix = typeof this.config.keyPrefix === "string" ? this.config.keyPrefix : "";
19156
19183
  const options = {
19157
19184
  Bucket: this.config.bucket,
19158
- Key: this.config.keyPrefix ? path$1.join(this.config.keyPrefix, to) : to,
19159
- CopySource: path$1.join(this.config.bucket, this.config.keyPrefix ? path$1.join(this.config.keyPrefix, from) : from)
19185
+ Key: keyPrefix ? path$1.join(keyPrefix, to) : to,
19186
+ CopySource: path$1.join(this.config.bucket, keyPrefix ? path$1.join(keyPrefix, from) : from)
19160
19187
  };
19188
+ if (metadataDirective) {
19189
+ options.MetadataDirective = metadataDirective;
19190
+ }
19191
+ if (metadata && typeof metadata === "object") {
19192
+ const encodedMetadata = {};
19193
+ for (const [key, value] of Object.entries(metadata)) {
19194
+ const { encoded } = metadataEncode(value);
19195
+ encodedMetadata[key] = encoded;
19196
+ }
19197
+ options.Metadata = encodedMetadata;
19198
+ }
19199
+ if (contentType) {
19200
+ options.ContentType = contentType;
19201
+ }
19161
19202
  const [ok, err, response] = await tryFn(() => this.sendCommand(new CopyObjectCommand(options)));
19162
- this.emit("copyObject", err || response, { from, to });
19203
+ this.emit("copyObject", err || response, { from, to, metadataDirective });
19163
19204
  if (!ok) {
19164
19205
  throw mapAwsError(err, {
19165
19206
  bucket: this.config.bucket,
@@ -22362,6 +22403,235 @@ ${errorDetails}`,
22362
22403
  return finalResult;
22363
22404
  }
22364
22405
  }
22406
+ /**
22407
+ * Patch resource (partial update optimized for metadata-only behaviors)
22408
+ *
22409
+ * This method provides an optimized update path for resources using metadata-only behaviors
22410
+ * (enforce-limits, truncate-data). It uses HeadObject + CopyObject for atomic updates without
22411
+ * body transfer, eliminating race conditions and reducing latency by ~50%.
22412
+ *
22413
+ * For behaviors that store data in body (body-overflow, body-only), it automatically falls
22414
+ * back to the standard update() method.
22415
+ *
22416
+ * @param {string} id - Resource ID
22417
+ * @param {Object} fields - Fields to update (partial data)
22418
+ * @param {Object} options - Update options
22419
+ * @param {string} options.partition - Partition name (if using partitions)
22420
+ * @param {Object} options.partitionValues - Partition values (if using partitions)
22421
+ * @returns {Promise<Object>} Updated resource data
22422
+ *
22423
+ * @example
22424
+ * // Fast atomic update (enforce-limits behavior)
22425
+ * await resource.patch('user-123', { status: 'active', loginCount: 42 });
22426
+ *
22427
+ * @example
22428
+ * // With partitions
22429
+ * await resource.patch('order-456', { status: 'shipped' }, {
22430
+ * partition: 'byRegion',
22431
+ * partitionValues: { region: 'US' }
22432
+ * });
22433
+ */
22434
+ async patch(id, fields, options = {}) {
22435
+ if (isEmpty(id)) {
22436
+ throw new Error("id cannot be empty");
22437
+ }
22438
+ if (!fields || typeof fields !== "object") {
22439
+ throw new Error("fields must be a non-empty object");
22440
+ }
22441
+ const behavior = this.behavior;
22442
+ const hasNestedFields = Object.keys(fields).some((key) => key.includes("."));
22443
+ if ((behavior === "enforce-limits" || behavior === "truncate-data") && !hasNestedFields) {
22444
+ return await this._patchViaCopyObject(id, fields, options);
22445
+ }
22446
+ return await this.update(id, fields, options);
22447
+ }
22448
+ /**
22449
+ * Internal helper: Optimized patch using HeadObject + CopyObject
22450
+ * Only works for metadata-only behaviors (enforce-limits, truncate-data)
22451
+ * Only for simple field updates (no nested fields with dot notation)
22452
+ * @private
22453
+ */
22454
+ async _patchViaCopyObject(id, fields, options = {}) {
22455
+ const { partition, partitionValues } = options;
22456
+ const key = this.getResourceKey(id);
22457
+ const headResponse = await this.client.headObject(key);
22458
+ const currentMetadata = headResponse.Metadata || {};
22459
+ let currentData = await this.schema.unmapper(currentMetadata);
22460
+ if (!currentData.id) {
22461
+ currentData.id = id;
22462
+ }
22463
+ const fieldsClone = cloneDeep(fields);
22464
+ let mergedData = cloneDeep(currentData);
22465
+ for (const [key2, value] of Object.entries(fieldsClone)) {
22466
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
22467
+ mergedData[key2] = merge({}, mergedData[key2], value);
22468
+ } else {
22469
+ mergedData[key2] = cloneDeep(value);
22470
+ }
22471
+ }
22472
+ if (this.config.timestamps) {
22473
+ mergedData.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
22474
+ }
22475
+ const validationResult = await this.schema.validate(mergedData);
22476
+ if (validationResult !== true) {
22477
+ throw new ValidationError("Validation failed during patch", validationResult);
22478
+ }
22479
+ const newMetadata = await this.schema.mapper(mergedData);
22480
+ newMetadata._v = String(this.version);
22481
+ await this.client.copyObject({
22482
+ from: key,
22483
+ to: key,
22484
+ metadataDirective: "REPLACE",
22485
+ metadata: newMetadata
22486
+ });
22487
+ if (this.config.partitions && Object.keys(this.config.partitions).length > 0) {
22488
+ const oldData = { ...currentData, id };
22489
+ const newData = { ...mergedData, id };
22490
+ if (this.config.asyncPartitions) {
22491
+ setImmediate(() => {
22492
+ this.handlePartitionReferenceUpdates(oldData, newData).catch((err) => {
22493
+ this.emit("partitionIndexError", {
22494
+ operation: "patch",
22495
+ id,
22496
+ error: err
22497
+ });
22498
+ });
22499
+ });
22500
+ } else {
22501
+ await this.handlePartitionReferenceUpdates(oldData, newData);
22502
+ }
22503
+ }
22504
+ return mergedData;
22505
+ }
22506
+ /**
22507
+ * Replace resource (full object replacement without GET)
22508
+ *
22509
+ * This method performs a direct PUT operation without fetching the current object.
22510
+ * Use this when you already have the complete object and want to replace it entirely,
22511
+ * saving 1 S3 request (GET).
22512
+ *
22513
+ * ⚠️ Warning: You must provide ALL required fields. Missing fields will NOT be preserved
22514
+ * from the current object. This method does not merge with existing data.
22515
+ *
22516
+ * @param {string} id - Resource ID
22517
+ * @param {Object} fullData - Complete object data (all required fields)
22518
+ * @param {Object} options - Update options
22519
+ * @param {string} options.partition - Partition name (if using partitions)
22520
+ * @param {Object} options.partitionValues - Partition values (if using partitions)
22521
+ * @returns {Promise<Object>} Replaced resource data
22522
+ *
22523
+ * @example
22524
+ * // Replace entire object (must include ALL required fields)
22525
+ * await resource.replace('user-123', {
22526
+ * name: 'John Doe',
22527
+ * email: 'john@example.com',
22528
+ * status: 'active',
22529
+ * loginCount: 42
22530
+ * });
22531
+ *
22532
+ * @example
22533
+ * // With partitions
22534
+ * await resource.replace('order-456', fullOrderData, {
22535
+ * partition: 'byRegion',
22536
+ * partitionValues: { region: 'US' }
22537
+ * });
22538
+ */
22539
+ async replace(id, fullData, options = {}) {
22540
+ if (isEmpty(id)) {
22541
+ throw new Error("id cannot be empty");
22542
+ }
22543
+ if (!fullData || typeof fullData !== "object") {
22544
+ throw new Error("fullData must be a non-empty object");
22545
+ }
22546
+ const { partition, partitionValues } = options;
22547
+ const dataClone = cloneDeep(fullData);
22548
+ const attributesWithDefaults = this.applyDefaults(dataClone);
22549
+ if (this.config.timestamps) {
22550
+ if (!attributesWithDefaults.createdAt) {
22551
+ attributesWithDefaults.createdAt = (/* @__PURE__ */ new Date()).toISOString();
22552
+ }
22553
+ attributesWithDefaults.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
22554
+ }
22555
+ const completeData = { id, ...attributesWithDefaults };
22556
+ const {
22557
+ errors,
22558
+ isValid,
22559
+ data: validated
22560
+ } = await this.validate(completeData);
22561
+ if (!isValid) {
22562
+ const errorMsg = errors && errors.length && errors[0].message ? errors[0].message : "Replace failed";
22563
+ throw new InvalidResourceItem({
22564
+ bucket: this.client.config.bucket,
22565
+ resourceName: this.name,
22566
+ attributes: completeData,
22567
+ validation: errors,
22568
+ message: errorMsg
22569
+ });
22570
+ }
22571
+ const { id: validatedId, ...validatedAttributes } = validated;
22572
+ const mappedMetadata = await this.schema.mapper(validatedAttributes);
22573
+ mappedMetadata._v = String(this.version);
22574
+ const behaviorImpl = getBehavior(this.behavior);
22575
+ const { mappedData: finalMetadata, body } = await behaviorImpl.handleInsert({
22576
+ resource: this,
22577
+ data: validatedAttributes,
22578
+ mappedData: mappedMetadata,
22579
+ originalData: completeData
22580
+ });
22581
+ const key = this.getResourceKey(id);
22582
+ let contentType = void 0;
22583
+ if (body && body !== "") {
22584
+ const [okParse] = await tryFn(() => Promise.resolve(JSON.parse(body)));
22585
+ if (okParse) contentType = "application/json";
22586
+ }
22587
+ if (this.behavior === "body-only" && (!body || body === "")) {
22588
+ throw new Error(`[Resource.replace] Attempt to save object without body! Data: id=${id}, resource=${this.name}`);
22589
+ }
22590
+ const [okPut, errPut] = await tryFn(() => this.client.putObject({
22591
+ key,
22592
+ body,
22593
+ contentType,
22594
+ metadata: finalMetadata
22595
+ }));
22596
+ if (!okPut) {
22597
+ const msg = errPut && errPut.message ? errPut.message : "";
22598
+ if (msg.includes("metadata headers exceed") || msg.includes("Replace failed")) {
22599
+ const totalSize = calculateTotalSize(finalMetadata);
22600
+ const effectiveLimit = calculateEffectiveLimit({
22601
+ s3Limit: 2047,
22602
+ systemConfig: {
22603
+ version: this.version,
22604
+ timestamps: this.config.timestamps,
22605
+ id
22606
+ }
22607
+ });
22608
+ const excess = totalSize - effectiveLimit;
22609
+ errPut.totalSize = totalSize;
22610
+ errPut.limit = 2047;
22611
+ errPut.effectiveLimit = effectiveLimit;
22612
+ errPut.excess = excess;
22613
+ throw new ResourceError("metadata headers exceed", { resourceName: this.name, operation: "replace", id, totalSize, effectiveLimit, excess, suggestion: "Reduce metadata size or number of fields." });
22614
+ }
22615
+ throw errPut;
22616
+ }
22617
+ const replacedObject = { id, ...validatedAttributes };
22618
+ if (this.config.partitions && Object.keys(this.config.partitions).length > 0) {
22619
+ if (this.config.asyncPartitions) {
22620
+ setImmediate(() => {
22621
+ this.handlePartitionReferenceUpdates({}, replacedObject).catch((err) => {
22622
+ this.emit("partitionIndexError", {
22623
+ operation: "replace",
22624
+ id,
22625
+ error: err
22626
+ });
22627
+ });
22628
+ });
22629
+ } else {
22630
+ await this.handlePartitionReferenceUpdates({}, replacedObject);
22631
+ }
22632
+ }
22633
+ return replacedObject;
22634
+ }
22365
22635
  /**
22366
22636
  * Update with conditional check (If-Match ETag)
22367
22637
  * @param {string} id - Resource ID
@@ -23710,29 +23980,6 @@ ${errorDetails}`,
23710
23980
  }
23711
23981
  return filtered;
23712
23982
  }
23713
- async replace(id, attributes) {
23714
- await this.delete(id);
23715
- await new Promise((r) => setTimeout(r, 100));
23716
- const maxWait = 5e3;
23717
- const interval = 50;
23718
- const start = Date.now();
23719
- while (Date.now() - start < maxWait) {
23720
- const exists = await this.exists(id);
23721
- if (!exists) {
23722
- break;
23723
- }
23724
- await new Promise((r) => setTimeout(r, interval));
23725
- }
23726
- const [ok, err, result] = await tryFn(() => this.insert({ ...attributes, id }));
23727
- if (!ok) {
23728
- if (err && err.message && err.message.includes("already exists")) {
23729
- const updateResult = await this.update(id, attributes);
23730
- return updateResult;
23731
- }
23732
- throw err;
23733
- }
23734
- return result;
23735
- }
23736
23983
  // --- MIDDLEWARE SYSTEM ---
23737
23984
  _initMiddleware() {
23738
23985
  this._middlewares = /* @__PURE__ */ new Map();
@@ -23934,7 +24181,7 @@ class Database extends EventEmitter {
23934
24181
  this.id = idGenerator(7);
23935
24182
  this.version = "1";
23936
24183
  this.s3dbVersion = (() => {
23937
- const [ok, err, version] = tryFn(() => true ? "12.0.0" : "latest");
24184
+ const [ok, err, version] = tryFn(() => true ? "12.0.1" : "latest");
23938
24185
  return ok ? version : "latest";
23939
24186
  })();
23940
24187
  this.resources = {};
@@ -26804,7 +27051,7 @@ class ReplicatorPlugin extends Plugin {
26804
27051
  async updateReplicatorLog(logId, updates) {
26805
27052
  if (!this.replicatorLog) return;
26806
27053
  const [ok, err] = await tryFn(async () => {
26807
- await this.replicatorLog.update(logId, {
27054
+ await this.replicatorLog.patch(logId, {
26808
27055
  ...updates,
26809
27056
  lastAttempt: (/* @__PURE__ */ new Date()).toISOString()
26810
27057
  });
@@ -39784,6 +40031,9 @@ var runner = {};
39784
40031
 
39785
40032
  var createId = {};
39786
40033
 
40034
+ const require = createRequire(import.meta.url);
40035
+ function __require() { return require("node:crypto"); }
40036
+
39787
40037
  var hasRequiredCreateId;
39788
40038
 
39789
40039
  function requireCreateId () {
@@ -39794,7 +40044,7 @@ function requireCreateId () {
39794
40044
  };
39795
40045
  Object.defineProperty(createId, "__esModule", { value: true });
39796
40046
  createId.createID = createID;
39797
- const node_crypto_1 = __importDefault(require$$0);
40047
+ const node_crypto_1 = __importDefault(__require());
39798
40048
  function createID(prefix = '', length = 16) {
39799
40049
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
39800
40050
  const values = node_crypto_1.default.randomBytes(length);