cds-caching 1.0.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
@@ -1,4 +1,6 @@
1
1
  # Welcome to cds-caching
2
+ [![npm version](https://img.shields.io/npm/v/cds-caching)](https://www.npmjs.com/package/cds-caching/common)
3
+ [![monthly downloads](https://img.shields.io/npm/dm/cds-caching)](https://www.npmjs.com/package/cds-caching)
2
4
 
3
5
  ## Overview
4
6
 
@@ -35,10 +37,36 @@ Please also read the introduction blog post in the SAP Community: [Boosting perf
35
37
  > - [Metrics Guide](docs/metrics-guide.md)
36
38
  > - [OData API Reference](docs/odata-api.md)
37
39
 
38
- ## 🚨 Breaking Changes: Migrating from cds-caching 0.x
40
+ ## 🚨 Breaking Changes: Migrating cds-caching
39
41
 
40
42
  > **⚠️ Important:** Version 1.x contains breaking changes. Please review the migration guide below.
41
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
+
42
70
  ### πŸ”„ API Changes for read-through methods
43
71
 
44
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.
@@ -136,44 +164,45 @@ await cache.set(data, value, {
136
164
 
137
165
  For detailed API documentation, see [Programmatic API Reference](docs/programmatic-api.md).
138
166
 
139
- ### Installation
167
+ ### Example Application
140
168
 
141
- Installing and using cds-caching is straightforward since it's a CAP plugin. Simply run:
169
+ The cds-caching plugin includes a comprehensive example application demonstrating various caching use cases and a UI5-based dashboard for monitoring cache performance.
142
170
 
143
- ```bash
144
- npm install cds-caching
145
- ```
171
+ ![Cache Dashboard](./docs/dashboard.jpg)
146
172
 
147
- ### TypeScript Support
173
+ The example consists of:
174
+ - **Backend Application** (`examples/app/`) - A CAP application showing annotation-based and programmatic caching patterns
175
+ - **Dashboard** (`examples/dashboard/`) - A UI5-based monitoring interface with real-time metrics, key-level analytics, and historical data
148
176
 
149
- cds-caching includes comprehensive TypeScript definitions. The library is written in JavaScript but provides full TypeScript support for better development experience.
177
+ [See the full Example Application Guide β†’](docs/example-app.md)
150
178
 
151
- #### Basic Usage with TypeScript
152
179
 
153
- ```typescript
154
- import { CachingService, CacheOptions, ReadThroughResult } from 'cds-caching';
180
+ ### Installation
155
181
 
156
- const cache = await cds.connect.to('caching') as CachingService;
182
+ Installing and using cds-caching is straightforward since it's a CAP plugin. Simply run:
157
183
 
158
- // Basic cache operations
159
- await cache.set('my-key', { data: 'value' }, { ttl: 3600 });
160
- const value = await cache.get('my-key');
184
+ ```bash
185
+ npm install cds-caching
186
+ ```
161
187
 
162
- // Read-through operations with full type safety
163
- const { result, cacheKey, metadata } = await cache.rt.send(request, service, {
164
- ttl: 1800,
165
- tags: ['user-data']
166
- });
188
+ #### Adapter packages (Redis / SQLite / Compression)
167
189
 
168
- // Function wrapping with type inference
169
- const cachedFunction = cache.rt.wrap('expensive-operation', async (id: string) => {
170
- return await this.performExpensiveOperation(id);
171
- });
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**:
172
191
 
173
- const { result: operationResult } = await cachedFunction('user-123');
192
+ ```bash
193
+ # Redis store
194
+ npm install @keyv/redis
174
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"
175
203
  ```
176
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.
177
206
 
178
207
  ### Configuration
179
208
 
@@ -212,6 +241,8 @@ The cds-caching plugin supports comprehensive configuration through `package.jso
212
241
  "namespace": "caching",
213
242
  "store": "in-memory", // "in-memory", "sqlite", or "redis"
214
243
  "compression": "lz4", // "lz4" or "gzip"
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())
215
246
  "credentials": {
216
247
  // Redis configuration
217
248
  "host": "localhost",
@@ -230,6 +261,27 @@ The cds-caching plugin supports comprehensive configuration through `package.jso
230
261
  }
231
262
  ```
232
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
+
233
285
  #### Read-Through (RT) Key Configuration
234
286
 
235
287
  Configure default key templates for read-through operations:
@@ -253,6 +305,36 @@ Configure default key templates for read-through operations:
253
305
 
254
306
  **Default behavior** (if not configured): All context elements are disabled by default.
255
307
 
308
+ #### Error Handling Configuration
309
+
310
+ Configure how the caching service handles errors:
311
+
312
+ ```json
313
+ {
314
+ "cds": {
315
+ "requires": {
316
+ "caching": {
317
+ ...
318
+ "throwOnErrors": true, // Basic operations (set, get, delete, has) throw errors
319
+ // Default: false - operations return undefined/null instead of throwing
320
+ }
321
+ }
322
+ }
323
+ }
324
+ ```
325
+
326
+ **Error Handling Behavior:**
327
+
328
+ - **Basic Operations** (`set`, `get`, `delete`, `has`):
329
+ - When `throwOnErrors: false` (default): Operations return `undefined`/`null` on errors
330
+ - When `throwOnErrors: true`: Operations throw errors for connection issues, etc.
331
+
332
+ - **Read-Through Operations** (`rt.run`, `rt.send`, `rt.wrap`, `rt.exec`):
333
+ - Never throw errors, regardless of `throwOnErrors` setting
334
+ - Include `cacheErrors` array in response when errors occur
335
+ - Always fetch from remote service when cache operations fail
336
+ - Log errors for monitoring and debugging
337
+
256
338
  #### Environment-Specific Configuration
257
339
 
258
340
  You can override settings for different environments:
@@ -308,16 +390,19 @@ cds-caching provides 3 storage options:
308
390
  - Memory on SAP BTP Cloud Foundry is limited (up to 16 GB) and produces costs
309
391
 
310
392
  ##### SQLite (for medium-size use uses)
393
+ - Requires installing the adapter package: `@keyv/sqlite` (in your project)
311
394
  - Data is stored in local SQLite database
312
395
  - Data is persited next to SAP BTP application with disk-quota up to 10 GB
313
396
  - Cache will be removed after each deployment to SAP BTP
314
397
  - No distributed cache between application instances (horizontal scaling)
315
398
 
316
399
  ##### Redis Cache (recommended for production)
400
+ - Requires installing the adapter package: `@keyv/redis` (in your project)
317
401
  - Persistent and supports distributed caching
318
402
  - Works across multiple app instances, making it ideal for scalable applications
319
403
  - Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud)
320
404
  - Even trial accounts provide Redis access
405
+ - Redis will be non-blocking
321
406
 
322
407
  #### Redis Development Setup
323
408
 
@@ -431,6 +516,31 @@ await cache.delete("bp:1000001")
431
516
  await cache.clear()
432
517
  ```
433
518
 
519
+ **Error Handling for Basic Operations:**
520
+
521
+ ```javascript
522
+ // With throwOnErrors: false (default)
523
+ try {
524
+ const value = await cache.get("bp:1000001")
525
+ if (value === undefined) {
526
+ // Handle cache miss or error
527
+ console.log("Value not found or cache error occurred")
528
+ }
529
+ } catch (error) {
530
+ // Only thrown for non-cache related errors
531
+ console.error("Unexpected error:", error)
532
+ }
533
+
534
+ // With throwOnErrors: true
535
+ try {
536
+ const value = await cache.get("bp:1000001")
537
+ // Value will be undefined if not found, but errors will be thrown
538
+ } catch (error) {
539
+ // Errors thrown for connection issues, etc.
540
+ console.error("Cache error:", error)
541
+ }
542
+ ```
543
+
434
544
  #### 2. CQN Query Caching
435
545
 
436
546
  For more advanced CAP integration, cache CAP's CQN queries directly. By passing in the query, a dynamic key is generated based on the CQN structure of the query. Note, that passing in queries with dynamic parameters (e.g. `SELECT.from(Foo).where({id: 1})`) will result in a different key for each query execution.
@@ -463,6 +573,23 @@ Because the cache key has been dynamically created at runtime, it will also be r
463
573
  const { result, cacheKey } = await cache.rt.run(query, db)
464
574
  ```
465
575
 
576
+ **Error Handling for Read-Through Operations:**
577
+
578
+ Read-through operations never throw errors, even when cache operations fail. Instead, they include error information in the response:
579
+
580
+ ```javascript
581
+ // Read-through operations always return a result, even on cache errors
582
+ const { result, cacheKey, metadata, cacheErrors } = await cache.rt.run(query, db)
583
+
584
+ if (cacheErrors && cacheErrors.length > 0) {
585
+ console.log("Cache errors occurred:", cacheErrors)
586
+ // Result will be fetched from remote service despite cache errors
587
+ }
588
+
589
+ // The result is always available, regardless of cache errors
590
+ return result
591
+ ```
592
+
466
593
  #### 3. RemoteService Request-Level Caching
467
594
 
468
595
  Cache entire CAP requests with context awareness (e.g. user, tenant, locale, etc.), which is useful for caching slow remote service calls or even application services. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale (if not configured otherwise).
@@ -770,6 +897,36 @@ for await (const entry of iterator) {
770
897
 
771
898
  This will return an iterator over all cache entries. You can use this to traverse all cache entries and invalidate them based on a specific condition. You should only use this for small caches (e.g. by using multiple caching services with different namespaces).
772
899
 
900
+ ### TypeScript Support
901
+
902
+ cds-caching includes comprehensive TypeScript definitions. The library is written in JavaScript but provides full TypeScript support for better development experience.
903
+
904
+ #### Basic Usage with TypeScript
905
+
906
+ ```typescript
907
+ import { CachingService, CacheOptions, ReadThroughResult } from 'cds-caching';
908
+
909
+ const cache = await cds.connect.to('caching') as CachingService;
910
+
911
+ // Basic cache operations
912
+ await cache.set('my-key', { data: 'value' }, { ttl: 3600 });
913
+ const value = await cache.get('my-key');
914
+
915
+ // Read-through operations with full type safety
916
+ const { result, cacheKey, metadata } = await cache.rt.send(request, service, {
917
+ ttl: 1800,
918
+ tags: ['user-data']
919
+ });
920
+
921
+ // Function wrapping with type inference
922
+ const cachedFunction = cache.rt.wrap('expensive-operation', async (id: string) => {
923
+ return await this.performExpensiveOperation(id);
924
+ });
925
+
926
+ const { result: operationResult } = await cachedFunction('user-123');
927
+
928
+ ```
929
+
773
930
  ### OData Service Caching Considerations
774
931
 
775
932
  While caching individual requests can improve performance, **caching an entire OData service is generally not recommended**. Here's why:
@@ -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,11 +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
- credentials: {}
19
+ credentials: {},
20
+ namespace: null,
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 || {})
20
27
  };
28
+ this.options.credentials = this.options.credentials || {};
21
29
 
22
30
  // Initialize managers
23
31
  this.storeManager = new CacheStoreManager();
@@ -128,15 +136,61 @@ class CachingService extends cds.Service {
128
136
 
129
137
  createKey(...args) { return this.keyManager.createKey(...args); }
130
138
 
131
- async set(...args) { return this.basicOperations.set(...args); }
132
- async get(...args) { return this.basicOperations.get(...args); }
133
- async has(...args) { return this.basicOperations.has(...args); }
134
- async delete(...args) { return this.basicOperations.delete(...args); }
135
- async clear(...args) { return this.basicOperations.clear(...args); }
136
- async deleteByTag(...args) { return this.basicOperations.deleteByTag(...args); }
137
- async metadata(...args) { return this.basicOperations.metadata(...args); }
138
- async tags(...args) { return this.basicOperations.tags(...args); }
139
- 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); }
140
194
 
141
195
  // ============================================================================
142
196
  // PUBLIC API - Read Through Operations
@@ -164,7 +218,8 @@ class CachingService extends cds.Service {
164
218
  if (typeof args[0] !== "object" || !args[1].send || typeof args[1] !== "object" || args[0].method !== "GET") {
165
219
  return super.send(...args);
166
220
  } else {
167
- return (await this.rt.send(...args)).result;
221
+ const result = await this.rt.send(...args);
222
+ return result.result;
168
223
  }
169
224
  }
170
225
 
@@ -177,7 +232,8 @@ class CachingService extends cds.Service {
177
232
  if (typeof args[0] !== "object" && !["Request", "NoaRequest", "ODataRequest", "cds.ql"].includes(args[0].constructor.name)) {
178
233
  return super.run(...args);
179
234
  } else {
180
- return (await this.rt.run(...args)).result;
235
+ const result = await this.rt.run(...args);
236
+ return result.result;
181
237
  }
182
238
  }
183
239