s3db.js 10.0.0 → 10.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.
@@ -1,6 +1,7 @@
1
1
  import { join } from "path";
2
+ import jsonStableStringify from "json-stable-stringify";
3
+ import crypto from 'crypto';
2
4
 
3
- import { sha256 } from "../concerns/crypto.js";
4
5
  import Plugin from "./plugin.class.js";
5
6
  import S3Cache from "./cache/s3-cache.class.js";
6
7
  import MemoryCache from "./cache/memory-cache.class.js";
@@ -11,26 +12,34 @@ import tryFn from "../concerns/try-fn.js";
11
12
  export class CachePlugin extends Plugin {
12
13
  constructor(options = {}) {
13
14
  super(options);
14
-
15
- // Extract primary configuration
16
- this.driverName = options.driver || 's3';
17
- this.ttl = options.ttl;
18
- this.maxSize = options.maxSize;
19
- this.config = options.config || {};
20
-
21
- // Plugin-level settings
22
- this.includePartitions = options.includePartitions !== false;
23
- this.partitionStrategy = options.partitionStrategy || 'hierarchical';
24
- this.partitionAware = options.partitionAware !== false;
25
- this.trackUsage = options.trackUsage !== false;
26
- this.preloadRelated = options.preloadRelated !== false;
27
-
28
- // Legacy support - keep the old options for backward compatibility
29
- this.legacyConfig = {
30
- memoryOptions: options.memoryOptions,
31
- filesystemOptions: options.filesystemOptions,
32
- s3Options: options.s3Options,
33
- driver: options.driver
15
+
16
+ // Clean, consolidated configuration
17
+ this.config = {
18
+ // Driver configuration
19
+ driver: options.driver || 's3',
20
+ config: {
21
+ ttl: options.ttl,
22
+ maxSize: options.maxSize,
23
+ ...options.config // Driver-specific config (can override ttl/maxSize)
24
+ },
25
+
26
+ // Resource filtering
27
+ include: options.include || null, // Array of resource names to cache (null = all)
28
+ exclude: options.exclude || [], // Array of resource names to exclude
29
+
30
+ // Partition settings
31
+ includePartitions: options.includePartitions !== false,
32
+ partitionStrategy: options.partitionStrategy || 'hierarchical',
33
+ partitionAware: options.partitionAware !== false,
34
+ trackUsage: options.trackUsage !== false,
35
+ preloadRelated: options.preloadRelated !== false,
36
+
37
+ // Retry configuration
38
+ retryAttempts: options.retryAttempts || 3,
39
+ retryDelay: options.retryDelay || 100, // ms
40
+
41
+ // Logging
42
+ verbose: options.verbose || false
34
43
  };
35
44
  }
36
45
 
@@ -40,68 +49,29 @@ export class CachePlugin extends Plugin {
40
49
 
41
50
  async onSetup() {
42
51
  // Initialize cache driver
43
- if (this.driverName && typeof this.driverName === 'object') {
52
+ if (this.config.driver && typeof this.config.driver === 'object') {
44
53
  // Use custom driver instance if provided
45
- this.driver = this.driverName;
46
- } else if (this.driverName === 'memory') {
47
- // Build driver configuration with proper precedence
48
- const driverConfig = {
49
- ...this.legacyConfig.memoryOptions, // Legacy support (lowest priority)
50
- ...this.config, // New config format (medium priority)
51
- };
52
-
53
- // Add global settings if defined (highest priority)
54
- if (this.ttl !== undefined) {
55
- driverConfig.ttl = this.ttl;
56
- }
57
- if (this.maxSize !== undefined) {
58
- driverConfig.maxSize = this.maxSize;
59
- }
60
-
61
- this.driver = new MemoryCache(driverConfig);
62
- } else if (this.driverName === 'filesystem') {
63
- // Build driver configuration with proper precedence
64
- const driverConfig = {
65
- ...this.legacyConfig.filesystemOptions, // Legacy support (lowest priority)
66
- ...this.config, // New config format (medium priority)
67
- };
68
-
69
- // Add global settings if defined (highest priority)
70
- if (this.ttl !== undefined) {
71
- driverConfig.ttl = this.ttl;
72
- }
73
- if (this.maxSize !== undefined) {
74
- driverConfig.maxSize = this.maxSize;
75
- }
76
-
54
+ this.driver = this.config.driver;
55
+ } else if (this.config.driver === 'memory') {
56
+ this.driver = new MemoryCache(this.config.config);
57
+ } else if (this.config.driver === 'filesystem') {
77
58
  // Use partition-aware filesystem cache if enabled
78
- if (this.partitionAware) {
59
+ if (this.config.partitionAware) {
79
60
  this.driver = new PartitionAwareFilesystemCache({
80
- partitionStrategy: this.partitionStrategy,
81
- trackUsage: this.trackUsage,
82
- preloadRelated: this.preloadRelated,
83
- ...driverConfig
61
+ partitionStrategy: this.config.partitionStrategy,
62
+ trackUsage: this.config.trackUsage,
63
+ preloadRelated: this.config.preloadRelated,
64
+ ...this.config.config
84
65
  });
85
66
  } else {
86
- this.driver = new FilesystemCache(driverConfig);
67
+ this.driver = new FilesystemCache(this.config.config);
87
68
  }
88
69
  } else {
89
- // Default to S3Cache - build driver configuration with proper precedence
90
- const driverConfig = {
91
- client: this.database.client, // Required for S3Cache
92
- ...this.legacyConfig.s3Options, // Legacy support (lowest priority)
93
- ...this.config, // New config format (medium priority)
94
- };
95
-
96
- // Add global settings if defined (highest priority)
97
- if (this.ttl !== undefined) {
98
- driverConfig.ttl = this.ttl;
99
- }
100
- if (this.maxSize !== undefined) {
101
- driverConfig.maxSize = this.maxSize;
102
- }
103
-
104
- this.driver = new S3Cache(driverConfig);
70
+ // Default to S3Cache
71
+ this.driver = new S3Cache({
72
+ client: this.database.client,
73
+ ...this.config.config
74
+ });
105
75
  }
106
76
 
107
77
  // Use database hooks instead of method overwriting
@@ -117,7 +87,9 @@ export class CachePlugin extends Plugin {
117
87
  installDatabaseHooks() {
118
88
  // Hook into resource creation to install cache middleware
119
89
  this.database.addHook('afterCreateResource', async ({ resource }) => {
120
- this.installResourceHooksForResource(resource);
90
+ if (this.shouldCacheResource(resource.name)) {
91
+ this.installResourceHooksForResource(resource);
92
+ }
121
93
  });
122
94
  }
123
95
 
@@ -132,10 +104,33 @@ export class CachePlugin extends Plugin {
132
104
  // Remove the old installDatabaseProxy method
133
105
  installResourceHooks() {
134
106
  for (const resource of Object.values(this.database.resources)) {
107
+ // Check if resource should be cached
108
+ if (!this.shouldCacheResource(resource.name)) {
109
+ continue;
110
+ }
135
111
  this.installResourceHooksForResource(resource);
136
112
  }
137
113
  }
138
114
 
115
+ shouldCacheResource(resourceName) {
116
+ // Skip plugin resources by default (unless explicitly included)
117
+ if (resourceName.startsWith('plg_') && !this.config.include) {
118
+ return false;
119
+ }
120
+
121
+ // Check exclude list
122
+ if (this.config.exclude.includes(resourceName)) {
123
+ return false;
124
+ }
125
+
126
+ // Check include list (if specified)
127
+ if (this.config.include && !this.config.include.includes(resourceName)) {
128
+ return false;
129
+ }
130
+
131
+ return true;
132
+ }
133
+
139
134
  installResourceHooksForResource(resource) {
140
135
  if (!this.driver) return;
141
136
 
@@ -300,19 +295,28 @@ export class CachePlugin extends Plugin {
300
295
 
301
296
  async clearCacheForResource(resource, data) {
302
297
  if (!resource.cache) return; // Skip if no cache is available
303
-
298
+
304
299
  const keyPrefix = `resource=${resource.name}`;
305
-
300
+
306
301
  // For specific operations, only clear relevant cache entries
307
302
  if (data && data.id) {
308
303
  // Clear specific item caches for this ID
309
304
  const itemSpecificMethods = ['get', 'exists', 'content', 'hasContent'];
310
305
  for (const method of itemSpecificMethods) {
311
- try {
312
- const specificKey = await this.generateCacheKey(resource, method, { id: data.id });
313
- await resource.cache.clear(specificKey.replace('.json.gz', ''));
314
- } catch (error) {
315
- // Ignore cache clearing errors for individual items
306
+ const specificKey = await this.generateCacheKey(resource, method, { id: data.id });
307
+ const [ok, err] = await this.clearCacheWithRetry(resource.cache, specificKey);
308
+
309
+ if (!ok) {
310
+ this.emit('cache_clear_error', {
311
+ resource: resource.name,
312
+ method,
313
+ id: data.id,
314
+ error: err.message
315
+ });
316
+
317
+ if (this.config.verbose) {
318
+ console.warn(`[CachePlugin] Failed to clear ${method} cache for ${resource.name}:${data.id}:`, err.message);
319
+ }
316
320
  }
317
321
  }
318
322
 
@@ -321,34 +325,74 @@ export class CachePlugin extends Plugin {
321
325
  const partitionValues = this.getPartitionValues(data, resource);
322
326
  for (const [partitionName, values] of Object.entries(partitionValues)) {
323
327
  if (values && Object.keys(values).length > 0 && Object.values(values).some(v => v !== null && v !== undefined)) {
324
- try {
325
- const partitionKeyPrefix = join(keyPrefix, `partition=${partitionName}`);
326
- await resource.cache.clear(partitionKeyPrefix);
327
- } catch (error) {
328
- // Ignore partition cache clearing errors
328
+ const partitionKeyPrefix = join(keyPrefix, `partition=${partitionName}`);
329
+ const [ok, err] = await this.clearCacheWithRetry(resource.cache, partitionKeyPrefix);
330
+
331
+ if (!ok) {
332
+ this.emit('cache_clear_error', {
333
+ resource: resource.name,
334
+ partition: partitionName,
335
+ error: err.message
336
+ });
337
+
338
+ if (this.config.verbose) {
339
+ console.warn(`[CachePlugin] Failed to clear partition cache for ${resource.name}/${partitionName}:`, err.message);
340
+ }
329
341
  }
330
342
  }
331
343
  }
332
344
  }
333
345
  }
334
-
346
+
335
347
  // Clear aggregate caches more broadly to ensure all variants are cleared
336
- try {
337
- // Clear all cache entries for this resource - this ensures aggregate methods are invalidated
338
- await resource.cache.clear(keyPrefix);
339
- } catch (error) {
348
+ const [ok, err] = await this.clearCacheWithRetry(resource.cache, keyPrefix);
349
+
350
+ if (!ok) {
351
+ this.emit('cache_clear_error', {
352
+ resource: resource.name,
353
+ type: 'broad',
354
+ error: err.message
355
+ });
356
+
357
+ if (this.config.verbose) {
358
+ console.warn(`[CachePlugin] Failed to clear broad cache for ${resource.name}, trying specific methods:`, err.message);
359
+ }
360
+
340
361
  // If broad clearing fails, try specific method clearing
341
362
  const aggregateMethods = ['count', 'list', 'listIds', 'getAll', 'page', 'query'];
342
363
  for (const method of aggregateMethods) {
343
- try {
344
- // Try multiple key patterns to ensure we catch all variations
345
- await resource.cache.clear(`${keyPrefix}/action=${method}`);
346
- await resource.cache.clear(`resource=${resource.name}/action=${method}`);
347
- } catch (methodError) {
348
- // Ignore individual method clearing errors
349
- }
364
+ // Try multiple key patterns to ensure we catch all variations
365
+ await this.clearCacheWithRetry(resource.cache, `${keyPrefix}/action=${method}`);
366
+ await this.clearCacheWithRetry(resource.cache, `resource=${resource.name}/action=${method}`);
367
+ }
368
+ }
369
+ }
370
+
371
+ async clearCacheWithRetry(cache, key) {
372
+ let lastError;
373
+
374
+ for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
375
+ const [ok, err] = await tryFn(() => cache.clear(key));
376
+
377
+ if (ok) {
378
+ return [true, null];
379
+ }
380
+
381
+ lastError = err;
382
+
383
+ // Don't retry if it's a "not found" error
384
+ if (err.name === 'NoSuchKey' || err.code === 'NoSuchKey') {
385
+ return [true, null]; // Key doesn't exist, that's fine
386
+ }
387
+
388
+ // Wait before retry (exponential backoff)
389
+ if (attempt < this.config.retryAttempts - 1) {
390
+ const delay = this.config.retryDelay * Math.pow(2, attempt);
391
+ await new Promise(resolve => setTimeout(resolve, delay));
350
392
  }
351
393
  }
394
+
395
+ return [false, lastError];
352
396
  }
353
397
 
354
398
  async generateCacheKey(resource, action, params = {}, partition = null, partitionValues = null) {
@@ -369,20 +413,21 @@ export class CachePlugin extends Plugin {
369
413
 
370
414
  // Add params if they exist
371
415
  if (Object.keys(params).length > 0) {
372
- const paramsHash = await this.hashParams(params);
416
+ const paramsHash = this.hashParams(params);
373
417
  keyParts.push(paramsHash);
374
418
  }
375
419
 
376
420
  return join(...keyParts) + '.json.gz';
377
421
  }
378
422
 
379
- async hashParams(params) {
380
- const sortedParams = Object.keys(params)
381
- .sort()
382
- .map(key => `${key}:${JSON.stringify(params[key])}`) // Use JSON.stringify for complex objects
383
- .join('|') || 'empty';
384
-
385
- return await sha256(sortedParams);
423
+ hashParams(params) {
424
+ // Use json-stable-stringify for deterministic serialization
425
+ // Handles nested objects, dates, and maintains consistent key order
426
+ const serialized = jsonStableStringify(params) || 'empty';
427
+
428
+ // Use MD5 for fast non-cryptographic hashing (10x faster than SHA-256)
429
+ // Security not needed here - just need consistent, collision-resistant hash
430
+ return crypto.createHash('md5').update(serialized).digest('hex').substring(0, 16);
386
431
  }
387
432
 
388
433
  // Utility methods
@@ -413,7 +458,7 @@ export class CachePlugin extends Plugin {
413
458
  throw new Error(`Resource '${resourceName}' not found`);
414
459
  }
415
460
 
416
- const { includePartitions = true } = options;
461
+ const { includePartitions = true, sampleSize = 100 } = options;
417
462
 
418
463
  // Use partition-aware warming if available
419
464
  if (this.driver instanceof PartitionAwareFilesystemCache && resource.warmPartitionCache) {
@@ -421,60 +466,63 @@ export class CachePlugin extends Plugin {
421
466
  return await resource.warmPartitionCache(partitionNames, options);
422
467
  }
423
468
 
424
- // Fallback to standard warming
425
- await resource.getAll();
469
+ // Use pagination instead of getAll() for efficiency
470
+ let offset = 0;
471
+ const pageSize = 100;
472
+ const sampledRecords = [];
473
+
474
+ // Get sample of records using pagination
475
+ while (sampledRecords.length < sampleSize) {
476
+ const [ok, err, pageResult] = await tryFn(() => resource.page({ offset, size: pageSize }));
477
+
478
+ if (!ok || !pageResult) {
479
+ break;
480
+ }
481
+
482
+ // page() might return { items, total } or just an array
483
+ const pageItems = Array.isArray(pageResult) ? pageResult : (pageResult.items || []);
484
+
485
+ if (pageItems.length === 0) {
486
+ break;
487
+ }
488
+
489
+ sampledRecords.push(...pageItems);
490
+ offset += pageSize;
491
+
492
+ // Cache the page while we're at it
493
+ // (page() call already cached it via middleware)
494
+ }
426
495
 
427
496
  // Warm partition caches if enabled
428
- if (includePartitions && resource.config.partitions) {
497
+ if (includePartitions && resource.config.partitions && sampledRecords.length > 0) {
429
498
  for (const [partitionName, partitionDef] of Object.entries(resource.config.partitions)) {
430
499
  if (partitionDef.fields) {
431
- // Get some sample partition values and warm those caches
432
- const allRecords = await resource.getAll();
433
-
434
- // Ensure allRecords is an array
435
- const recordsArray = Array.isArray(allRecords) ? allRecords : [];
436
- const partitionValues = new Set();
437
-
438
- for (const record of recordsArray.slice(0, 10)) { // Sample first 10 records
500
+ // Get unique partition values from sample
501
+ const partitionValuesSet = new Set();
502
+
503
+ for (const record of sampledRecords) {
439
504
  const values = this.getPartitionValues(record, resource);
440
505
  if (values[partitionName]) {
441
- partitionValues.add(JSON.stringify(values[partitionName]));
506
+ partitionValuesSet.add(JSON.stringify(values[partitionName]));
442
507
  }
443
508
  }
444
-
509
+
445
510
  // Warm cache for each partition value
446
- for (const partitionValueStr of partitionValues) {
511
+ for (const partitionValueStr of partitionValuesSet) {
447
512
  const partitionValues = JSON.parse(partitionValueStr);
448
- await resource.list({ partition: partitionName, partitionValues });
513
+ await tryFn(() => resource.list({ partition: partitionName, partitionValues }));
449
514
  }
450
515
  }
451
516
  }
452
517
  }
453
- }
454
-
455
- // Partition-specific methods
456
- async getPartitionCacheStats(resourceName, partition = null) {
457
- if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
458
- throw new Error('Partition cache statistics are only available with PartitionAwareFilesystemCache');
459
- }
460
-
461
- return await this.driver.getPartitionStats(resourceName, partition);
462
- }
463
518
 
464
- async getCacheRecommendations(resourceName) {
465
- if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
466
- throw new Error('Cache recommendations are only available with PartitionAwareFilesystemCache');
467
- }
468
-
469
- return await this.driver.getCacheRecommendations(resourceName);
470
- }
471
-
472
- async clearPartitionCache(resourceName, partition, partitionValues = {}) {
473
- if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
474
- throw new Error('Partition cache clearing is only available with PartitionAwareFilesystemCache');
475
- }
476
-
477
- return await this.driver.clearPartition(resourceName, partition, partitionValues);
519
+ return {
520
+ resourceName,
521
+ recordsSampled: sampledRecords.length,
522
+ partitionsWarmed: includePartitions && resource.config.partitions
523
+ ? Object.keys(resource.config.partitions).length
524
+ : 0
525
+ };
478
526
  }
479
527
 
480
528
  async analyzeCacheUsage() {
@@ -493,8 +541,13 @@ export class CachePlugin extends Plugin {
493
541
  }
494
542
  };
495
543
 
496
- // Analyze each resource
544
+ // Analyze each resource (respect include/exclude filters)
497
545
  for (const [resourceName, resource] of Object.entries(this.database.resources)) {
546
+ // Skip resources that shouldn't be cached
547
+ if (!this.shouldCacheResource(resourceName)) {
548
+ continue;
549
+ }
550
+
498
551
  try {
499
552
  analysis.resourceStats[resourceName] = await this.driver.getPartitionStats(resourceName);
500
553
  analysis.recommendations[resourceName] = await this.driver.getCacheRecommendations(resourceName);