cds-caching 0.2.1 → 0.3.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.
package/README.md CHANGED
@@ -26,12 +26,12 @@ cds-caching is specifically designed for efficient caching, not data replication
26
26
  ### Key Features
27
27
 
28
28
  * **Flexible Key-Value Store** – Store and retrieve data using simple key-based access.
29
- * **CachingService** – A CALESI-compliant cds.Service implementation with an intuitive API for seamless integration.
29
+ * **CachingService** – A cds.Service implementation with an intuitive API for seamless integration into CAP.
30
30
  * **Event Handling** – Monitor and react to cache events, such as before/after storage and retrieval.
31
31
  * **CAP-specific Caching** – Effortlessly cache CQN queries or CAP cds.Requests using code or the @cache annotation.
32
32
  * **TTL Support** – Automatically manage data expiration with configurable time-to-live (TTL) settings.
33
33
  * **Tag Support** – Use dynamic tags for flexible cache invalidation options.
34
- * **Pluggable Storage Options** – Choose between in-memory caching or Redis.
34
+ * **Pluggable Storage Options** – Choose between in-memory caching, SQLite or Redis.
35
35
  * **Compression** – Compress cached data to save memory using LZ4 or GZIP.
36
36
  * **Integrated Statistics** – Monitor cache performance with hit rates, latencies, and more.
37
37
 
@@ -74,13 +74,20 @@ For more control, you can specify additional options:
74
74
  "caching": {
75
75
  "impl": "cds-caching",
76
76
  "namespace": "my::app::caching",
77
- "store": "in-memory", // "in-memory" or "redis"
77
+ "store": "in-memory", // "in-memory" or "sqlite" or "redis"
78
78
  "compression": "lz4", // "lz4" or "gzip"
79
- "credentials": { // if store is redis
79
+ "credentials": { // if store is redis or sqlite
80
+
81
+ // Redis specific
80
82
  "host": "localhost",
81
83
  "port": 6379,
82
84
  "password": "optional",
83
85
  "url": "redis://..." // Alternative: Redis connection URI
86
+
87
+ // SQLite specific
88
+ "url": "sqlite://./cache.sqlite"
89
+ "table": "cache",
90
+ "busyTimeout": 10000
84
91
  },
85
92
  "statistics": {
86
93
  "enabled": true,
@@ -98,12 +105,19 @@ For more control, you can specify additional options:
98
105
 
99
106
  #### Storage Options
100
107
 
101
- cds-caching provides two storage options:
108
+ cds-caching provides 3 storage options:
102
109
 
103
- ##### In-Memory Cache (for small-scale use)
110
+ ##### In-Memory Cache (for development / small-scale uses)
104
111
  - Simple and fast, but not persistent
105
112
  - Not suitable for production since Node.js runtime memory is limited
106
113
  - Data is lost when the application restarts
114
+ - Memory on SAP BTP Cloud Foundry is limited (up to 16 GB) and produces costs
115
+
116
+ ##### SQLite (for medium-size use uses)
117
+ - Data is stored in local SQLite database
118
+ - Data is persited next to SAP BTP application with disk-quota up to 10 GB
119
+ - Cache will be removed after each deployment to SAP BTP
120
+ - No distributed cache between application instances (horizontal scaling)
107
121
 
108
122
  ##### Redis Cache (recommended for production)
109
123
  - Persistent and supports distributed caching
@@ -111,7 +125,7 @@ cds-caching provides two storage options:
111
125
  - Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud)
112
126
  - Even trial accounts provide Redis access
113
127
 
114
- #### Development Setup
128
+ #### Redis Development Setup
115
129
 
116
130
  ##### Running Redis Locally via Docker
117
131
  For local development, Redis can be quickly set up using Docker. A simple docker-compose configuration provides a lightweight caching environment:
@@ -237,11 +251,12 @@ const result = await cache.run(query, db)
237
251
 
238
252
  This will transparently cache the result of the query and return the cached result if available for all further requests.
239
253
 
240
- #### 3. Request-Level Caching
254
+ #### 3. RemoteService Request-Level Caching
241
255
 
242
- Cache entire CAP requests with context awareness (e.g. user, tenant, locale, etc.), which is useful for caching slow remote service calls. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
256
+ 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.
243
257
 
244
258
  ```javascript
259
+ // Cache the requests to an exposed external entity
245
260
  this.on('READ', BusinessPartners, async (req, next) => {
246
261
  const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
247
262
  let value = await cache.get(req)
@@ -256,17 +271,45 @@ this.on('READ', BusinessPartners, async (req, next) => {
256
271
  Alternatively use read-through caching via the `run` method to let the caching service handle the caching transparently:
257
272
 
258
273
  ```javascript
259
- const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
260
- const result = await cache.run(req, bupa)
274
+ this.on('READ', BusinessPartners, async (req, next) => {
275
+ const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
276
+ return await cache.run(req, bupa)
277
+ })
278
+
261
279
  ```
262
280
 
263
281
  This will transparently cache the result of the request and return the cached result if available for all further requests.
264
282
 
265
- #### 4. Declarative Caching with Annotations
283
+ ### 4. ApplicationService Request-Level Caching
284
+
285
+
286
+ > Caching an entire entity should be used with caution, as it will cache all permutations of requests ($filter, $expand, $orderby, etc.) on the entity, which will lead to a huge number of cache entries. Use this only for entities where you can guarantee a low number of different queries.
287
+
288
+
289
+ But not only external services can be cached, it's also possible to cache requests against an ApplicationService.
290
+ Here, you should make use of the [`prepend`](https://cap.cloud.sap/docs/node.js/core-services#srv-prepend) function, to register the `on` handler before the default handler. Thus, it is possible to first check for the cache entries and only execute the default behavior if necessary.
291
+
292
+
293
+ ```javascript
294
+ class MyService extends cds.ApplicationService {
295
+ async init() {
296
+
297
+ // Read-through caching for the full entity
298
+ this.prepend(() => {
299
+ const { MyEntity } = this.entities;
300
+ this.on('READ', MyEntity, async (req, next) => {
301
+ const cache = cds.connect.to("caching");
302
+ return cache.run(req, next);
303
+ });
304
+ });
305
+ return super.init()
306
+ }
307
+ }
308
+ ```
266
309
 
267
- Use annotations to enable caching on service entities or OData functions. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
310
+ #### 5. ApplicationService Request-Level Caching with Annotations
268
311
 
269
- **Caching an entire entity should be used with caution, as it will cache all permutations of requests ($filter, $expand, $orderby, etc.) on the entity, which will lead to a huge number of cache entries. Use this only for entities where you can guarantee a low number of different queries.**
312
+ Alternatively to doing this via code, you can use annotations to enable caching on service entities or OData functions. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
270
313
 
271
314
  ```
272
315
  service MyService {
@@ -753,4 +796,4 @@ Contributions are welcome! Please read our contributing guidelines and submit pu
753
796
 
754
797
  ### License
755
798
 
756
- This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
799
+ This project is licensed under the MIT License - see the LICENSE file for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cds-caching",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "A caching plugin for SAP CAP applications supporting Redis",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,17 +41,16 @@
41
41
  "dependencies": {
42
42
  "@keyv/compress-gzip": "^2.0.2",
43
43
  "@keyv/compress-lz4": "^1.0.0",
44
- "@keyv/redis": "^4.2.0",
45
- "keyv": "^5.2.3"
44
+ "@keyv/redis": "^4.3.1",
45
+ "@keyv/sqlite": "^4.0.1",
46
+ "keyv": "^5.3.1"
46
47
  },
47
48
  "devDependencies": {
48
- "chai": "^4.5.0",
49
- "chai-as-promised": "^7.1.2",
50
- "chai-subset": "^1.6.0",
51
- "eslint": "^8.57.0",
52
- "husky": "^9.0.11",
49
+ "eslint": "^9.21.0",
50
+ "husky": "^9.1.7",
53
51
  "jest": "^29.7.0",
54
- "release-it": "^18.1.2"
52
+ "release-it": "^18.1.2",
53
+ "@cap-js/cds-test": "^0.2.0"
55
54
  },
56
55
  "engines": {
57
56
  "node": ">=16"
@@ -1,6 +1,7 @@
1
1
  const cds = require('@sap/cds');
2
2
  const { Keyv } = require('keyv');
3
3
  const { default: KeyvRedis } = require('@keyv/redis');
4
+ const { default: KeyvSqlite } = require('@keyv/sqlite');
4
5
  const { default: KeyvLz4 } = require('@keyv/compress-lz4');
5
6
  const { default: KeyvGzip } = require('@keyv/compress-gzip');
6
7
  const crypto = require('crypto');
@@ -20,30 +21,45 @@ class CachingService extends cds.Service {
20
21
  this.options = this.options || {
21
22
  store: null,
22
23
  compression: null,
23
- credentials: {
24
- }
24
+ credentials: { }
25
25
  };
26
26
 
27
+ let store;
28
+
29
+ switch (this.options.store) {
30
+ case "sqlite":
31
+ store = new KeyvSqlite({
32
+ url: this.options.credentials?.url,
33
+ table: this.options.credentials?.table || 'cache',
34
+ busyTimeout: this.options.credentials?.busyTimeout || 10000
35
+ });
36
+ break;
37
+ case "redis":
38
+ store = new KeyvRedis({
39
+ ...this.options.credentials,
40
+ // Redis, Hyperscaler Option on BTP provides a URI
41
+ ...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
42
+ });
43
+
44
+ cds.once("shutdown", async () => {
45
+ if (this.cache.store?.disconnect) {
46
+ await this.cache.store.disconnect().catch((err) => {
47
+ this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
48
+ });
49
+ }
50
+ });
51
+ break;
52
+ default:
53
+ store = new Map();
54
+ break;
55
+ }
56
+
27
57
  let cacheOptions = {
28
58
  namespace: this.options.namespace || this.name,
29
- ...(this.options.store === "redis" ? { store: new KeyvRedis({
30
- ...this.options.credentials,
31
- // Redis, Hyperscaler Option on BTP provides a URI
32
- ...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
33
- }) } : {}),
59
+ store: store,
34
60
  compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
35
61
  }
36
62
 
37
- if (this.options.store === "redis") {
38
-
39
- cds.once("shutdown", async () => {
40
- if (this.cache.store?.disconnect) {
41
- await this.cache.store.disconnect().catch((err) => {
42
- this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
43
- });
44
- }
45
- });
46
- }
47
63
 
48
64
  this.cache = new Keyv(cacheOptions);
49
65
  this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
@@ -218,8 +234,9 @@ class CachingService extends cds.Service {
218
234
  req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
219
235
  req.cacheKey = this.createKey(req, req.cacheOptions.key);
220
236
  req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
221
- if (await this.has(req.cacheKey)) {
222
- return this.get(req.cacheKey);
237
+ const cachedValue = await this.get(req.cacheKey);
238
+ if (cachedValue) {
239
+ return cachedValue;
223
240
  }
224
241
  const response = await next();
225
242
  req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, req.params);
@@ -230,7 +247,7 @@ class CachingService extends cds.Service {
230
247
  const srv = arguments[1];
231
248
 
232
249
  if (query.SELECT) {
233
-
250
+
234
251
  let options = {
235
252
  ttl: 0,
236
253
  tags: [],
@@ -277,7 +294,7 @@ class CachingService extends cds.Service {
277
294
  async set(key, value, options = {}) {
278
295
  const wrappedValue = {
279
296
  value,
280
- tags: options.tags || [],
297
+ tags: this.resolveTags(options.tags, value, options.params) || [],
281
298
  timestamp: Date.now()
282
299
  };
283
300
  await this.send('SET', {
@@ -330,7 +347,7 @@ class CachingService extends cds.Service {
330
347
  // Iterators
331
348
  async *iterator() {
332
349
  for await (const [key, value] of this.cache.iterator()) {
333
- if (typeof value === "string") {
350
+ if (typeof value === "string") {
334
351
  yield [key, JSON.parse(value)];
335
352
  } else {
336
353
  yield [key, value];
@@ -346,85 +363,77 @@ class CachingService extends cds.Service {
346
363
  * @returns {string[]} Array of resolved tags
347
364
  */
348
365
  resolveTags(tagConfigs = [], data, params = {}) {
349
- let resolvedTags = [];
350
-
351
- // Handle no data/params case
352
- if (!data && !params) {
353
- // Only process static tags when no sources are present
354
- return tagConfigs
355
- .filter(config => config.value)
356
- .map(config => config.value);
357
- }
366
+ // Handle empty/invalid configs
367
+ if (!tagConfigs?.length) return [];
358
368
 
359
- // Convert data to array if single object
360
- const dataArray = data ? (Array.isArray(data) ? data : [data]) : [];
369
+ // Convert data to array if single object or string
370
+ const dataArray = !data ? [] :
371
+ Array.isArray(data) ? data :
372
+ typeof data === 'string' ? [data] : [data];
361
373
 
362
374
  // Process each tag configuration
363
- tagConfigs.forEach(config => {
375
+ const resolvedTags = tagConfigs.flatMap(config => {
376
+ // Handle string tags
377
+ if (typeof config === 'string') {
378
+ return [config];
379
+ }
380
+
381
+ // Handle invalid/empty config objects
382
+ if (!config || typeof config !== 'object') {
383
+ return [];
384
+ }
385
+
386
+ // Handle static value tags
364
387
  if (config.value) {
365
- // Static tag
366
- resolvedTags.push(config.value);
367
- } else if (config.data) {
368
- // Dynamic tags from data
369
- dataArray.forEach(item => {
370
- if (typeof config.data === "string") {
371
- // Single data configuration
372
- const dataValue = item[config.data];
373
- if (dataValue) {
374
- const tag = [
375
- config.prefix,
376
- dataValue,
377
- config.suffix
378
- ].filter(Boolean).join('');
379
- resolvedTags.push(tag);
380
- }
381
- } else if (Array.isArray(config.data)) {
382
- // Multiple data configuration
383
- const dataValues = config.data
384
- .map(data => item[data])
385
- .filter(Boolean);
386
-
387
- if (dataValues.length > 0) {
388
- const combinedValue = dataValues.join(config.separator || ':');
389
- const tag = [
390
- config.prefix,
391
- combinedValue,
392
- config.suffix
393
- ].filter(Boolean).join('');
394
- resolvedTags.push(tag);
395
- }
396
- }
397
- });
398
- } else if (config.param) {
399
- // Dynamic tags from params
400
- if (typeof config.param === "string") {
401
- // Single param configuration
402
- const paramValue = params[config.param];
403
- if (paramValue) {
404
- const tag = [
405
- config.prefix,
406
- paramValue,
407
- config.suffix
408
- ].filter(Boolean).join('');
409
- resolvedTags.push(tag);
410
- }
411
- } else if (Array.isArray(config.param)) {
412
- // Multiple params configuration
413
- const paramValues = config.param
414
- .map(param => params[param])
388
+ const tag = [
389
+ config.prefix,
390
+ config.value,
391
+ config.suffix
392
+ ].filter(Boolean).join('');
393
+ return [tag];
394
+ }
395
+
396
+ // Handle data-based tags
397
+ if (config.data && dataArray.length) {
398
+ return dataArray.flatMap(item => {
399
+ if (typeof item !== 'object') return [];
400
+
401
+ const dataFields = Array.isArray(config.data) ? config.data : [config.data];
402
+ const values = dataFields
403
+ .map(field => item[field])
415
404
  .filter(Boolean);
416
405
 
417
- if (paramValues.length > 0) {
418
- const combinedValue = paramValues.join(config.separator || ':');
419
- const tag = [
420
- config.prefix,
421
- combinedValue,
422
- config.suffix
423
- ].filter(Boolean).join('');
424
- resolvedTags.push(tag);
425
- }
426
- }
406
+ if (!values.length) return [];
407
+
408
+ const value = values.join(config.separator || ':');
409
+ const tag = [
410
+ config.prefix,
411
+ value,
412
+ config.suffix
413
+ ].filter(Boolean).join('');
414
+ return [tag];
415
+ });
416
+ }
417
+
418
+ // Handle param-based tags
419
+ if (config.param) {
420
+ const paramFields = Array.isArray(config.param) ? config.param : [config.param];
421
+ const values = paramFields
422
+ .map(field => params[field])
423
+ .filter(Boolean);
424
+
425
+ if (!values.length) return [];
426
+
427
+ const value = values.join(config.separator || ':');
428
+ const tag = [
429
+ config.prefix,
430
+ value,
431
+ config.suffix
432
+ ].filter(Boolean).join('');
433
+ return [tag];
427
434
  }
435
+
436
+ return [];
428
437
  });
429
438
 
430
439
  // Remove duplicates
@@ -451,13 +460,13 @@ class CachingService extends cds.Service {
451
460
  case "Request":
452
461
  case "NoaRequest":
453
462
 
454
- return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
455
- req: keyOrObject,
456
- params: keyOrObject.params,
457
- data: keyOrObject.data,
458
- locale: keyOrObject.locale,
459
- user: keyOrObject.user.id,
460
- tenant: keyOrObject.tenant
463
+ return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
464
+ req: keyOrObject,
465
+ params: keyOrObject.params,
466
+ data: keyOrObject.data,
467
+ locale: keyOrObject.locale,
468
+ user: keyOrObject.user.id,
469
+ tenant: keyOrObject.tenant
461
470
  });
462
471
  case "cds.ql":
463
472
  if (keyOrObject.SELECT) {
package/srv/util.js CHANGED
@@ -1,5 +1,20 @@
1
1
  const cds = require("@sap/cds")
2
2
 
3
+ const extractCacheProperties = (entity, prefix) => {
4
+ const result = {};
5
+ for (const key of Object.keys(entity)) {
6
+ if (key.startsWith(`@cache.${prefix}.`)) {
7
+ const subKey = key.substring(`@cache.${prefix}.`.length);
8
+ result[subKey] = entity[key];
9
+ }
10
+ }
11
+ // If there are no subproperties but the main property exists, use it directly
12
+ if (Object.keys(result).length === 0 && entity[`@cache.${prefix}`]) {
13
+ return entity[`@cache.${prefix}`];
14
+ }
15
+ return Object.keys(result).length > 0 ? result : undefined;
16
+ };
17
+
3
18
  const bindFunction = async (service, action, isBound = false) => {
4
19
  const cache = await cds.connect.to(action['@cache.service'] || "caching");
5
20
  cache.addCachableFunction(action.name.split('.').pop(), action, isBound);
@@ -9,8 +24,8 @@ const bindFunction = async (service, action, isBound = false) => {
9
24
  const cache = await cds.connect.to(action['@cache.service'] || "caching");
10
25
  const data = await cache.run(req, next, {
11
26
  ttl: action['@cache.ttl'],
12
- tags: action['@cache.tags'],
13
- key: action['@cache.key']
27
+ tags: extractCacheProperties(action, 'tags'),
28
+ key: extractCacheProperties(action, 'key')
14
29
  });
15
30
  return data;
16
31
  })
@@ -21,10 +36,11 @@ const bindEntity = async (service, entity) => {
21
36
  service.prepend(function () {
22
37
  service.on('READ', entity.name, async (req, next) => {
23
38
  const cache = await cds.connect.to(entity['@cache.service'] || "caching");
39
+
24
40
  const data = await cache.run(req, next, {
25
41
  ttl: entity['@cache.ttl'],
26
- tags: entity['@cache.tags'],
27
- key: entity['@cache.key']
42
+ tags: extractCacheProperties(entity, 'tags'),
43
+ key: extractCacheProperties(entity, 'key')
28
44
  });
29
45
  return data;
30
46
  })