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/PLUGINS.md +95 -34
- package/dist/s3db.cjs.js +123 -53
- package/dist/s3db.cjs.min.js +1 -1
- package/dist/s3db.es.js +123 -53
- package/dist/s3db.es.min.js +1 -1
- package/dist/s3db.iife.js +123 -53
- package/dist/s3db.iife.min.js +1 -1
- package/package.json +2 -2
- package/src/behaviors/body-overflow.js +0 -1
- package/src/behaviors/enforce-limits.js +3 -1
- package/src/behaviors/truncate-data.js +2 -1
- package/src/behaviors/user-managed.js +24 -3
- package/src/plugins/audit.plugin.js +61 -10
- package/src/plugins/cache/partition-aware-filesystem-cache.class.js +28 -0
- package/src/plugins/cache/s3-cache.class.js +1 -1
- package/src/resource.class.js +38 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s3db.js",
|
|
3
|
-
"version": "8.1.
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
145
|
-
|
|
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:
|
|
71
|
-
partitionValues:
|
|
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:
|
|
90
|
-
partitionValues:
|
|
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:
|
|
109
|
-
partitionValues:
|
|
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
|
|
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(
|
|
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
|
*/
|
package/src/resource.class.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
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',
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
//
|
|
854
|
-
|
|
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);
|