s3db.js 7.4.2 → 8.0.0

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.
@@ -28,12 +28,12 @@ export class CachePlugin extends Plugin {
28
28
 
29
29
  async onSetup() {
30
30
  // Initialize cache driver
31
- if (this.config.driver) {
32
- // Use custom driver if provided
31
+ if (this.config.driver && typeof this.config.driver === 'object') {
32
+ // Use custom driver instance if provided
33
33
  this.driver = this.config.driver;
34
- } else if (this.config.driverType === 'memory') {
34
+ } else if (this.config.driver === 'memory') {
35
35
  this.driver = new MemoryCache(this.config.memoryOptions || {});
36
- } else if (this.config.driverType === 'filesystem') {
36
+ } else if (this.config.driver === 'filesystem') {
37
37
  // Use partition-aware filesystem cache if enabled
38
38
  if (this.config.partitionAware) {
39
39
  this.driver = new PartitionAwareFilesystemCache({
@@ -50,13 +50,23 @@ export class CachePlugin extends Plugin {
50
50
  this.driver = new S3Cache({ client: this.database.client, ...(this.config.s3Options || {}) });
51
51
  }
52
52
 
53
- // Install database proxy for new resources
54
- this.installDatabaseProxy();
53
+ // Use database hooks instead of method overwriting
54
+ this.installDatabaseHooks();
55
55
 
56
56
  // Install hooks for existing resources
57
57
  this.installResourceHooks();
58
58
  }
59
59
 
60
+ /**
61
+ * Install database hooks to handle resource creation/updates
62
+ */
63
+ installDatabaseHooks() {
64
+ // Hook into resource creation to install cache middleware
65
+ this.database.addHook('afterCreateResource', async ({ resource }) => {
66
+ this.installResourceHooksForResource(resource);
67
+ });
68
+ }
69
+
60
70
  async onStart() {
61
71
  // Plugin is ready
62
72
  }
@@ -65,27 +75,7 @@ export class CachePlugin extends Plugin {
65
75
  // Cleanup if needed
66
76
  }
67
77
 
68
- installDatabaseProxy() {
69
- if (this.database._cacheProxyInstalled) {
70
- return; // Already installed
71
- }
72
-
73
- const installResourceHooks = this.installResourceHooks.bind(this);
74
-
75
- // Store original method
76
- this.database._originalCreateResourceForCache = this.database.createResource;
77
-
78
- // Create new method that doesn't call itself
79
- this.database.createResource = async function (...args) {
80
- const resource = await this._originalCreateResourceForCache(...args);
81
- installResourceHooks(resource);
82
- return resource;
83
- };
84
-
85
- // Mark as installed
86
- this.database._cacheProxyInstalled = true;
87
- }
88
-
78
+ // Remove the old installDatabaseProxy method
89
79
  installResourceHooks() {
90
80
  for (const resource of Object.values(this.database.resources)) {
91
81
  this.installResourceHooksForResource(resource);
@@ -126,10 +116,12 @@ export class CachePlugin extends Plugin {
126
116
  };
127
117
  }
128
118
 
129
- // List of methods to cache
119
+ // Expanded list of methods to cache (including previously missing ones)
130
120
  const cacheMethods = [
131
- 'count', 'listIds', 'getMany', 'getAll', 'page', 'list', 'get'
121
+ 'count', 'listIds', 'getMany', 'getAll', 'page', 'list', 'get',
122
+ 'exists', 'content', 'hasContent', 'query', 'getFromPartition'
132
123
  ];
124
+
133
125
  for (const method of cacheMethods) {
134
126
  resource.useMiddleware(method, async (ctx, next) => {
135
127
  // Build cache key
@@ -142,11 +134,29 @@ export class CachePlugin extends Plugin {
142
134
  } else if (method === 'list' || method === 'listIds' || method === 'count') {
143
135
  const { partition, partitionValues } = ctx.args[0] || {};
144
136
  key = await resource.cacheKeyFor({ action: method, partition, partitionValues });
137
+ } else if (method === 'query') {
138
+ const filter = ctx.args[0] || {};
139
+ const options = ctx.args[1] || {};
140
+ key = await resource.cacheKeyFor({
141
+ action: method,
142
+ params: { filter, options: { limit: options.limit, offset: options.offset } },
143
+ partition: options.partition,
144
+ partitionValues: options.partitionValues
145
+ });
146
+ } else if (method === 'getFromPartition') {
147
+ const { id, partitionName, partitionValues } = ctx.args[0] || {};
148
+ key = await resource.cacheKeyFor({
149
+ action: method,
150
+ params: { id, partitionName },
151
+ partition: partitionName,
152
+ partitionValues
153
+ });
145
154
  } else if (method === 'getAll') {
146
155
  key = await resource.cacheKeyFor({ action: method });
147
- } else if (method === 'get') {
156
+ } else if (['get', 'exists', 'content', 'hasContent'].includes(method)) {
148
157
  key = await resource.cacheKeyFor({ action: method, params: { id: ctx.args[0] } });
149
158
  }
159
+
150
160
  // Try cache with partition awareness
151
161
  let cached;
152
162
  if (this.driver instanceof PartitionAwareFilesystemCache) {
@@ -156,6 +166,14 @@ export class CachePlugin extends Plugin {
156
166
  const args = ctx.args[0] || {};
157
167
  partition = args.partition;
158
168
  partitionValues = args.partitionValues;
169
+ } else if (method === 'query') {
170
+ const options = ctx.args[1] || {};
171
+ partition = options.partition;
172
+ partitionValues = options.partitionValues;
173
+ } else if (method === 'getFromPartition') {
174
+ const { partitionName, partitionValues: pValues } = ctx.args[0] || {};
175
+ partition = partitionName;
176
+ partitionValues = pValues;
159
177
  }
160
178
 
161
179
  const [ok, err, result] = await tryFn(() => resource.cache._get(key, {
@@ -194,8 +212,8 @@ export class CachePlugin extends Plugin {
194
212
  });
195
213
  }
196
214
 
197
- // List of methods to clear cache on write
198
- const writeMethods = ['insert', 'update', 'delete', 'deleteMany'];
215
+ // List of methods to clear cache on write (expanded to include new methods)
216
+ const writeMethods = ['insert', 'update', 'delete', 'deleteMany', 'setContent', 'deleteContent', 'replace'];
199
217
  for (const method of writeMethods) {
200
218
  resource.useMiddleware(method, async (ctx, next) => {
201
219
  const result = await next();
@@ -211,6 +229,12 @@ export class CachePlugin extends Plugin {
211
229
  if (ok && full) data = full;
212
230
  }
213
231
  await this.clearCacheForResource(resource, data);
232
+ } else if (method === 'setContent' || method === 'deleteContent') {
233
+ const id = ctx.args[0]?.id || ctx.args[0];
234
+ await this.clearCacheForResource(resource, { id });
235
+ } else if (method === 'replace') {
236
+ const id = ctx.args[0];
237
+ await this.clearCacheForResource(resource, { id, ...ctx.args[1] });
214
238
  } else if (method === 'deleteMany') {
215
239
  // After all deletions, clear all aggregate and partition caches
216
240
  await this.clearCacheForResource(resource);
@@ -225,28 +249,52 @@ export class CachePlugin extends Plugin {
225
249
 
226
250
  const keyPrefix = `resource=${resource.name}`;
227
251
 
228
- // Always clear main cache for this resource
229
- await resource.cache.clear(keyPrefix);
230
-
231
- // Only clear partition cache if partitions are enabled AND resource has partitions AND includePartitions is true
232
- if (this.config.includePartitions === true && resource.config?.partitions && Object.keys(resource.config.partitions).length > 0) {
233
- if (!data) {
234
- // If no data, clear all partition caches
235
- for (const partitionName of Object.keys(resource.config.partitions)) {
236
- const partitionKeyPrefix = join(keyPrefix, `partition=${partitionName}`);
237
- await resource.cache.clear(partitionKeyPrefix);
252
+ // For specific operations, only clear relevant cache entries
253
+ if (data && data.id) {
254
+ // Clear specific item caches for this ID
255
+ const itemSpecificMethods = ['get', 'exists', 'content', 'hasContent'];
256
+ for (const method of itemSpecificMethods) {
257
+ try {
258
+ const specificKey = await this.generateCacheKey(resource, method, { id: data.id });
259
+ await resource.cache.clear(specificKey.replace('.json.gz', ''));
260
+ } catch (error) {
261
+ // Ignore cache clearing errors for individual items
238
262
  }
239
- } else {
263
+ }
264
+
265
+ // Clear partition-specific caches if this resource has partitions
266
+ if (this.config.includePartitions === true && resource.config?.partitions && Object.keys(resource.config.partitions).length > 0) {
240
267
  const partitionValues = this.getPartitionValues(data, resource);
241
268
  for (const [partitionName, values] of Object.entries(partitionValues)) {
242
- // Only clear partition cache if there are actual values
243
269
  if (values && Object.keys(values).length > 0 && Object.values(values).some(v => v !== null && v !== undefined)) {
244
- const partitionKeyPrefix = join(keyPrefix, `partition=${partitionName}`);
245
- await resource.cache.clear(partitionKeyPrefix);
270
+ try {
271
+ const partitionKeyPrefix = join(keyPrefix, `partition=${partitionName}`);
272
+ await resource.cache.clear(partitionKeyPrefix);
273
+ } catch (error) {
274
+ // Ignore partition cache clearing errors
275
+ }
246
276
  }
247
277
  }
248
278
  }
249
279
  }
280
+
281
+ // Clear aggregate caches more broadly to ensure all variants are cleared
282
+ try {
283
+ // Clear all cache entries for this resource - this ensures aggregate methods are invalidated
284
+ await resource.cache.clear(keyPrefix);
285
+ } catch (error) {
286
+ // If broad clearing fails, try specific method clearing
287
+ const aggregateMethods = ['count', 'list', 'listIds', 'getAll', 'page', 'query'];
288
+ for (const method of aggregateMethods) {
289
+ try {
290
+ // Try multiple key patterns to ensure we catch all variations
291
+ await resource.cache.clear(`${keyPrefix}/action=${method}`);
292
+ await resource.cache.clear(`resource=${resource.name}/action=${method}`);
293
+ } catch (methodError) {
294
+ // Ignore individual method clearing errors
295
+ }
296
+ }
297
+ }
250
298
  }
251
299
 
252
300
  async generateCacheKey(resource, action, params = {}, partition = null, partitionValues = null) {
@@ -277,7 +325,7 @@ export class CachePlugin extends Plugin {
277
325
  async hashParams(params) {
278
326
  const sortedParams = Object.keys(params)
279
327
  .sort()
280
- .map(key => `${key}:${params[key]}`)
328
+ .map(key => `${key}:${JSON.stringify(params[key])}`) // Use JSON.stringify for complex objects
281
329
  .join('|') || 'empty';
282
330
 
283
331
  return await sha256(sortedParams);
@@ -34,6 +34,10 @@ export class FullTextPlugin extends Plugin {
34
34
  // Load existing indexes
35
35
  await this.loadIndexes();
36
36
 
37
+ // Use database hooks for automatic resource discovery
38
+ this.installDatabaseHooks();
39
+
40
+ // Install hooks for existing resources
37
41
  this.installIndexingHooks();
38
42
  }
39
43
 
@@ -44,6 +48,9 @@ export class FullTextPlugin extends Plugin {
44
48
  async stop() {
45
49
  // Save indexes before stopping
46
50
  await this.saveIndexes();
51
+
52
+ // Remove database hooks
53
+ this.removeDatabaseHooks();
47
54
  }
48
55
 
49
56
  async loadIndexes() {
@@ -86,6 +93,20 @@ export class FullTextPlugin extends Plugin {
86
93
  });
87
94
  }
88
95
 
96
+ installDatabaseHooks() {
97
+ // Use the new database hooks system for automatic resource discovery
98
+ this.database.addHook('afterCreateResource', (resource) => {
99
+ if (resource.name !== 'fulltext_indexes') {
100
+ this.installResourceHooks(resource);
101
+ }
102
+ });
103
+ }
104
+
105
+ removeDatabaseHooks() {
106
+ // Remove the hook we added
107
+ this.database.removeHook('afterCreateResource', this.installResourceHooks.bind(this));
108
+ }
109
+
89
110
  installIndexingHooks() {
90
111
  // Register plugin with database
91
112
  if (!this.database.plugins) {
@@ -86,7 +86,10 @@ export class MetricsPlugin extends Plugin {
86
86
  this.performanceResource = database.resources.performance_logs;
87
87
  }
88
88
 
89
- // Install hooks for all resources except metrics resources
89
+ // Use database hooks for automatic resource discovery
90
+ this.installDatabaseHooks();
91
+
92
+ // Install hooks for existing resources
90
93
  this.installMetricsHooks();
91
94
 
92
95
  // Disable flush timer during tests to avoid side effects
@@ -100,16 +103,28 @@ export class MetricsPlugin extends Plugin {
100
103
  }
101
104
 
102
105
  async stop() {
103
- // Stop flush timer and flush remaining metrics
106
+ // Stop flush timer
104
107
  if (this.flushTimer) {
105
108
  clearInterval(this.flushTimer);
106
109
  this.flushTimer = null;
107
110
  }
108
111
 
109
- // Don't flush metrics during tests
110
- if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') {
111
- await this.flushMetrics();
112
- }
112
+ // Remove database hooks
113
+ this.removeDatabaseHooks();
114
+ }
115
+
116
+ installDatabaseHooks() {
117
+ // Use the new database hooks system for automatic resource discovery
118
+ this.database.addHook('afterCreateResource', (resource) => {
119
+ if (resource.name !== 'metrics' && resource.name !== 'error_logs' && resource.name !== 'performance_logs') {
120
+ this.installResourceHooks(resource);
121
+ }
122
+ });
123
+ }
124
+
125
+ removeDatabaseHooks() {
126
+ // Remove the hook we added
127
+ this.database.removeHook('afterCreateResource', this.installResourceHooks.bind(this));
113
128
  }
114
129
 
115
130
  installMetricsHooks() {
@@ -164,6 +164,13 @@ export class ReplicatorPlugin extends Plugin {
164
164
  return filtered;
165
165
  }
166
166
 
167
+ async getCompleteData(resource, data) {
168
+ // Always get the complete record from the resource to ensure we have all data
169
+ // This handles all behaviors: body-overflow, truncate-data, body-only, etc.
170
+ const [ok, err, completeRecord] = await tryFn(() => resource.get(data.id));
171
+ return ok ? completeRecord : data;
172
+ }
173
+
167
174
  installEventListeners(resource, database, plugin) {
168
175
  if (!resource || this.eventListenersInstalled.has(resource.name) ||
169
176
  resource.name === this.config.replicatorLogResource) {
@@ -186,8 +193,10 @@ export class ReplicatorPlugin extends Plugin {
186
193
 
187
194
  resource.on('update', async (data, beforeData) => {
188
195
  const [ok, error] = await tryFn(async () => {
189
- const completeData = { ...data, updatedAt: new Date().toISOString() };
190
- await plugin.processReplicatorEvent('update', resource.name, completeData.id, completeData, beforeData);
196
+ // For updates, we need to get the complete updated record, not just the changed fields
197
+ const completeData = await plugin.getCompleteData(resource, data);
198
+ const dataWithTimestamp = { ...completeData, updatedAt: new Date().toISOString() };
199
+ await plugin.processReplicatorEvent('update', resource.name, completeData.id, dataWithTimestamp, beforeData);
191
200
  });
192
201
 
193
202
  if (!ok) {
@@ -214,76 +223,73 @@ export class ReplicatorPlugin extends Plugin {
214
223
  this.eventListenersInstalled.add(resource.name);
215
224
  }
216
225
 
217
- /**
218
- * Get complete data by always fetching the full record from the resource
219
- * This ensures we always have the complete data regardless of behavior or data size
220
- */
221
- async getCompleteData(resource, data) {
222
- // Always get the complete record from the resource to ensure we have all data
223
- // This handles all behaviors: body-overflow, truncate-data, body-only, etc.
224
- const [ok, err, completeRecord] = await tryFn(() => resource.get(data.id));
225
- return ok ? completeRecord : data;
226
- }
227
-
228
226
  async setup(database) {
229
227
  this.database = database;
230
-
231
- const [initOk, initError] = await tryFn(async () => {
232
- await this.initializeReplicators(database);
233
- });
234
228
 
235
- if (!initOk) {
236
- if (this.config.verbose) {
237
- console.warn(`[ReplicatorPlugin] Replicator initialization failed: ${initError.message}`);
229
+ // Create replicator log resource if enabled
230
+ if (this.config.persistReplicatorLog) {
231
+ const [ok, err, logResource] = await tryFn(() => database.createResource({
232
+ name: this.config.replicatorLogResource || 'replicator_logs',
233
+ attributes: {
234
+ id: 'string|required',
235
+ resource: 'string|required',
236
+ action: 'string|required',
237
+ data: 'json',
238
+ timestamp: 'number|required',
239
+ createdAt: 'string|required'
240
+ },
241
+ behavior: 'truncate-data'
242
+ }));
243
+
244
+ if (ok) {
245
+ this.replicatorLogResource = logResource;
246
+ } else {
247
+ this.replicatorLogResource = database.resources[this.config.replicatorLogResource || 'replicator_logs'];
238
248
  }
239
- this.emit('error', { operation: 'setup', error: initError.message });
240
- throw initError;
241
249
  }
242
250
 
243
- const [logOk, logError] = await tryFn(async () => {
244
- if (this.config.replicatorLogResource) {
245
- const logRes = await database.createResource({
246
- name: this.config.replicatorLogResource,
247
- behavior: 'body-overflow',
248
- attributes: {
249
- operation: 'string',
250
- resourceName: 'string',
251
- recordId: 'string',
252
- data: 'string',
253
- error: 'string|optional',
254
- replicator: 'string',
255
- timestamp: 'string',
256
- status: 'string'
257
- }
258
- });
259
- }
260
- });
251
+ // Initialize replicators
252
+ await this.initializeReplicators(database);
261
253
 
262
- if (!logOk) {
263
- if (this.config.verbose) {
264
- console.warn(`[ReplicatorPlugin] Failed to create log resource ${this.config.replicatorLogResource}: ${logError.message}`);
254
+ // Use database hooks for automatic resource discovery
255
+ this.installDatabaseHooks();
256
+
257
+ // Install event listeners for existing resources
258
+ for (const resource of Object.values(database.resources)) {
259
+ if (resource.name !== (this.config.replicatorLogResource || 'replicator_logs')) {
260
+ this.installEventListeners(resource, database, this);
265
261
  }
266
- this.emit('replicator_log_resource_creation_error', {
267
- resourceName: this.config.replicatorLogResource,
268
- error: logError.message
269
- });
270
262
  }
263
+ }
271
264
 
272
- await this.uploadMetadataFile(database);
265
+ async start() {
266
+ // Plugin is ready
267
+ }
273
268
 
274
- const originalCreateResource = database.createResource.bind(database);
275
- database.createResource = async (config) => {
276
- const resource = await originalCreateResource(config);
277
- if (resource) {
278
- this.installEventListeners(resource, database, this);
269
+ async stop() {
270
+ // Stop all replicators
271
+ for (const replicator of this.replicators || []) {
272
+ if (replicator && typeof replicator.cleanup === 'function') {
273
+ await replicator.cleanup();
279
274
  }
280
- return resource;
281
- };
282
-
283
- for (const resourceName in database.resources) {
284
- const resource = database.resources[resourceName];
285
- this.installEventListeners(resource, database, this);
286
275
  }
276
+
277
+ // Remove database hooks
278
+ this.removeDatabaseHooks();
279
+ }
280
+
281
+ installDatabaseHooks() {
282
+ // Use the new database hooks system for automatic resource discovery
283
+ this.database.addHook('afterCreateResource', (resource) => {
284
+ if (resource.name !== (this.config.replicatorLogResource || 'replicator_logs')) {
285
+ this.installEventListeners(resource, this.database, this);
286
+ }
287
+ });
288
+ }
289
+
290
+ removeDatabaseHooks() {
291
+ // Remove the hook we added
292
+ this.database.removeHook('afterCreateResource', this.installEventListeners.bind(this));
287
293
  }
288
294
 
289
295
  createReplicator(driver, config, resources, client) {
@@ -309,17 +315,6 @@ export class ReplicatorPlugin extends Plugin {
309
315
  }
310
316
  }
311
317
 
312
- async start() {
313
- // Plugin is ready
314
- }
315
-
316
- async stop() {
317
- // Stop queue processing
318
- // this.isProcessing = false; // Removed as per edit hint
319
- // Process remaining queue items
320
- // await this.processQueue(); // Removed as per edit hint
321
- }
322
-
323
318
  async uploadMetadataFile(database) {
324
319
  if (typeof database.uploadMetadataFile === 'function') {
325
320
  await database.uploadMetadataFile();
@@ -2484,10 +2484,11 @@ export class Resource extends EventEmitter {
2484
2484
  _initMiddleware() {
2485
2485
  // Map of methodName -> array of middleware functions
2486
2486
  this._middlewares = new Map();
2487
- // Supported methods for middleware
2487
+ // Supported methods for middleware (expanded to include newly cached methods)
2488
2488
  this._middlewareMethods = [
2489
2489
  'get', 'list', 'listIds', 'getAll', 'count', 'page',
2490
- 'insert', 'update', 'delete', 'deleteMany', 'exists', 'getMany'
2490
+ 'insert', 'update', 'delete', 'deleteMany', 'exists', 'getMany',
2491
+ 'content', 'hasContent', 'query', 'getFromPartition', 'setContent', 'deleteContent', 'replace'
2491
2492
  ];
2492
2493
  for (const method of this._middlewareMethods) {
2493
2494
  this._middlewares.set(method, []);
package/src/s3db.d.ts CHANGED
@@ -6,6 +6,15 @@ declare module 's3db.js' {
6
6
  // CORE TYPES
7
7
  // ============================================================================
8
8
 
9
+ /** HTTP Client configuration for keep-alive and connection pooling */
10
+ export interface HttpClientOptions {
11
+ keepAlive?: boolean;
12
+ keepAliveMsecs?: number;
13
+ maxSockets?: number;
14
+ maxFreeSockets?: number;
15
+ timeout?: number;
16
+ }
17
+
9
18
  /** Main Database configuration */
10
19
  export interface DatabaseConfig {
11
20
  connectionString?: string;
@@ -23,6 +32,7 @@ declare module 's3db.js' {
23
32
  cache?: CacheConfig | boolean;
24
33
  plugins?: (PluginInterface | PluginFunction)[];
25
34
  client?: Client;
35
+ httpClientOptions?: HttpClientOptions;
26
36
  }
27
37
 
28
38
  /** Resource configuration */