s3db.js 12.2.2 → 12.2.4

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": "12.2.2",
3
+ "version": "12.2.4",
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",
@@ -1,15 +1,14 @@
1
- export const CostsPlugin = {
2
- async setup (db, options = {}) {
3
- if (!db || !db.client) {
4
- return; // Handle null/invalid database gracefully
5
- }
1
+ import { Plugin } from './plugin.class.js';
6
2
 
7
- this.client = db.client
8
- this.options = {
9
- considerFreeTier: false, // Flag to consider AWS free tier in calculations
10
- region: 'us-east-1', // AWS region for pricing (future use)
11
- ...options
12
- }
3
+ export class CostsPlugin extends Plugin {
4
+ constructor(config = {}) {
5
+ super(config);
6
+
7
+ this.config = {
8
+ considerFreeTier: config.considerFreeTier !== undefined ? config.considerFreeTier : false,
9
+ region: config.region || 'us-east-1',
10
+ ...config
11
+ };
13
12
 
14
13
  this.map = {
15
14
  PutObjectCommand: 'put',
@@ -19,7 +18,7 @@ export const CostsPlugin = {
19
18
  DeleteObjectCommand: 'delete',
20
19
  DeleteObjectsCommand: 'delete',
21
20
  ListObjectsV2Command: 'list',
22
- }
21
+ };
23
22
 
24
23
  this.costs = {
25
24
  total: 0,
@@ -97,19 +96,26 @@ export const CostsPlugin = {
97
96
  currentTier: 0,
98
97
  subtotal: 0 // Data transfer out cost
99
98
  }
99
+ };
100
+ }
101
+
102
+ async onInstall() {
103
+ if (!this.database || !this.database.client) {
104
+ return; // Handle null/invalid database gracefully
100
105
  }
101
106
 
107
+ this.client = this.database.client;
102
108
  this.client.costs = JSON.parse(JSON.stringify(this.costs));
103
- },
104
-
105
- async start () {
109
+ }
110
+
111
+ async onStart() {
106
112
  if (this.client) {
107
113
  this.client.on("command.response", (name, response, input) => this.addRequest(name, this.map[name], response, input));
108
114
  this.client.on("command.error", (name, response, input) => this.addRequest(name, this.map[name], response, input));
109
115
  }
110
- },
116
+ }
111
117
 
112
- addRequest (name, method, response = {}, input = {}) {
118
+ addRequest(name, method, response = {}, input = {}) {
113
119
  if (!method) return; // Skip if no mapping found
114
120
 
115
121
  // Track request counts
@@ -167,9 +173,9 @@ export const CostsPlugin = {
167
173
 
168
174
  // Update total cost (must be after mirroring request counters)
169
175
  this.updateTotal();
170
- },
176
+ }
171
177
 
172
- trackStorage (bytes) {
178
+ trackStorage(bytes) {
173
179
  this.costs.storage.totalBytes += bytes;
174
180
  this.costs.storage.totalGB = this.costs.storage.totalBytes / (1024 * 1024 * 1024);
175
181
  this.costs.storage.subtotal = this.calculateStorageCost(this.costs.storage);
@@ -183,9 +189,9 @@ export const CostsPlugin = {
183
189
 
184
190
  // Update total cost
185
191
  this.updateTotal();
186
- },
192
+ }
187
193
 
188
- trackDataTransferIn (bytes) {
194
+ trackDataTransferIn(bytes) {
189
195
  this.costs.dataTransfer.inBytes += bytes;
190
196
  this.costs.dataTransfer.inGB = this.costs.dataTransfer.inBytes / (1024 * 1024 * 1024);
191
197
  // inCost is always $0
@@ -198,9 +204,9 @@ export const CostsPlugin = {
198
204
 
199
205
  // Update total cost
200
206
  this.updateTotal();
201
- },
207
+ }
202
208
 
203
- trackDataTransferOut (bytes) {
209
+ trackDataTransferOut(bytes) {
204
210
  this.costs.dataTransfer.outBytes += bytes;
205
211
  this.costs.dataTransfer.outGB = this.costs.dataTransfer.outBytes / (1024 * 1024 * 1024);
206
212
  this.costs.dataTransfer.subtotal = this.calculateDataTransferCost(this.costs.dataTransfer);
@@ -214,9 +220,9 @@ export const CostsPlugin = {
214
220
 
215
221
  // Update total cost
216
222
  this.updateTotal();
217
- },
223
+ }
218
224
 
219
- calculateStorageCost (storage) {
225
+ calculateStorageCost(storage) {
220
226
  const totalGB = storage.totalGB;
221
227
  let cost = 0;
222
228
  let remaining = totalGB;
@@ -239,14 +245,14 @@ export const CostsPlugin = {
239
245
  }
240
246
 
241
247
  return cost;
242
- },
248
+ }
243
249
 
244
- calculateDataTransferCost (dataTransfer) {
250
+ calculateDataTransferCost(dataTransfer) {
245
251
  let totalGB = dataTransfer.outGB;
246
252
  let cost = 0;
247
253
 
248
254
  // Apply free tier if enabled
249
- if (this.options && this.options.considerFreeTier) {
255
+ if (this.config && this.config.considerFreeTier) {
250
256
  const freeTierRemaining = dataTransfer.freeTierGB - dataTransfer.freeTierUsed;
251
257
 
252
258
  if (freeTierRemaining > 0 && totalGB > 0) {
@@ -276,9 +282,9 @@ export const CostsPlugin = {
276
282
  }
277
283
 
278
284
  return cost;
279
- },
285
+ }
280
286
 
281
- updateTotal () {
287
+ updateTotal() {
282
288
  this.costs.total =
283
289
  this.costs.requests.subtotal +
284
290
  this.costs.storage.subtotal +
@@ -291,7 +297,7 @@ export const CostsPlugin = {
291
297
  this.client.costs.storage.subtotal +
292
298
  this.client.costs.dataTransfer.subtotal;
293
299
  }
294
- },
300
+ }
295
301
  }
296
302
 
297
- export default CostsPlugin
303
+ export default CostsPlugin;
@@ -86,6 +86,7 @@ export class VectorPlugin extends Plugin {
86
86
  *
87
87
  * Detects large vector fields and warns if proper behavior is not set.
88
88
  * Can optionally auto-fix by setting body-overflow behavior.
89
+ * Auto-creates partitions for optional embedding fields to enable O(1) filtering.
89
90
  */
90
91
  validateVectorStorage() {
91
92
  for (const resource of Object.values(this.database.resources)) {
@@ -131,7 +132,278 @@ export class VectorPlugin extends Plugin {
131
132
  }
132
133
  }
133
134
  }
135
+
136
+ // Auto-create partitions for optional embedding fields
137
+ this.setupEmbeddingPartitions(resource, vectorFields);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Setup automatic partitions for optional embedding fields
143
+ *
144
+ * Creates a partition that separates records with embeddings from those without.
145
+ * This enables O(1) filtering instead of O(n) full scans when searching/clustering.
146
+ *
147
+ * @param {Resource} resource - Resource instance
148
+ * @param {Array} vectorFields - Detected vector fields with metadata
149
+ */
150
+ setupEmbeddingPartitions(resource, vectorFields) {
151
+ // Skip if resource doesn't have config (e.g., mocked resources)
152
+ if (!resource.config) return;
153
+
154
+ for (const vectorField of vectorFields) {
155
+ // Check if the vector field is optional
156
+ const isOptional = this.isFieldOptional(resource.schema.attributes, vectorField.name);
157
+
158
+ if (!isOptional) continue;
159
+
160
+ // Generate partition name
161
+ const partitionName = `byHas${this.capitalize(vectorField.name.replace(/\./g, '_'))}`;
162
+ const trackingFieldName = `_has${this.capitalize(vectorField.name.replace(/\./g, '_'))}`;
163
+
164
+ // Check if partition already exists
165
+ if (resource.config.partitions && resource.config.partitions[partitionName]) {
166
+ this.emit('vector:partition-exists', {
167
+ resource: resource.name,
168
+ vectorField: vectorField.name,
169
+ partition: partitionName,
170
+ timestamp: Date.now()
171
+ });
172
+ continue;
173
+ }
174
+
175
+ // Create partition configuration
176
+ if (!resource.config.partitions) {
177
+ resource.config.partitions = {};
178
+ }
179
+
180
+ resource.config.partitions[partitionName] = {
181
+ fields: {
182
+ [trackingFieldName]: 'boolean'
183
+ }
184
+ };
185
+
186
+ // Add tracking field to schema if not present
187
+ if (!resource.schema.attributes[trackingFieldName]) {
188
+ resource.schema.attributes[trackingFieldName] = {
189
+ type: 'boolean',
190
+ optional: true,
191
+ default: false
192
+ };
193
+ }
194
+
195
+ // Emit event
196
+ this.emit('vector:partition-created', {
197
+ resource: resource.name,
198
+ vectorField: vectorField.name,
199
+ partition: partitionName,
200
+ trackingField: trackingFieldName,
201
+ timestamp: Date.now()
202
+ });
203
+
204
+ console.log(`✅ VectorPlugin: Created partition '${partitionName}' for optional embedding field '${vectorField.name}' in resource '${resource.name}'`);
205
+
206
+ // Install hooks to maintain the partition
207
+ this.installEmbeddingHooks(resource, vectorField.name, trackingFieldName);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Check if a field is optional in the schema
213
+ *
214
+ * @param {Object} attributes - Resource attributes
215
+ * @param {string} fieldPath - Field path (supports dot notation)
216
+ * @returns {boolean} True if field is optional
217
+ */
218
+ isFieldOptional(attributes, fieldPath) {
219
+ const parts = fieldPath.split('.');
220
+ let current = attributes;
221
+
222
+ for (let i = 0; i < parts.length; i++) {
223
+ const part = parts[i];
224
+ const attr = current[part];
225
+
226
+ if (!attr) return true; // Field doesn't exist = optional
227
+
228
+ // Shorthand notation (e.g., 'string|required', 'embedding:1536')
229
+ if (typeof attr === 'string') {
230
+ const flags = attr.split('|');
231
+ // If it has 'required' flag, it's not optional
232
+ if (flags.includes('required')) return false;
233
+ // If it has 'optional' flag, it's optional
234
+ if (flags.includes('optional') || flags.some(f => f.startsWith('optional:'))) return true;
235
+ // By default, fields without 'required' are optional
236
+ return !flags.includes('required');
237
+ }
238
+
239
+ // Expanded notation (e.g., { type: 'string', optional: true })
240
+ if (typeof attr === 'object') {
241
+ // If we're at the last part, check if it's optional
242
+ if (i === parts.length - 1) {
243
+ // Explicit optional field
244
+ if (attr.optional === true) return true;
245
+ // Explicit required field
246
+ if (attr.optional === false) return false;
247
+ // Check for 'required' in nested object structure
248
+ // Default: optional unless explicitly marked as required
249
+ return attr.optional !== false;
250
+ }
251
+
252
+ // Navigate into nested object
253
+ if (attr.type === 'object' && attr.props) {
254
+ current = attr.props;
255
+ } else {
256
+ return true; // Can't navigate further = assume optional
257
+ }
258
+ }
134
259
  }
260
+
261
+ return true; // Default to optional
262
+ }
263
+
264
+ /**
265
+ * Capitalize first letter of string
266
+ *
267
+ * @param {string} str - Input string
268
+ * @returns {string} Capitalized string
269
+ */
270
+ capitalize(str) {
271
+ return str.charAt(0).toUpperCase() + str.slice(1);
272
+ }
273
+
274
+ /**
275
+ * Install hooks to maintain embedding partition tracking field
276
+ *
277
+ * @param {Resource} resource - Resource instance
278
+ * @param {string} vectorField - Vector field name
279
+ * @param {string} trackingField - Tracking field name
280
+ */
281
+ installEmbeddingHooks(resource, vectorField, trackingField) {
282
+ // beforeInsert: Set tracking field based on vector presence
283
+ resource.registerHook('beforeInsert', async (data) => {
284
+ const hasVector = this.hasVectorValue(data, vectorField);
285
+ this.setNestedValue(data, trackingField, hasVector);
286
+ return data;
287
+ });
288
+
289
+ // beforeUpdate: Update tracking field if vector changes
290
+ resource.registerHook('beforeUpdate', async (id, updates) => {
291
+ // Check if the vector field is being updated
292
+ if (vectorField in updates || this.hasNestedKey(updates, vectorField)) {
293
+ const hasVector = this.hasVectorValue(updates, vectorField);
294
+ this.setNestedValue(updates, trackingField, hasVector);
295
+ }
296
+ return updates;
297
+ });
298
+
299
+ this.emit('vector:hooks-installed', {
300
+ resource: resource.name,
301
+ vectorField,
302
+ trackingField,
303
+ hooks: ['beforeInsert', 'beforeUpdate'],
304
+ timestamp: Date.now()
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Check if data has a valid vector value for the given field
310
+ *
311
+ * @param {Object} data - Data object
312
+ * @param {string} fieldPath - Field path (supports dot notation)
313
+ * @returns {boolean} True if vector exists and is valid
314
+ */
315
+ hasVectorValue(data, fieldPath) {
316
+ const value = this.getNestedValue(data, fieldPath);
317
+ return value != null && Array.isArray(value) && value.length > 0;
318
+ }
319
+
320
+ /**
321
+ * Check if object has a nested key
322
+ *
323
+ * @param {Object} obj - Object to check
324
+ * @param {string} path - Dot-notation path
325
+ * @returns {boolean} True if key exists
326
+ */
327
+ hasNestedKey(obj, path) {
328
+ const parts = path.split('.');
329
+ let current = obj;
330
+
331
+ for (const part of parts) {
332
+ if (current == null || typeof current !== 'object') return false;
333
+ if (!(part in current)) return false;
334
+ current = current[part];
335
+ }
336
+
337
+ return true;
338
+ }
339
+
340
+ /**
341
+ * Get nested value from object using dot notation
342
+ *
343
+ * @param {Object} obj - Object to traverse
344
+ * @param {string} path - Dot-notation path
345
+ * @returns {*} Value at path or undefined
346
+ */
347
+ getNestedValue(obj, path) {
348
+ const parts = path.split('.');
349
+ let current = obj;
350
+
351
+ for (const part of parts) {
352
+ if (current == null || typeof current !== 'object') return undefined;
353
+ current = current[part];
354
+ }
355
+
356
+ return current;
357
+ }
358
+
359
+ /**
360
+ * Set nested value in object using dot notation
361
+ *
362
+ * @param {Object} obj - Object to modify
363
+ * @param {string} path - Dot-notation path
364
+ * @param {*} value - Value to set
365
+ */
366
+ setNestedValue(obj, path, value) {
367
+ const parts = path.split('.');
368
+ let current = obj;
369
+
370
+ for (let i = 0; i < parts.length - 1; i++) {
371
+ const part = parts[i];
372
+ if (!(part in current) || typeof current[part] !== 'object') {
373
+ current[part] = {};
374
+ }
375
+ current = current[part];
376
+ }
377
+
378
+ current[parts[parts.length - 1]] = value;
379
+ }
380
+
381
+ /**
382
+ * Get auto-created embedding partition for a vector field
383
+ *
384
+ * Returns partition configuration if an auto-partition exists for the given vector field.
385
+ * Auto-partitions enable O(1) filtering to only records with embeddings.
386
+ *
387
+ * @param {Resource} resource - Resource instance
388
+ * @param {string} vectorField - Vector field name
389
+ * @returns {Object|null} Partition config or null
390
+ */
391
+ getAutoEmbeddingPartition(resource, vectorField) {
392
+ // Skip if resource doesn't have config (e.g., mocked resources)
393
+ if (!resource.config) return null;
394
+
395
+ const partitionName = `byHas${this.capitalize(vectorField.replace(/\./g, '_'))}`;
396
+ const trackingFieldName = `_has${this.capitalize(vectorField.replace(/\./g, '_'))}`;
397
+
398
+ // Check if auto-partition exists
399
+ if (resource.config.partitions && resource.config.partitions[partitionName]) {
400
+ return {
401
+ partitionName,
402
+ partitionValues: { [trackingFieldName]: true }
403
+ };
404
+ }
405
+
406
+ return null;
135
407
  }
136
408
 
137
409
  /**
@@ -313,11 +585,12 @@ export class VectorPlugin extends Plugin {
313
585
  vectorField = 'vector'; // Fallback to default
314
586
  }
315
587
 
316
- const {
588
+ let {
317
589
  limit = 10,
318
590
  distanceMetric = this.config.distanceMetric,
319
591
  threshold = null,
320
- partition = null
592
+ partition = null,
593
+ partitionValues = null
321
594
  } = options;
322
595
 
323
596
  const distanceFn = this.distanceFunctions[distanceMetric];
@@ -337,6 +610,23 @@ export class VectorPlugin extends Plugin {
337
610
  throw error;
338
611
  }
339
612
 
613
+ // Auto-use embedding partition if available and no custom partition specified
614
+ if (!partition) {
615
+ const autoPartition = this.getAutoEmbeddingPartition(resource, vectorField);
616
+ if (autoPartition) {
617
+ partition = autoPartition.partitionName;
618
+ partitionValues = autoPartition.partitionValues;
619
+
620
+ this._emitEvent('vector:auto-partition-used', {
621
+ resource: resource.name,
622
+ vectorField,
623
+ partition,
624
+ partitionValues,
625
+ timestamp: Date.now()
626
+ });
627
+ }
628
+ }
629
+
340
630
  // Emit start event
341
631
  this._emitEvent('vector:search-start', {
342
632
  resource: resource.name,
@@ -344,6 +634,7 @@ export class VectorPlugin extends Plugin {
344
634
  limit,
345
635
  distanceMetric,
346
636
  partition,
637
+ partitionValues,
347
638
  threshold,
348
639
  queryDimensions: queryVector.length,
349
640
  timestamp: startTime
@@ -352,21 +643,41 @@ export class VectorPlugin extends Plugin {
352
643
  try {
353
644
  // Get all records (with optional partition filter)
354
645
  let allRecords;
355
- if (partition) {
646
+ if (partition && partitionValues) {
356
647
  this._emitEvent('vector:partition-filter', {
357
648
  resource: resource.name,
358
649
  partition,
650
+ partitionValues,
359
651
  timestamp: Date.now()
360
652
  });
361
- allRecords = await resource.list({ partition, partitionValues: partition });
653
+ allRecords = await resource.list({ partition, partitionValues });
362
654
  } else {
363
- allRecords = await resource.getAll();
655
+ // Fallback to list() if getAll() doesn't exist (for mocked resources in tests)
656
+ allRecords = resource.getAll ? await resource.getAll() : await resource.list();
364
657
  }
365
658
 
366
659
  const totalRecords = allRecords.length;
367
660
  let processedRecords = 0;
368
661
  let dimensionMismatches = 0;
369
662
 
663
+ // Performance warning for large resources without partition
664
+ if (!partition && totalRecords > 1000) {
665
+ const warning = {
666
+ resource: resource.name,
667
+ operation: 'vectorSearch',
668
+ totalRecords,
669
+ vectorField,
670
+ recommendation: 'Use partitions to filter data before vector search for better performance'
671
+ };
672
+
673
+ this._emitEvent('vector:performance-warning', warning);
674
+
675
+ console.warn(`⚠️ VectorPlugin: Performing vectorSearch on ${totalRecords} records without partition filter`);
676
+ console.warn(` Resource: '${resource.name}'`);
677
+ console.warn(` Recommendation: Use partition parameter to reduce search space`);
678
+ console.warn(` Example: resource.vectorSearch(vector, { partition: 'byCategory', partitionValues: { category: 'books' } })`);
679
+ }
680
+
370
681
  // Calculate distances
371
682
  const results = allRecords
372
683
  .filter(record => record[vectorField] && Array.isArray(record[vectorField]))
@@ -473,10 +784,11 @@ export class VectorPlugin extends Plugin {
473
784
  vectorField = 'vector'; // Fallback to default
474
785
  }
475
786
 
476
- const {
787
+ let {
477
788
  k = 5,
478
789
  distanceMetric = this.config.distanceMetric,
479
790
  partition = null,
791
+ partitionValues = null,
480
792
  ...kmeansOptions
481
793
  } = options;
482
794
 
@@ -497,6 +809,23 @@ export class VectorPlugin extends Plugin {
497
809
  throw error;
498
810
  }
499
811
 
812
+ // Auto-use embedding partition if available and no custom partition specified
813
+ if (!partition) {
814
+ const autoPartition = this.getAutoEmbeddingPartition(resource, vectorField);
815
+ if (autoPartition) {
816
+ partition = autoPartition.partitionName;
817
+ partitionValues = autoPartition.partitionValues;
818
+
819
+ this._emitEvent('vector:auto-partition-used', {
820
+ resource: resource.name,
821
+ vectorField,
822
+ partition,
823
+ partitionValues,
824
+ timestamp: Date.now()
825
+ });
826
+ }
827
+ }
828
+
500
829
  // Emit start event
501
830
  this._emitEvent('vector:cluster-start', {
502
831
  resource: resource.name,
@@ -504,6 +833,7 @@ export class VectorPlugin extends Plugin {
504
833
  k,
505
834
  distanceMetric,
506
835
  partition,
836
+ partitionValues,
507
837
  maxIterations: kmeansOptions.maxIterations || 100,
508
838
  timestamp: startTime
509
839
  });
@@ -511,15 +841,17 @@ export class VectorPlugin extends Plugin {
511
841
  try {
512
842
  // Get all records (with optional partition filter)
513
843
  let allRecords;
514
- if (partition) {
844
+ if (partition && partitionValues) {
515
845
  this._emitEvent('vector:partition-filter', {
516
846
  resource: resource.name,
517
847
  partition,
848
+ partitionValues,
518
849
  timestamp: Date.now()
519
850
  });
520
- allRecords = await resource.list({ partition, partitionValues: partition });
851
+ allRecords = await resource.list({ partition, partitionValues });
521
852
  } else {
522
- allRecords = await resource.getAll();
853
+ // Fallback to list() if getAll() doesn't exist (for mocked resources in tests)
854
+ allRecords = resource.getAll ? await resource.getAll() : await resource.list();
523
855
  }
524
856
 
525
857
  // Extract vectors
@@ -527,6 +859,26 @@ export class VectorPlugin extends Plugin {
527
859
  record => record[vectorField] && Array.isArray(record[vectorField])
528
860
  );
529
861
 
862
+ // Performance warning for large resources without partition
863
+ if (!partition && allRecords.length > 1000) {
864
+ const warning = {
865
+ resource: resource.name,
866
+ operation: 'cluster',
867
+ totalRecords: allRecords.length,
868
+ recordsWithVectors: recordsWithVectors.length,
869
+ vectorField,
870
+ recommendation: 'Use partitions to filter data before clustering for better performance'
871
+ };
872
+
873
+ this._emitEvent('vector:performance-warning', warning);
874
+
875
+ console.warn(`⚠️ VectorPlugin: Performing clustering on ${allRecords.length} records without partition filter`);
876
+ console.warn(` Resource: '${resource.name}'`);
877
+ console.warn(` Records with vectors: ${recordsWithVectors.length}`);
878
+ console.warn(` Recommendation: Use partition parameter to reduce clustering space`);
879
+ console.warn(` Example: resource.cluster({ k: 5, partition: 'byCategory', partitionValues: { category: 'books' } })`);
880
+ }
881
+
530
882
  if (recordsWithVectors.length === 0) {
531
883
  const error = new VectorError('No vectors found in resource', {
532
884
  operation: 'cluster',