cds-caching 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -37,10 +37,36 @@ Please also read the introduction blog post in the SAP Community: [Boosting perf
37
37
  > - [Metrics Guide](docs/metrics-guide.md)
38
38
  > - [OData API Reference](docs/odata-api.md)
39
39
 
40
- ## 🚨 Breaking Changes: Migrating from cds-caching 0.x
40
+ ## 🚨 Breaking Changes: Migrating cds-caching
41
41
 
42
42
  > **⚠️ Important:** Version 1.x contains breaking changes. Please review the migration guide below.
43
43
 
44
+ ## Upgrading from 1.1.0 to 1.2.0
45
+
46
+ From **1.2.0** onwards, storage/compression adapters are treated as **optional peer dependencies** and must be installed **explicitly in your consuming CAP project** (i.e. *your app*, not `cds-caching`). This avoids relying on transitive dependencies and makes adapter usage deterministic.
47
+
48
+ ### Required adapter packages (add to your app's `package.json`)
49
+
50
+ Install the package(s) matching your configured `store` / `compression`:
51
+
52
+ | Config | Value | Install in your app |
53
+ |---|---|---|
54
+ | `store` | `"redis"` | `@keyv/redis` |
55
+ | `store` | `"sqlite"` | `@resolid/keyv-sqlite` (recommended) **or** `@keyv/sqlite` |
56
+ | `store` | `"postgres"` | `@keyv/postgres` |
57
+ | `compression` | `"lz4"` | `@keyv/compress-lz4` |
58
+ | `compression` | `"gzip"` | `@keyv/compress-gzip` |
59
+
60
+ Example:
61
+
62
+ ```bash
63
+ npm i @keyv/redis
64
+ # or: npm i @resolid/keyv-sqlite
65
+ # and optionally: npm i @keyv/compress-gzip
66
+ ```
67
+
68
+ ## Upgrading from 0.x to 1.x
69
+
44
70
  ### 🔄 API Changes for read-through methods
45
71
 
46
72
  Version 1.x introduces new methods that provide more insights into the read-through caching as they also directly return the genrated cache `key` and some caching `metadata`. The should be preferrably used instead of the old methods.
@@ -159,6 +185,25 @@ Installing and using cds-caching is straightforward since it's a CAP plugin. Sim
159
185
  npm install cds-caching
160
186
  ```
161
187
 
188
+ #### Adapter packages (Redis / SQLite / Compression)
189
+
190
+ `cds-caching` only ships with the in-memory store. If you configure a different store or compression, you must install the corresponding adapter package **in your consuming CAP project**:
191
+
192
+ ```bash
193
+ # Redis store
194
+ npm install @keyv/redis
195
+
196
+ # SQLite store
197
+ npm install @resolid/keyv-sqlite # Preferred in CAP because of better-sqlite3 usage
198
+ npm install @keyv/sqlite # Alternative if you want to rely on the official adapter
199
+
200
+ # Compression
201
+ npm install @keyv/compress-lz4 # for "lz4"
202
+ npm install @keyv/compress-gzip # for "gzip"
203
+ ```
204
+
205
+ If you configure an adapter but don’t have its package installed, `cds-caching` will fail fast with a clear error telling you what to install.
206
+
162
207
  ### Configuration
163
208
 
164
209
  The cds-caching plugin supports comprehensive configuration through `package.json`. Here are all available configuration options:
@@ -197,6 +242,7 @@ The cds-caching plugin supports comprehensive configuration through `package.jso
197
242
  "store": "in-memory", // "in-memory", "sqlite", or "redis"
198
243
  "compression": "lz4", // "lz4" or "gzip"
199
244
  "throwOnErrors": false, // Whether basic operations should throw errors (default: false)
245
+ "transactionalOperations": false, // When true, basic ops run in a dedicated cache tx (cache.tx())
200
246
  "credentials": {
201
247
  // Redis configuration
202
248
  "host": "localhost",
@@ -215,6 +261,27 @@ The cds-caching plugin supports comprehensive configuration through `package.jso
215
261
  }
216
262
  ```
217
263
 
264
+ #### Transaction isolation for basic operations (`transactionalOperations`)
265
+
266
+ CAP can run multiple `before` handlers concurrently. If one handler fails and rolls back the request transaction, other concurrent handlers may still be running and can fail when they access the cache (typical error: “Transaction is rolled back, no subsequent .run allowed…”).
267
+
268
+ To isolate **basic cache operations** (`get`, `set`, `delete`, `clear`, `deleteByTag`, `metadata`, `tags`, `getRaw`) from the request transaction, enable:
269
+
270
+ ```json
271
+ {
272
+ "cds": {
273
+ "requires": {
274
+ "caching": {
275
+ "impl": "cds-caching",
276
+ "transactionalOperations": true
277
+ }
278
+ }
279
+ }
280
+ }
281
+ ```
282
+
283
+ In the background, the caching service opens a dedicated cache transaction via `cache.tx()`, executes the operation via `tx.send(...)`, and commits/rolls back the cache transaction per operation. This keeps cache calls working even if the surrounding request transaction is already rolled back.
284
+
218
285
  #### Read-Through (RT) Key Configuration
219
286
 
220
287
  Configure default key templates for read-through operations:
@@ -323,12 +390,14 @@ cds-caching provides 3 storage options:
323
390
  - Memory on SAP BTP Cloud Foundry is limited (up to 16 GB) and produces costs
324
391
 
325
392
  ##### SQLite (for medium-size use uses)
393
+ - Requires installing the adapter package: `@keyv/sqlite` (in your project)
326
394
  - Data is stored in local SQLite database
327
395
  - Data is persited next to SAP BTP application with disk-quota up to 10 GB
328
396
  - Cache will be removed after each deployment to SAP BTP
329
397
  - No distributed cache between application instances (horizontal scaling)
330
398
 
331
399
  ##### Redis Cache (recommended for production)
400
+ - Requires installing the adapter package: `@keyv/redis` (in your project)
332
401
  - Persistent and supports distributed caching
333
402
  - Works across multiple app instances, making it ideal for scalable applications
334
403
  - Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud)
@@ -124,6 +124,12 @@ export declare class CachingService extends Service {
124
124
  compression: any;
125
125
  credentials: Record<string, any>;
126
126
  namespace?: string;
127
+ throwOnErrors?: boolean;
128
+ /**
129
+ * When enabled, basic operations (`get`, `set`, `delete`, ...) run in a dedicated cache transaction.
130
+ * This isolates cache access from the caller's request transaction (useful for concurrent BEFORE handlers).
131
+ */
132
+ transactionalOperations?: boolean;
127
133
  };
128
134
 
129
135
  private cacheAnnotatedFunctions: {
@@ -146,52 +152,52 @@ export declare class CachingService extends Service {
146
152
  /**
147
153
  * Set a value in the cache
148
154
  */
149
- set(key: string | object, value: any, options?: CacheOptions): Promise<void>;
155
+ set(key: string | object, value: any, options?: CacheOptions, tx?: any): Promise<void>;
150
156
 
151
157
  /**
152
158
  * Get a value from the cache
153
159
  */
154
- get(key: string | object): Promise<any>;
160
+ get(key: string | object, tx?: any): Promise<any>;
155
161
 
156
162
  /**
157
163
  * Check if a key exists in the cache
158
164
  */
159
- has(key: string | object): Promise<boolean>;
165
+ has(key: string | object, tx?: any): Promise<boolean>;
160
166
 
161
167
  /**
162
168
  * Delete a key from the cache
163
169
  */
164
- delete(key: string | object): Promise<boolean>;
170
+ delete(key: string | object, tx?: any): Promise<boolean>;
165
171
 
166
172
  /**
167
173
  * Clear all cache entries
168
174
  */
169
- clear(): Promise<void>;
175
+ clear(tx?: any): Promise<void>;
170
176
 
171
177
  /**
172
178
  * Delete all keys that have a specific tag
173
179
  */
174
- deleteByTag(tag: string): Promise<void>;
180
+ deleteByTag(tag: string, tx?: any): Promise<void>;
175
181
 
176
182
  /**
177
183
  * Get metadata for a key
178
184
  */
179
- metadata(key: string | object): Promise<CacheMetadata | null>;
185
+ metadata(key: string | object, tx?: any): Promise<CacheMetadata | null>;
180
186
 
181
187
  /**
182
188
  * Get tags for a key
183
189
  */
184
- tags(key: string | object): Promise<string[]>;
190
+ tags(key: string | object, tx?: any): Promise<string[]>;
185
191
 
186
192
  /**
187
193
  * Iterator for all cache entries
188
194
  */
189
- iterator(): AsyncIterableIterator<[string, CacheMetadata]>;
195
+ iterator(tx?: any): AsyncIterableIterator<[string, CacheMetadata]>;
190
196
 
191
197
  /**
192
198
  * Get a raw value from the cache without statistics tracking
193
199
  */
194
- getRaw(key: string | object): Promise<any>;
200
+ getRaw(key: string | object, tx?: any): Promise<any>;
195
201
 
196
202
  // ============================================================================
197
203
  // Deprecated CAP Operations (for backward compatibility)
@@ -13,13 +13,19 @@ class CachingService extends cds.Service {
13
13
  async init() {
14
14
  super.init()
15
15
  this.log = cds.log('cds-caching')
16
- this.options = this.options || {
16
+ this.options = {
17
17
  store: null,
18
18
  compression: null,
19
19
  credentials: {},
20
20
  namespace: null,
21
21
  throwOnErrors: false,
22
+ // When enabled, basic cache operations (`get`, `set`, `delete`, ...)
23
+ // will be executed in a dedicated cache transaction (`cache.tx()`),
24
+ // isolating them from the caller's request transaction (e.g. concurrent BEFORE handlers).
25
+ transactionalOperations: false,
26
+ ...(this.options || {})
22
27
  };
28
+ this.options.credentials = this.options.credentials || {};
23
29
 
24
30
  // Initialize managers
25
31
  this.storeManager = new CacheStoreManager();
@@ -130,15 +136,61 @@ class CachingService extends cds.Service {
130
136
 
131
137
  createKey(...args) { return this.keyManager.createKey(...args); }
132
138
 
133
- async set(...args) { return this.basicOperations.set(...args); }
134
- async get(...args) { return this.basicOperations.get(...args); }
135
- async has(...args) { return this.basicOperations.has(...args); }
136
- async delete(...args) { return this.basicOperations.delete(...args); }
137
- async clear(...args) { return this.basicOperations.clear(...args); }
138
- async deleteByTag(...args) { return this.basicOperations.deleteByTag(...args); }
139
- async metadata(...args) { return this.basicOperations.metadata(...args); }
140
- async tags(...args) { return this.basicOperations.tags(...args); }
141
- async *iterator(...args) { return yield* this.basicOperations.iterator(...args); }
139
+ async set(key, value, options = {}, tx = null) {
140
+ if (tx) return this.basicOperations.set(key, value, options, tx);
141
+ if (this.options.transactionalOperations) return this.basicOperations.setInTx(key, value, options);
142
+ return this.basicOperations.set(key, value, options);
143
+ }
144
+
145
+ async get(key, tx = null) {
146
+ if (tx) return this.basicOperations.get(key, tx);
147
+ if (this.options.transactionalOperations) return this.basicOperations.getInTx(key);
148
+ return this.basicOperations.get(key);
149
+ }
150
+
151
+ async has(key, tx = null) {
152
+ // `has()` bypasses CAP tx handling in BasicOperations, so the `tx` argument is ignored.
153
+ // It is accepted here for API symmetry.
154
+ return this.basicOperations.has(key, tx);
155
+ }
156
+
157
+ async delete(key, tx = null) {
158
+ if (tx) return this.basicOperations.delete(key, tx);
159
+ if (this.options.transactionalOperations) return this.basicOperations.deleteInTx(key);
160
+ return this.basicOperations.delete(key);
161
+ }
162
+
163
+ async clear(tx = null) {
164
+ if (tx) return this.basicOperations.clear(tx);
165
+ if (this.options.transactionalOperations) return this.basicOperations.clearInTx();
166
+ return this.basicOperations.clear();
167
+ }
168
+
169
+ async deleteByTag(tag, tx = null) {
170
+ if (tx) return this.basicOperations.deleteByTag(tag, tx);
171
+ if (this.options.transactionalOperations) return this.basicOperations.deleteByTagInTx(tag);
172
+ return this.basicOperations.deleteByTag(tag);
173
+ }
174
+
175
+ async metadata(key, tx = null) {
176
+ if (tx) return this.basicOperations.metadata(key, tx);
177
+ if (this.options.transactionalOperations) return this.basicOperations.metadataInTx(key);
178
+ return this.basicOperations.metadata(key);
179
+ }
180
+
181
+ async tags(key, tx = null) {
182
+ if (tx) return this.basicOperations.tags(key, tx);
183
+ if (this.options.transactionalOperations) return this.basicOperations.tagsInTx(key);
184
+ return this.basicOperations.tags(key);
185
+ }
186
+
187
+ async getRaw(key, tx = null) {
188
+ if (tx) return this.basicOperations.getRaw(key, tx);
189
+ if (this.options.transactionalOperations) return this.basicOperations.getRawInTx(key);
190
+ return this.basicOperations.getRaw(key);
191
+ }
192
+
193
+ async *iterator(tx = null) { return yield* this.basicOperations.iterator(tx); }
142
194
 
143
195
  // ============================================================================
144
196
  // PUBLIC API - Read Through Operations
@@ -9,6 +9,16 @@ class BasicOperations {
9
9
  this.statistics = statistics;
10
10
  }
11
11
 
12
+ _keyv() {
13
+ // The underlying Keyv instance is stored on the CachingService as `this.cache`
14
+ // (see `CachingService.init()` where `this.cache = new Keyv(...)`).
15
+ const keyv = this.cache?.cache;
16
+ if (!keyv) {
17
+ throw new Error('cds-caching: Keyv store not initialized on caching service');
18
+ }
19
+ return keyv;
20
+ }
21
+
12
22
  /**
13
23
  * Set a value in the cache
14
24
  * @param {string|object} key - the key to set
@@ -16,14 +26,17 @@ class BasicOperations {
16
26
  * @param {object} options - cache options
17
27
  * @returns {Promise<void>}
18
28
  */
19
- async set(key, value, options = {}) {
29
+ async set(key, value, options = {}, tx = null) {
20
30
  const wrappedValue = {
21
31
  value,
22
32
  tags: this.tagResolver.resolveTags(options.tags, value, options.params) || [],
23
33
  timestamp: Date.now()
24
34
  };
25
35
  const createdKey = this.keyManager.createKey(key, {}, options.key);
26
- await this.cache.send('SET', {
36
+ // If no key was created, the input should not be cached (e.g. non-SELECT CQN)
37
+ if (!createdKey) return;
38
+ const srv = tx || this.cache;
39
+ await srv.send('SET', {
27
40
  key: createdKey,
28
41
  value: wrappedValue,
29
42
  ttl: options.ttl || 0
@@ -44,9 +57,16 @@ class BasicOperations {
44
57
  * @param {string|object} key - the key to get
45
58
  * @returns {Promise<any>} - the cached value
46
59
  */
47
- async get(key) {
60
+ async get(key, tx = null) {
48
61
  const createdKey = this.keyManager.createKey(key);
49
- const wrappedValue = await this.cache.send('GET', {
62
+
63
+ // If not key was created, return undefined
64
+ if(!createdKey) {
65
+ return undefined;
66
+ }
67
+
68
+ const srv = tx || this.cache;
69
+ const wrappedValue = await srv.send('GET', {
50
70
  key: createdKey
51
71
  });
52
72
 
@@ -68,10 +88,14 @@ class BasicOperations {
68
88
  * @param {string|object} key - the key to check
69
89
  * @returns {Promise<boolean>} - whether the key exists
70
90
  */
71
- async has(key) {
91
+ async has(key, tx = null) {
72
92
  const createdKey = this.keyManager.createKey(key);
93
+ if (!createdKey) return false;
73
94
  try {
74
- return await this.cache.cache.has(createdKey);
95
+ // Intentionally bypass CAP service/transaction handling here.
96
+ // This avoids coupling to the caller's request tx (which might already be rolled back)
97
+ // and makes `has()` safe to call even in concurrent BEFORE handlers.
98
+ return await this._keyv().has(createdKey);
75
99
  } catch (error) {
76
100
  if (this.cache.options.throwOnErrors) {
77
101
  throw error;
@@ -86,9 +110,11 @@ class BasicOperations {
86
110
  * @param {string|object} key - the key to delete
87
111
  * @returns {Promise<boolean>} - whether the key was deleted
88
112
  */
89
- async delete(key) {
113
+ async delete(key, tx = null) {
90
114
  const createdKey = this.keyManager.createKey(key);
91
- const result = await this.cache.send('DELETE', {
115
+ if (!createdKey) return false;
116
+ const srv = tx || this.cache;
117
+ const result = await srv.send('DELETE', {
92
118
  key: createdKey
93
119
  });
94
120
 
@@ -108,8 +134,9 @@ class BasicOperations {
108
134
  * Clear all cache entries
109
135
  * @returns {Promise<void>}
110
136
  */
111
- async clear() {
112
- await this.cache.send('CLEAR', {
137
+ async clear(tx = null) {
138
+ const srv = tx || this.cache;
139
+ await srv.send('CLEAR', {
113
140
  deleteAll: true
114
141
  });
115
142
 
@@ -129,10 +156,11 @@ class BasicOperations {
129
156
  * @param {string} tag - the tag to match
130
157
  * @returns {Promise<void>}
131
158
  */
132
- async deleteByTag(tag) {
159
+ async deleteByTag(tag, tx = null) {
160
+ // Iterate directly on the underlying store to avoid recursion via CachingService.iterator()
133
161
  for await (const [key, wrappedValue] of this.iterator()) {
134
162
  if (wrappedValue?.tags?.includes(tag)) {
135
- await this.delete(key);
163
+ await this.delete(key, tx);
136
164
  }
137
165
  }
138
166
 
@@ -152,9 +180,11 @@ class BasicOperations {
152
180
  * @param {string|object} key - the key to get metadata for
153
181
  * @returns {Promise<object|null>} - the metadata or null if not found
154
182
  */
155
- async metadata(key) {
183
+ async metadata(key, tx = null) {
156
184
  const createdKey = this.keyManager.createKey(key);
157
- const wrappedValue = await this.cache.send('GET', {
185
+ if (!createdKey) return null;
186
+ const srv = tx || this.cache;
187
+ const wrappedValue = await srv.send('GET', {
158
188
  key: createdKey
159
189
  });
160
190
  if (!wrappedValue) return null;
@@ -168,9 +198,11 @@ class BasicOperations {
168
198
  * @param {string|object} key - the key to get tags for
169
199
  * @returns {Promise<string[]>} - the tags
170
200
  */
171
- async tags(key) {
201
+ async tags(key, tx = null) {
172
202
  const createdKey = this.keyManager.createKey(key);
173
- const wrappedValue = await this.cache.send('GET', {
203
+ if (!createdKey) return [];
204
+ const srv = tx || this.cache;
205
+ const wrappedValue = await srv.send('GET', {
174
206
  key: createdKey
175
207
  });
176
208
  return wrappedValue?.tags || [];
@@ -181,9 +213,11 @@ class BasicOperations {
181
213
  * @param {string|object} key - the key to get
182
214
  * @returns {Promise<any>} - the raw cached value
183
215
  */
184
- async getRaw(key) {
216
+ async getRaw(key, tx = null) {
185
217
  const createdKey = this.keyManager.createKey(key);
186
- const wrappedValue = await this.cache.send('GET', {
218
+ if (!createdKey) return undefined;
219
+ const srv = tx || this.cache;
220
+ const wrappedValue = await srv.send('GET', {
187
221
  key: createdKey
188
222
  });
189
223
  return wrappedValue?.value;
@@ -193,8 +227,9 @@ class BasicOperations {
193
227
  * Iterator for all cache entries
194
228
  * @returns {AsyncIterator} - iterator for cache entries
195
229
  */
196
- async *iterator() {
197
- for await (const [key, value] of this.cache.cache.iterator()) {
230
+ async *iterator(tx = null) {
231
+ // Iterate directly on Keyv to avoid recursion via CachingService.iterator()
232
+ for await (const [key, value] of this._keyv().iterator()) {
198
233
  if (typeof value === "string") {
199
234
  try {
200
235
  yield [key, JSON.parse(value)];
@@ -206,6 +241,107 @@ class BasicOperations {
206
241
  }
207
242
  }
208
243
  }
244
+
245
+ async setInTx(key, value, options = {}) {
246
+ const tx = await this.cache.tx();
247
+ try {
248
+ await this.set(key, value, options, tx);
249
+ await tx.commit();
250
+ return;
251
+ } catch (error) {
252
+ await tx.rollback();
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ async getInTx(key) {
258
+ const tx = await this.cache.tx();
259
+ try {
260
+ const value = await this.get(key, tx);
261
+ await tx.commit();
262
+ return value;
263
+ } catch (error) {
264
+ await tx.rollback();
265
+ throw error;
266
+ }
267
+ }
268
+
269
+ async hasInTx(key) {
270
+ // `has()` bypasses CAP tx handling on purpose, so opening a new transaction is unnecessary.
271
+ return this.has(key);
272
+ }
273
+
274
+ async deleteInTx(key) {
275
+ const tx = await this.cache.tx();
276
+ try {
277
+ const value = await this.delete(key, tx);
278
+ await tx.commit();
279
+ return value;
280
+ } catch (error) {
281
+ await tx.rollback();
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ async clearInTx() {
287
+ const tx = await this.cache.tx();
288
+ try {
289
+ await this.clear(tx);
290
+ await tx.commit();
291
+ return;
292
+ } catch (error) {
293
+ await tx.rollback();
294
+ throw error;
295
+ }
296
+ }
297
+
298
+ async deleteByTagInTx(tag) {
299
+ const tx = await this.cache.tx();
300
+ try {
301
+ await this.deleteByTag(tag, tx);
302
+ await tx.commit();
303
+ return;
304
+ } catch (error) {
305
+ await tx.rollback();
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ async metadataInTx(key) {
311
+ const tx = await this.cache.tx();
312
+ try {
313
+ const value = await this.metadata(key, tx);
314
+ await tx.commit();
315
+ return value;
316
+ } catch (error) {
317
+ await tx.rollback();
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ async tagsInTx(key) {
323
+ const tx = await this.cache.tx();
324
+ try {
325
+ const value = await this.tags(key, tx);
326
+ await tx.commit();
327
+ return value;
328
+ } catch (error) {
329
+ await tx.rollback();
330
+ throw error;
331
+ }
332
+ }
333
+
334
+ async getRawInTx(key) {
335
+ const tx = await this.cache.tx();
336
+ try {
337
+ const value = await this.getRaw(key, tx);
338
+ await tx.commit();
339
+ return value;
340
+ } catch (error) {
341
+ await tx.rollback();
342
+ throw error;
343
+ }
344
+ }
209
345
  }
210
346
 
211
347
  module.exports = BasicOperations;
@@ -28,7 +28,7 @@ class CapOperations {
28
28
  async safeCacheOperation(operation, operationName, context = {}) {
29
29
  try {
30
30
  const result = await operation();
31
- this.log.info('REEEESULT', { result });
31
+ this.log.info('RESULT', { result, operationName, context });
32
32
  return { success: true, result, error: null };
33
33
  } catch (error) {
34
34
  this.log.warn(`Cache ${operationName} failed:`, {
@@ -500,8 +500,6 @@ class CapOperations {
500
500
  */
501
501
  extractMetadataFromRequest(req) {
502
502
 
503
- console.log(JSON.stringify(req.http?.req, null, 2));
504
-
505
503
  const metadata = {
506
504
  dataType: req.constructor.name,
507
505
  serviceName: req.target?.name || '',
@@ -1,8 +1,5 @@
1
1
  const { Keyv } = require('keyv');
2
- const { default: KeyvRedis } = require('@keyv/redis');
3
- const { default: KeyvSqlite } = require('@keyv/sqlite');
4
- const { default: KeyvLz4 } = require('@keyv/compress-lz4');
5
- const { default: KeyvGzip } = require('@keyv/compress-gzip');
2
+ const { requireOptional, requireAnyOptional } = require('./optionalRequire');
6
3
 
7
4
  /**
8
5
  * Manages cache store initialization and configuration
@@ -21,7 +18,7 @@ class CacheStoreManager {
21
18
  */
22
19
  createStore(options, cacheName) {
23
20
  const store = this._createStoreInstance(options);
24
- const cacheOptions = this._createCacheOptions(options, cacheName);
21
+ const cacheOptions = this._createCacheOptions(options, cacheName, store);
25
22
  const cache = new Keyv(cacheOptions);
26
23
  cache.throwOnErrors = options.throwOnErrors;
27
24
 
@@ -43,31 +40,47 @@ class CacheStoreManager {
43
40
  _createStoreInstance(options) {
44
41
  switch (options.store) {
45
42
  case "sqlite":
43
+ // Support both adapters:
44
+ // - @keyv/sqlite (default export)
45
+ // - @resolid/keyv-sqlite (named export KeyvSqlite) https://github.com/huijiewei/keyv-sqlite
46
+ const sqliteMod = requireAnyOptional(['@keyv/sqlite', '@resolid/keyv-sqlite'], { feature: 'store', value: 'sqlite' });
47
+ const KeyvSqlite = sqliteMod?.KeyvSqlite ?? sqliteMod?.default ?? sqliteMod;
46
48
  return new KeyvSqlite({
47
49
  url: options.credentials?.url,
48
50
  table: options.credentials?.table || 'cache',
49
51
  busyTimeout: options.credentials?.busyTimeout || 10000
50
52
  });
51
53
  case "redis":
54
+ const KeyvRedis = requireOptional('@keyv/redis', { feature: 'store', value: 'redis' });
52
55
  const store = new KeyvRedis({
53
56
  ...options.credentials,
54
57
  ...(options.credentials?.uri ? { url: options.credentials?.uri } : {}),
55
58
  ...{ throwOnConnectErrors: options.throwOnErrors, useKeyPrefix: false, throwOnErrors: options.throwOnErrors }
56
59
  });
57
60
 
61
+ // see https://keyv.org/docs/storage-adapters/redis/#gracefully-handling-errors-and-timeouts for more details
58
62
  if (options.throwOnErrors) {
59
63
  store.throwOnConnectErrors = false; // We want to handle the errors ourselves
60
- store.throwOnErrors = true;
64
+ store.throwOnErrors = true; // Redis will throw errors for connection issues, etc.
61
65
  const redisClient = store.client;
62
66
  if (redisClient.options) {
63
67
  redisClient.options.disableOfflineQueue = true;
64
68
  if (redisClient.options.socket) {
65
- redisClient.options.socket.reconnectStrategy = false;
69
+ redisClient.options.socket.reconnectStrategy = false; // Disable automatic reconnection
66
70
  }
67
71
  }
68
72
  }
69
73
 
70
74
  return store;
75
+ case "postgres":
76
+ const KeyvPostgres = requireOptional('@keyv/postgres', { feature: 'store', value: 'postgres' });
77
+ return new KeyvPostgres({
78
+ ...options.credentials,
79
+ ...(options.credentials?.url ? { uri: options.credentials?.url } : options.credentials?.uri ? { url: options.credentials?.uri } : {}),
80
+ ...(options.credentials?.schema ? { schema: options.credentials?.schema } : {}),
81
+ ...(options.credentials?.table ? { table: options.credentials?.table } : {}),
82
+ ...{ throwOnConnectErrors: options.throwOnErrors, useKeyPrefix: false, throwOnErrors: options.throwOnErrors }
83
+ });
71
84
  default:
72
85
  return new Map();
73
86
  }
@@ -77,11 +90,12 @@ class CacheStoreManager {
77
90
  * Create cache options object
78
91
  * @private
79
92
  */
80
- _createCacheOptions(options, cacheName) {
93
+ _createCacheOptions(options, cacheName, store) {
81
94
  return {
82
95
  namespace: options.namespace || cacheName,
83
- store: this._createStoreInstance(options),
84
- compression: this._createCompression(options.compression)
96
+ store,
97
+ compression: this._createCompression(options.compression),
98
+ useKeyPrefix: store.constructor.name === 'KeyvRedis' ? false : true // see https://github.com/mikezaschka/cds-caching/issues/11
85
99
  };
86
100
  }
87
101
 
@@ -92,8 +106,10 @@ class CacheStoreManager {
92
106
  _createCompression(compressionType) {
93
107
  switch (compressionType) {
94
108
  case "lz4":
109
+ const KeyvLz4 = requireOptional('@keyv/compress-lz4', { feature: 'compression', value: 'lz4' });
95
110
  return new KeyvLz4();
96
111
  case "gzip":
112
+ const KeyvGzip = requireOptional('@keyv/compress-gzip', { feature: 'compression', value: 'gzip' });
97
113
  return new KeyvGzip();
98
114
  default:
99
115
  return undefined;
@@ -0,0 +1,45 @@
1
+ function isMissingModuleError(err, packageName) {
2
+ const msg = String(err?.message || '');
3
+ return (
4
+ (err?.code === 'MODULE_NOT_FOUND' || err?.code === 'ERR_MODULE_NOT_FOUND') &&
5
+ (msg.includes(`'${packageName}'`) || msg.includes(`"${packageName}"`) || msg.includes(packageName))
6
+ );
7
+ }
8
+
9
+ function requireOptional(packageName, { feature, value } = {}) {
10
+ try {
11
+ const mod = require(packageName);
12
+ return mod?.default ?? mod;
13
+ } catch (err) {
14
+ if (!isMissingModuleError(err, packageName)) throw err;
15
+
16
+ const featureText = feature ? `${feature}="${value}" ` : '';
17
+ throw new Error(`cds-caching: ${featureText}requires installing "${packageName}" (npm i ${packageName})`);
18
+ }
19
+ }
20
+
21
+ function requireAnyOptional(packageNames, { feature, value } = {}) {
22
+ const missing = [];
23
+ for (const packageName of packageNames) {
24
+ try {
25
+ return require(packageName);
26
+ } catch (err) {
27
+ if (isMissingModuleError(err, packageName)) {
28
+ missing.push(packageName);
29
+ continue;
30
+ }
31
+ throw err;
32
+ }
33
+ }
34
+
35
+ const featureText = feature ? `${feature}="${value}" ` : '';
36
+ const pkgsQuoted = missing.map((p) => `"${p}"`).join(' or ');
37
+ const pkgsInstall = missing.map((p) => `npm i ${p}`).join(' OR ');
38
+ throw new Error(`cds-caching: ${featureText}requires installing ${pkgsQuoted} (${pkgsInstall})`);
39
+ }
40
+
41
+ module.exports = {
42
+ requireOptional,
43
+ requireAnyOptional
44
+ };
45
+
package/lib/util.js CHANGED
@@ -93,7 +93,7 @@ const bindEntity = async (service, entity) => {
93
93
  const createCacheEntry = async (cacheName, serviceConfig = {}) => {
94
94
  try {
95
95
  const db = await cds.connect.to('db');
96
- const { Caches } = db.entities('plugin.cds_caching');
96
+ const { Caches } = cds.entities('plugin.cds_caching');
97
97
 
98
98
  // Check if cache entry already exists
99
99
  const existingCache = await db.read(Caches).where({ name: cacheName });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cds-caching",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A caching plugin for SAP CAP applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,33 @@
34
34
  "release": "release-it"
35
35
  },
36
36
  "peerDependencies": {
37
- "@sap/cds": ">=8"
37
+ "@sap/cds": ">=8",
38
+ "@keyv/compress-gzip": "^2.0.3",
39
+ "@keyv/compress-lz4": "^1.0.1",
40
+ "@keyv/redis": "^5.0.0",
41
+ "@keyv/sqlite": "^4.0.5",
42
+ "@resolid/keyv-sqlite": "^4.0.5",
43
+ "@keyv/postgres": "^4.0.5"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@keyv/redis": {
47
+ "optional": true
48
+ },
49
+ "@keyv/sqlite": {
50
+ "optional": true
51
+ },
52
+ "@keyv/compress-lz4": {
53
+ "optional": true
54
+ },
55
+ "@keyv/compress-gzip": {
56
+ "optional": true
57
+ },
58
+ "@resolid/keyv-sqlite": {
59
+ "optional": true
60
+ },
61
+ "@keyv/postgres": {
62
+ "optional": true
63
+ }
38
64
  },
39
65
  "workspaces": [
40
66
  ".",
@@ -43,21 +69,22 @@
43
69
  "examples/app"
44
70
  ],
45
71
  "dependencies": {
46
- "@keyv/compress-gzip": "^2.0.3",
47
- "@keyv/compress-lz4": "^1.0.1",
48
- "@keyv/redis": "^5.0.0",
49
- "@keyv/sqlite": "^4.0.5",
50
- "keyv": "^5.4.0"
72
+ "keyv": "^5.6.0"
51
73
  },
52
74
  "devDependencies": {
53
- "@cap-js/cds-test": "^0.4.0",
54
- "@release-it/conventional-changelog": "^10.0.1",
55
- "eslint": "^9.32.0",
75
+ "@cap-js/cds-test": "^0.4.1",
76
+ "@release-it/conventional-changelog": "^10.0.5",
77
+ "eslint": "^10.0.0",
56
78
  "husky": "^9.1.7",
57
- "jest": "^30.0.5",
58
- "release-it": "^19.0.4"
79
+ "jest": "^30.2.0",
80
+ "release-it": "^19.2.4",
81
+ "@resolid/keyv-sqlite": "~5.0.1",
82
+ "@keyv/sqlite": "~4.0.8",
83
+ "@keyv/compress-lz4": "~1.0.1",
84
+ "@keyv/compress-gzip": "~2.0.3",
85
+ "@keyv/postgres": "~2.2.3"
59
86
  },
60
87
  "engines": {
61
- "node": ">=20"
88
+ "node": ">=24"
62
89
  }
63
90
  }