s3db.js 8.1.0 → 8.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "8.1.0",
3
+ "version": "8.1.1",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -106,7 +106,7 @@
106
106
  "build": "rollup -c",
107
107
  "dev": "rollup -c -w",
108
108
  "test": "npm run test:js && npm run test:ts",
109
- "test:js": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --testPathIgnorePatterns=\"plugin-audit.test.js|tests/typescript/\" --testTimeout=10000",
109
+ "test:js": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --testTimeout=10000",
110
110
  "test:ts": "tsc --noEmit --project tests/typescript/tsconfig.json",
111
111
  "test:js-converage": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --coverage --runInBand",
112
112
  "test:js-ai": "node --max-old-space-size=4096 --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --testPathIgnorePatterns=\"plugin-audit.test.js|tests/typescript/\"",
@@ -113,7 +113,6 @@ export async function handleInsert({ resource, data, mappedData, originalData })
113
113
 
114
114
  const hasOverflow = Object.keys(bodyFields).length > 0;
115
115
  let body = hasOverflow ? JSON.stringify(bodyFields) : "";
116
- if (!hasOverflow) body = '{}';
117
116
 
118
117
  // FIX: Only return metadataFields as mappedData, not full mappedData
119
118
  return { mappedData: metadataFields, body };
@@ -148,7 +148,9 @@ export async function handleInsert({ resource, data, mappedData, originalData })
148
148
  if (totalSize > effectiveLimit) {
149
149
  throw new Error(`S3 metadata size exceeds 2KB limit. Current size: ${totalSize} bytes, effective limit: ${effectiveLimit} bytes, absolute limit: ${S3_METADATA_LIMIT_BYTES} bytes`);
150
150
  }
151
- return { mappedData, body: JSON.stringify(mappedData) };
151
+
152
+ // If data fits in metadata, store only in metadata
153
+ return { mappedData, body: "" };
152
154
  }
153
155
 
154
156
  export async function handleUpdate({ resource, id, data, mappedData, originalData }) {
@@ -142,7 +142,8 @@ export async function handleInsert({ resource, data, mappedData, originalData })
142
142
  resultFields[TRUNCATED_FLAG] = TRUNCATED_FLAG_VALUE;
143
143
  }
144
144
 
145
- return { mappedData: resultFields, body: JSON.stringify(mappedData) };
145
+ // For truncate-data, all data should fit in metadata, so body is empty
146
+ return { mappedData: resultFields, body: "" };
146
147
  }
147
148
 
148
149
  export async function handleUpdate({ resource, id, data, mappedData, originalData }) {
@@ -64,6 +64,8 @@ import { S3_METADATA_LIMIT_BYTES } from './enforce-limits.js';
64
64
  * @property {boolean} [enabled=true] - Whether the behavior is active
65
65
  */
66
66
  export async function handleInsert({ resource, data, mappedData, originalData }) {
67
+
68
+
67
69
  const totalSize = calculateTotalSize(mappedData);
68
70
 
69
71
  // Calculate effective limit considering system overhead
@@ -84,8 +86,12 @@ export async function handleInsert({ resource, data, mappedData, originalData })
84
86
  excess: totalSize - 2047,
85
87
  data: originalData || data
86
88
  });
89
+ // If data exceeds limit, store in body
90
+ return { mappedData: { _v: mappedData._v }, body: JSON.stringify(mappedData) };
87
91
  }
88
- return { mappedData, body: JSON.stringify(data) };
92
+
93
+ // If data fits in metadata, store only in metadata
94
+ return { mappedData, body: "" };
89
95
  }
90
96
 
91
97
  export async function handleUpdate({ resource, id, data, mappedData, originalData }) {
@@ -141,7 +147,22 @@ export async function handleUpsert({ resource, id, data, mappedData, originalDat
141
147
  }
142
148
 
143
149
  export async function handleGet({ resource, metadata, body }) {
144
- // No special handling needed for user-managed behavior
145
- // User is responsible for handling metadata as received
150
+ // If body contains data, parse it and merge with metadata
151
+ if (body && body.trim() !== '') {
152
+ try {
153
+ const bodyData = JSON.parse(body);
154
+ // Merge body data with metadata, with metadata taking precedence
155
+ const mergedData = {
156
+ ...bodyData,
157
+ ...metadata
158
+ };
159
+ return { metadata: mergedData, body };
160
+ } catch (error) {
161
+ // If parsing fails, return original metadata and body
162
+ return { metadata, body };
163
+ }
164
+ }
165
+
166
+ // If no body data, return metadata as is
146
167
  return { metadata, body };
147
168
  }
@@ -61,14 +61,15 @@ export class AuditPlugin extends Plugin {
61
61
  setupResourceAuditing(resource) {
62
62
  // Insert
63
63
  resource.on('insert', async (data) => {
64
+ const partitionValues = this.config.includePartitions ? this.getPartitionValues(data, resource) : null;
64
65
  await this.logAudit({
65
66
  resourceName: resource.name,
66
67
  operation: 'insert',
67
68
  recordId: data.id || 'auto-generated',
68
69
  oldData: null,
69
70
  newData: this.config.includeData ? JSON.stringify(this.truncateData(data)) : null,
70
- partition: this.config.includePartitions && this.getPartitionValues(data, resource) ? this.getPrimaryPartition(this.getPartitionValues(data, resource)) : null,
71
- partitionValues: this.config.includePartitions && this.getPartitionValues(data, resource) ? JSON.stringify(this.getPartitionValues(data, resource)) : null
71
+ partition: partitionValues ? this.getPrimaryPartition(partitionValues) : null,
72
+ partitionValues: partitionValues ? JSON.stringify(partitionValues) : null
72
73
  });
73
74
  });
74
75
 
@@ -80,14 +81,15 @@ export class AuditPlugin extends Plugin {
80
81
  if (ok) oldData = fetched;
81
82
  }
82
83
 
84
+ const partitionValues = this.config.includePartitions ? this.getPartitionValues(data, resource) : null;
83
85
  await this.logAudit({
84
86
  resourceName: resource.name,
85
87
  operation: 'update',
86
88
  recordId: data.id,
87
89
  oldData: oldData && this.config.includeData ? JSON.stringify(this.truncateData(oldData)) : null,
88
90
  newData: this.config.includeData ? JSON.stringify(this.truncateData(data)) : null,
89
- partition: this.config.includePartitions && this.getPartitionValues(data, resource) ? this.getPrimaryPartition(this.getPartitionValues(data, resource)) : null,
90
- partitionValues: this.config.includePartitions && this.getPartitionValues(data, resource) ? JSON.stringify(this.getPartitionValues(data, resource)) : null
91
+ partition: partitionValues ? this.getPrimaryPartition(partitionValues) : null,
92
+ partitionValues: partitionValues ? JSON.stringify(partitionValues) : null
91
93
  });
92
94
  });
93
95
 
@@ -99,16 +101,59 @@ export class AuditPlugin extends Plugin {
99
101
  if (ok) oldData = fetched;
100
102
  }
101
103
 
104
+ const partitionValues = oldData && this.config.includePartitions ? this.getPartitionValues(oldData, resource) : null;
102
105
  await this.logAudit({
103
106
  resourceName: resource.name,
104
107
  operation: 'delete',
105
108
  recordId: data.id,
106
109
  oldData: oldData && this.config.includeData ? JSON.stringify(this.truncateData(oldData)) : null,
107
110
  newData: null,
108
- partition: oldData && this.config.includePartitions && this.getPartitionValues(oldData, resource) ? this.getPrimaryPartition(this.getPartitionValues(oldData, resource)) : null,
109
- partitionValues: oldData && this.config.includePartitions && this.getPartitionValues(oldData, resource) ? JSON.stringify(this.getPartitionValues(oldData, resource)) : null
111
+ partition: partitionValues ? this.getPrimaryPartition(partitionValues) : null,
112
+ partitionValues: partitionValues ? JSON.stringify(partitionValues) : null
110
113
  });
111
114
  });
115
+
116
+ // DeleteMany - We need to intercept before deletion to get the data
117
+ const originalDeleteMany = resource.deleteMany.bind(resource);
118
+ const plugin = this;
119
+ resource.deleteMany = async function(ids) {
120
+ // Fetch all objects before deletion for audit logging
121
+ const objectsToDelete = [];
122
+ if (plugin.config.includeData) {
123
+ for (const id of ids) {
124
+ const [ok, err, fetched] = await tryFn(() => resource.get(id));
125
+ if (ok) {
126
+ objectsToDelete.push(fetched);
127
+ } else {
128
+ objectsToDelete.push({ id }); // Just store the ID if we can't fetch
129
+ }
130
+ }
131
+ } else {
132
+ objectsToDelete.push(...ids.map(id => ({ id })));
133
+ }
134
+
135
+ // Perform the actual deletion
136
+ const result = await originalDeleteMany(ids);
137
+
138
+ // Log audit entries after successful deletion
139
+ for (const oldData of objectsToDelete) {
140
+ const partitionValues = oldData && plugin.config.includePartitions ? plugin.getPartitionValues(oldData, resource) : null;
141
+ await plugin.logAudit({
142
+ resourceName: resource.name,
143
+ operation: 'deleteMany',
144
+ recordId: oldData.id,
145
+ oldData: oldData && plugin.config.includeData ? JSON.stringify(plugin.truncateData(oldData)) : null,
146
+ newData: null,
147
+ partition: partitionValues ? plugin.getPrimaryPartition(partitionValues) : null,
148
+ partitionValues: partitionValues ? JSON.stringify(partitionValues) : null
149
+ });
150
+ }
151
+
152
+ return result;
153
+ };
154
+
155
+ // Store reference for cleanup if needed
156
+ resource._originalDeleteMany = originalDeleteMany;
112
157
  }
113
158
 
114
159
  // Backward compatibility for tests
@@ -154,10 +199,16 @@ export class AuditPlugin extends Plugin {
154
199
  }
155
200
 
156
201
  getPartitionValues(data, resource) {
157
- if (!this.config.includePartitions || !resource.partitions) return null;
202
+ if (!this.config.includePartitions) return null;
203
+
204
+ // Access partitions from resource.config.partitions, not resource.partitions
205
+ const partitions = resource.config?.partitions || resource.partitions;
206
+ if (!partitions) {
207
+ return null;
208
+ }
158
209
 
159
210
  const partitionValues = {};
160
- for (const [partitionName, partitionConfig] of Object.entries(resource.partitions)) {
211
+ for (const [partitionName, partitionConfig] of Object.entries(partitions)) {
161
212
  const values = {};
162
213
  for (const field of Object.keys(partitionConfig.fields)) {
163
214
  values[field] = this.getNestedFieldValue(data, field);
@@ -207,7 +258,7 @@ export class AuditPlugin extends Plugin {
207
258
  async getAuditLogs(options = {}) {
208
259
  if (!this.auditResource) return [];
209
260
 
210
- const { resourceName, operation, recordId, partition, startDate, endDate, limit = 100 } = options;
261
+ const { resourceName, operation, recordId, partition, startDate, endDate, limit = 100, offset = 0 } = options;
211
262
 
212
263
  let query = {};
213
264
 
@@ -222,7 +273,7 @@ export class AuditPlugin extends Plugin {
222
273
  if (endDate) query.timestamp.$lte = endDate;
223
274
  }
224
275
 
225
- const result = await this.auditResource.page({ query, limit });
276
+ const result = await this.auditResource.page({ query, limit, offset });
226
277
  return result.items || [];
227
278
  }
228
279
 
@@ -154,6 +154,34 @@ export class PartitionAwareFilesystemCache extends FilesystemCache {
154
154
  return super._set(key, data);
155
155
  }
156
156
 
157
+ /**
158
+ * Public set method with partition support
159
+ */
160
+ async set(resource, action, data, options = {}) {
161
+ if (typeof resource === 'string' && typeof action === 'string' && options.partition) {
162
+ // Partition-aware set
163
+ const key = this._getPartitionCacheKey(resource, action, options.partition, options.partitionValues, options.params);
164
+ return this._set(key, data, { resource, action, ...options });
165
+ }
166
+
167
+ // Standard cache set (first parameter is the key)
168
+ return super.set(resource, action); // resource is actually the key, action is the data
169
+ }
170
+
171
+ /**
172
+ * Public get method with partition support
173
+ */
174
+ async get(resource, action, options = {}) {
175
+ if (typeof resource === 'string' && typeof action === 'string' && options.partition) {
176
+ // Partition-aware get
177
+ const key = this._getPartitionCacheKey(resource, action, options.partition, options.partitionValues, options.params);
178
+ return this._get(key, { resource, action, ...options });
179
+ }
180
+
181
+ // Standard cache get (first parameter is the key)
182
+ return super.get(resource); // resource is actually the key
183
+ }
184
+
157
185
  /**
158
186
  * Enhanced get method with partition awareness
159
187
  */
@@ -118,7 +118,7 @@ export class S3Cache extends Cache {
118
118
  ttl = 0,
119
119
  prefix = undefined
120
120
  }) {
121
- super({ client, keyPrefix, ttl, prefix });
121
+ super();
122
122
  this.client = client
123
123
  this.keyPrefix = keyPrefix;
124
124
  this.config.ttl = ttl;
@@ -740,7 +740,17 @@ export class Resource extends EventEmitter {
740
740
  const { id: validatedId, ...validatedAttributes } = validated;
741
741
  // Reinjetar propriedades extras do beforeInsert
742
742
  Object.assign(validatedAttributes, extraData);
743
- const finalId = validatedId || id || this.idGenerator();
743
+
744
+ // Generate ID with fallback for empty generators
745
+ let finalId = validatedId || id;
746
+ if (!finalId) {
747
+ finalId = this.idGenerator();
748
+ // Fallback to default generator if custom generator returns empty
749
+ if (!finalId || finalId.trim() === '') {
750
+ const { idGenerator } = await import('#src/concerns/id.js');
751
+ finalId = idGenerator();
752
+ }
753
+ }
744
754
 
745
755
  const mappedData = await this.schema.mapper(validatedAttributes);
746
756
  mappedData._v = String(this.version);
@@ -769,8 +779,7 @@ export class Resource extends EventEmitter {
769
779
  throw new Error(`[Resource.insert] Attempt to save object without body! Data: id=${finalId}, resource=${this.name}`);
770
780
  }
771
781
  // For other behaviors, allow empty body (all data in metadata)
772
- // Before putObject in insert
773
- // eslint-disable-next-line no-console
782
+
774
783
  const [okPut, errPut, putResult] = await tryFn(() => this.client.putObject({
775
784
  key,
776
785
  body,
@@ -796,31 +805,19 @@ export class Resource extends EventEmitter {
796
805
  errPut.excess = excess;
797
806
  throw new ResourceError('metadata headers exceed', { resourceName: this.name, operation: 'insert', id: finalId, totalSize, effectiveLimit, excess, suggestion: 'Reduce metadata size or number of fields.' });
798
807
  }
799
- throw mapAwsError(errPut, {
800
- bucket: this.client.config.bucket,
801
- key,
802
- resourceName: this.name,
803
- operation: 'insert',
804
- id: finalId
805
- });
808
+ throw errPut;
806
809
  }
807
810
 
808
- // Compose the full object sem reinjetar extras
809
- let insertedData = await this.composeFullObjectFromWrite({
810
- id: finalId,
811
- metadata: finalMetadata,
812
- body,
813
- behavior: this.behavior
814
- });
815
-
811
+ // Get the inserted object
812
+ const insertedObject = await this.get(finalId);
813
+
816
814
  // Execute afterInsert hooks
817
- const finalResult = await this.executeHooks('afterInsert', insertedData);
818
- // Emit event with data before afterInsert hooks
819
- this.emit("insert", {
820
- ...insertedData,
821
- $before: { ...completeData },
822
- $after: { ...finalResult }
823
- });
815
+ const finalResult = await this.executeHooks('afterInsert', insertedObject);
816
+
817
+ // Emit insert event
818
+ this.emit('insert', finalResult);
819
+
820
+ // Return the final object
824
821
  return finalResult;
825
822
  }
826
823
 
@@ -830,7 +827,7 @@ export class Resource extends EventEmitter {
830
827
  * @returns {Promise<Object>} The resource object with all attributes and metadata
831
828
  * @example
832
829
  * const user = await resource.get('user-123');
833
- */
830
+ */
834
831
  async get(id) {
835
832
  if (isObject(id)) throw new Error(`id cannot be an object`);
836
833
  if (isEmpty(id)) throw new Error('id cannot be empty');
@@ -850,18 +847,8 @@ export class Resource extends EventEmitter {
850
847
  id
851
848
  });
852
849
  }
853
- // If object exists but has no content, throw NoSuchKey error
854
- if (request.ContentLength === 0) {
855
- const noContentErr = new Error(`No such key: ${key} [bucket:${this.client.config.bucket}]`);
856
- noContentErr.name = 'NoSuchKey';
857
- throw mapAwsError(noContentErr, {
858
- bucket: this.client.config.bucket,
859
- key,
860
- resourceName: this.name,
861
- operation: 'get',
862
- id
863
- });
864
- }
850
+ // NOTE: ContentLength === 0 is valid for objects with data in metadata only
851
+ // (removed validation that threw NoSuchKey for empty body objects)
865
852
 
866
853
  // Get the correct schema version for unmapping (from _v metadata)
867
854
  const objectVersionRaw = request.Metadata?._v || this.version;
@@ -2440,6 +2427,19 @@ export class Resource extends EventEmitter {
2440
2427
  Object.keys(result).forEach(k => { result[k] = fixValue(result[k]); });
2441
2428
  return result;
2442
2429
  }
2430
+
2431
+ // Handle user-managed behavior when data is in body
2432
+ if (behavior === 'user-managed' && body && body.trim() !== '') {
2433
+ const [okBody, errBody, parsedBody] = await tryFn(() => Promise.resolve(JSON.parse(body)));
2434
+ if (okBody) {
2435
+ const [okUnmap, errUnmap, unmappedBody] = await tryFn(() => this.schema.unmapper(parsedBody));
2436
+ const bodyData = okUnmap ? unmappedBody : {};
2437
+ const merged = { ...bodyData, ...unmappedMetadata, id };
2438
+ Object.keys(merged).forEach(k => { merged[k] = fixValue(merged[k]); });
2439
+ return filterInternalFields(merged);
2440
+ }
2441
+ }
2442
+
2443
2443
  const result = { ...unmappedMetadata, id };
2444
2444
  Object.keys(result).forEach(k => { result[k] = fixValue(result[k]); });
2445
2445
  const filtered = filterInternalFields(result);