cds-caching 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mike Zaschka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # Welcome to cds-caching
2
+
3
+ ## Overview
4
+
5
+ This plugin for the [SAP Cloud Application Programming Model (CAP)](https://cap.cloud.sap/docs/) provides a robust caching service for CAP applications, addressing common performance challenges in distributed systems.
6
+
7
+ By reducing database requests and accelerating response times, caching is ideal for handling expensive operations like complex queries/calculations or external API calls. However, it introduces another layer of complexity and should be used with caution.
8
+
9
+ ### Key Features
10
+
11
+ * **Flexible Key-Value Store** – Store and retrieve data using simple key-based access.
12
+ * **CachingService** – A cds.Service-based implementation with an intuitive API for seamless integration.
13
+ * **Event Handling** – Monitor and react to cache events, such as before/after storage and retrieval.
14
+ * **CAP-specific Caching** – Effortlessly cache CQN queries or CAP cds.Requests using code or the @cache annotation.
15
+ * **TTL Support** – Automatically manage data expiration with configurable time-to-live (TTL) settings.
16
+ * **Tag Support** – Use dynamic tags for flexible cache invalidation options.
17
+ * **Pluggable Storage Options** – Choose between in-memory caching or Redis.
18
+ * **Compression** – Compress cached data to save memory.
19
+ * **Integrated Statistics (WIP)** – Integrated statistics on cache hits, etc.
20
+ ### Installation
21
+
22
+ Installing and using cds-caching is straightforward since it’s a CAP plugin. Simply run:
23
+
24
+ ```
25
+ npm install cds-caching
26
+ ```
27
+
28
+ Next, add a caching service configuration to your package.json. You can even define **multiple caching services**, which is recommended if you need to cache different types of data within your application.
29
+
30
+ ```json
31
+
32
+ {
33
+ "cds": {
34
+ "requires": {
35
+ "caching": {
36
+ "impl": "cds-caching",
37
+ "namespace": "my::app::caching"
38
+ },
39
+ "bp-caching": {
40
+ "impl": "cds-caching",
41
+ "namespace": "my::app::bp-caching"
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ ```
48
+ ### Advanced Configuration
49
+
50
+ For more control, you can specify additional options. Some of those will be explained later:
51
+
52
+ ```javascript
53
+
54
+ {
55
+ "cds": {
56
+ "requires": {
57
+ "caching": {
58
+ "impl": "cds-caching",
59
+ "namespace": "my::app::caching",
60
+ "store": "in-memory", // "in-memory" or "redis"
61
+ "compression": "lz4", // "lz4" or "gzip"
62
+ "credentials": { // if store is redis
63
+ "host": "localhost",
64
+ "port": 6379,
65
+ "password": "optional",
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ ```
73
+ ### Low level usage
74
+
75
+ The **cds-caching** plugin provides direct **key-value storage**, allowing fine-grained caching control. Its API follows a familiar pattern, making it easy to use if you have ever worked with other caching solutions and frameworks.
76
+ Here’s how you can interact with the cache:
77
+
78
+ ```javascript
79
+ // Connect to the caching service
80
+ const cache = await cds.connect.to("caching")
81
+
82
+ // Store a value
83
+ await cache.set("key", "value")
84
+
85
+ // Retrieve the value
86
+ await cache.get("key") // => value
87
+
88
+ // Check if the key exists
89
+ await cache.has("key") // => true/false
90
+
91
+ // Delete the key
92
+ await cache.delete("key")
93
+
94
+ // Clear the whole cache
95
+ await cache.clear()
96
+ ```
97
+
98
+ This **low-level API** is useful when you need direct access to cached data, such as storing configuration values, or precomputed results. While the core API is simple, **cds-caching** also provides **higher-level caching strategies** that are more integrated with CAP. We will look at those in a minute. Before, let's focus on cache events.
99
+ #### Cache Events
100
+
101
+ The caching service follows the same event-driven principles as cds.Service instances. This means you can hook into events **before** and **after** storing or retrieving data. This is useful for logging, debugging, or performing additional actions when cache operations occur.
102
+
103
+ Each cache event (event.data.value) contains the following structure:
104
+ * **value** – The cached data.
105
+ * **tags** – Tags assigned to the cached entry.
106
+ * **timestamp** – The timestamp when the cache entry was created.
107
+
108
+ Here’s how you can listen for and react to cache events:
109
+
110
+ ```javascript
111
+
112
+ // Log before the cache is cleared
113
+ cache.before("CLEAR", () => {
114
+ console.log("Cache is about to be cleared")
115
+ })
116
+
117
+ // Log before storing data
118
+ cache.before("SET", (event) => {
119
+ console.log(`Storing key: ${event.data.key} with value: ${event.data.value}`)
120
+ })
121
+
122
+ // Log after retrieving data
123
+ cache.after("GET", (event) => {
124
+ console.log(`Retrieved key: ${event.data.key} with value: ${event.data.value}`)
125
+ })
126
+
127
+
128
+ ```
129
+
130
+ This approach allows you to monitor cache activity and, if needed, manipulate data before storage or retrieval.
131
+ #### Invalidation via Time-To-Live (TTL)
132
+
133
+ **cds-caching** supports automatic cache invalidation via **TTL (Time-To-Live)**. Cached values will expire after the specified TTL, preventing stale data from lingering in memory.
134
+
135
+ ```javascript
136
+ // Store a value with a ttl
137
+ await cache.set("key", "value", { ttl: 6000 }) // 60 seconds
138
+
139
+ // Retrieve the value in time
140
+ await cache.get("key") // => value
141
+
142
+ await new Promise((resolve) => setTimeout(resolve, 6100)) // wait 61 seonds
143
+
144
+ // Now the value is not available anymore
145
+ await cache.get("key") // => undefined
146
+ ```
147
+
148
+ TTL-based invalidation is useful for **temporary data, rate-limiting mechanisms, and frequently updated information**. However, **cds-caching** also supports other invalidation strategies, which will be covered in the advanced section.
149
+ #### Compression
150
+
151
+ To optimize storage, **cds-caching** supports **data compression**. This reduces cache size and can improve performance, especially when caching large datasets. Compression is **applied only when storing data**, meaning applications interact with uncompressed values. Available compression methods include:
152
+ * **lz4** – Faster compression and decompression, ideal for performance-critical applications.
153
+ * **gzip** – Higher compression ratio, reducing storage footprint at the cost of slightly increased CPU usage.
154
+
155
+ Compression can be configured via package.json:
156
+
157
+ ```json
158
+ {
159
+ "cds": {
160
+ "requires": {
161
+ "caching": {
162
+ "impl": "cds-caching",
163
+ "compression": "lz4"
164
+ }
165
+ }
166
+ }
167
+ }
168
+ ```
169
+
170
+ ### Medium level usage
171
+
172
+ One of the core principles of **cds-caching** is **seamless integration with CAP**, aligning with CAP’s design and query execution model. As a result, **cds-caching** provides **native support** for **CQN (Core Query Notation) queries** and **cds.Requests**.
173
+ #### Caching CQN queries
174
+
175
+ CQN queries are widely used in CAP for **database operations** and **remote service calls (e.g., OData requests)**. Since these queries often involve **repeated data retrieval**, caching them can **significantly reduce response times** and **offload system resources**.
176
+
177
+ **cds-caching** treats **CQN queries as first-class objects**, allowing them to be passed directly into the caching API. Internally, it generates a unique key based on the CQN query structure, ensuring **consistent retrieval** and **cache integrity**.
178
+
179
+ **Example: Caching a CQN Query**
180
+
181
+ ```javascript
182
+
183
+ // Create the CQN object
184
+ const query = SELECT.from(Foo)
185
+
186
+ // Execute to fetch the result
187
+ const result = await cds.run(query) // => [{...}, {...}]
188
+
189
+ // Store value in the cache
190
+ await cache.set(query, result)
191
+
192
+ // Retrieve the value from the cache using the same CQN object
193
+ const cachedResult = await cache.get(query) // => [{...}, {...}]
194
+
195
+ // Create the key that is used internally
196
+ const key = cache.createKey(query)
197
+
198
+ // Delete the value from the cache
199
+ await cache.delete(query)
200
+
201
+ ```
202
+
203
+ While **CQN queries** can be cached directly, **cds-caching** also supports **caching full** cds.Requests, including their **request context**.
204
+ #### Caching cds.Requests
205
+
206
+ Caching requests is particularly useful when exposing **remote services** through **local CAP services**. For example, if your CAP application proxies an **external API**, caching can significantly reduce redundant requests and improve response times.
207
+
208
+ 👉 **Use Case:** [Exposing Remote Services](https://cap.cloud.sap/docs/guides/using-services#expose-remote-services)
209
+
210
+ **cds-caching** automatically includes contextual information in the cache key, making request caching **tenant- and user-aware**. The cache key incorporates:
211
+
212
+ * req.tenant – Ensures data is scoped per tenant in multi-tenant environments.
213
+ * req.user – Allows user-specific caching when necessary.
214
+ * req.locale – Supports localized responses when caching multilingual content.
215
+
216
+ **Example: Caching a cds.Request**
217
+
218
+ ```javascript
219
+ this.on('READ', BusinessPartners, async (req, next) => {
220
+ const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
221
+ let value = await cache.get(req)
222
+ if(!value) {
223
+ value = await bupa.run(req)
224
+ await cache.set(req, next, { ttl: 3600 })
225
+ }
226
+ return value;
227
+ })
228
+ ```
229
+
230
+ **When Not to Cache Full OData Services**
231
+
232
+ While caching individual **requests** can improve performance, **caching an entire OData service is generally not recommended**. Here’s why:
233
+
234
+ 1. **Data Inconsistency** – OData services expose **live business data**, which frequently changes. Caching responses without an **appropriate invalidation strategy** can lead to outdated or incorrect data being served.
235
+
236
+ 2. **Complex Query Variations** – OData allows **dynamic query parameters** ($filter, $expand, $orderby, etc.), making it difficult to cache efficiently without storing excessive variations.
237
+
238
+ 3. **Large Payloads** – Full OData responses can be **significantly large**, consuming cache memory inefficiently compared to caching targeted **CQN queries** or specific **request results**.
239
+
240
+ 👉 **Best Practice:** Instead of caching entire OData service responses, cache **specific queries or request results** where the data is frequently accessed and doesn’t change often.
241
+
242
+ For example, focussing on remote services, **static master data**, or **computed results** is much safer and more efficient than blindly caching full OData responses.
243
+
244
+ ### Read-through CQN queries and cds.Requests
245
+
246
+ While this API is useful, it follows the **read-aside cache pattern**, meaning **manual cache checks** are required before fetching and storing data. In scenarios where caching logic becomes repetitive, **higher-level caching strategies** can help streamline this process and thanks to the already available CAP API, those are also available in cds-caching.
247
+
248
+ In **read-through caching**, queries and requests are executed **through the caching service itself**, reducing the need for manual cache handling. **cds-caching** extends CAP’s built-in service methods with two key functions:
249
+
250
+ * run – Executes **CQN queries** or **requests** against a database or remote OData service.
251
+ [CAP Documentation: srv.run(query)](https://cap.cloud.sap/docs/node.js/core-services#srv-run-query)
252
+
253
+ * send – Sends **custom synchronous requests** (e.g., to REST APIs) with configurable paths and headers.
254
+ [CAP Documentation: srv.send(request)](https://cap.cloud.sap/docs/node.js/core-services#srv-send-request)
255
+
256
+ Using **read-through caching**, the previous read-aside pattern can be reduced to a **single line of code**:
257
+
258
+ ```javascript
259
+ this.on('READ', BusinessPartners, async (req, next) => {
260
+ const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
261
+ return cache.run(req, bupa, { ttl: 3600 })
262
+ })
263
+ ```
264
+
265
+ With this approach, **all requests to the external service will be automatically executed and cached**, eliminating the need for manual cache handling.
266
+
267
+ Some other examples :
268
+
269
+ ```javascript
270
+ // Read-through for a CQN query
271
+ const queryResult = await cache.run(SELECT.from("Foo"), db, { ttl: 3600 })
272
+
273
+ // Read-through for a custom REST request
274
+ const restService = await cds.connect.to({
275
+ "kind": "rest",
276
+ "credentials": {
277
+ "url": "https://services.odata.org/V3/Northwind/Northwind.svc/"
278
+ }
279
+ });
280
+ const restResult = await cache.send({ method: "GET", path: "Products" }, restService, { ttl: 3600 });
281
+ ```
282
+
283
+ The **read-through strategy** makes caching **cleaner and more maintainable**, as it abstracts cache management entirely. However, **the first request is still slow** (since there’s no cached value yet), but all subsequent requests will be **served instantly from the cache**.
284
+ #### Wrapping async complex code
285
+
286
+ The **read-through approach** can also be applied to **non-CAP-specific** operations. **cds-caching** provides a wrap function that caches the result of **any asynchronous function**.
287
+
288
+ ```javascript
289
+ const expensiveFunction = async (param) => { /* Do something complex */ }
290
+
291
+ // Wrap the function with caching
292
+ const cachedExpensiveFunction = await cache.wrap("key", expensiveFunction, { ttl: 3600 })
293
+
294
+ // First call executes the function
295
+ result = await cachedExpensiveFunction("someParam"); // No cache hit
296
+
297
+ // Subsequent calls retrieve the result from cache
298
+ result = await cachedExpensiveFunction("someParam"); // Cache hit
299
+ ```
300
+
301
+ This is particularly useful for **heavy computations**, ensuring they only need to be executed **once per TTL period**.
302
+
303
+
304
+ ## TODO:
305
+
306
+ - [ ] Add documentation for cache iteration
307
+ - [ ] Add documentation for cache invalidation
308
+ - [ ] Add documentation for cache annotations
309
+ - [ ] Add documentation for cache key generation
310
+ - [ ] Add documentation for cache tags
311
+ - [ ] Add documentation for cache statistics
312
+ - [ ] Add documentation for running locally with redis on docker
313
+ - [ ] Add documentation for running with redis on BTP
314
+
315
+
316
+ ## Contributing
317
+
318
+ Contributions are welcome! Please feel free to submit a Pull Request.
319
+
320
+ ## License
321
+
322
+ This project is licensed under the MIT License - see the LICENSE file for detail
package/cds-plugin.js ADDED
@@ -0,0 +1,7 @@
1
+ const cds = require('@sap/cds')
2
+ const CachingService = require('./srv/CachingService')
3
+ const { scanCachingAnnotations } = require('./srv/util')
4
+
5
+ cds.on('served', scanCachingAnnotations)
6
+
7
+ module.exports = cds.service.impl(CachingService)
package/index.cds ADDED
@@ -0,0 +1,21 @@
1
+ namespace cds_caching;
2
+
3
+ entity Statistics {
4
+ key ID : String; // e.g., 'daily:2024-03-20' or 'hourly:2024-03-20-15'
5
+ key cache : String;
6
+ timestamp : DateTime;
7
+ period : String enum {
8
+ hourly;
9
+ daily;
10
+ monthly;
11
+ }; // Granularity
12
+ hits : Integer default 0;
13
+ misses : Integer default 0;
14
+ sets : Integer default 0;
15
+ deletes : Integer default 0;
16
+ errors : Integer default 0;
17
+ avgLatency : Double; // in milliseconds
18
+ p95Latency : Double; // 95th percentile latency
19
+ memoryUsage : Integer; // in bytes
20
+ itemCount : Integer;
21
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "cds-caching",
3
+ "version": "0.1.0",
4
+ "description": "A caching plugin for SAP CAP applications supporting Redis",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/mikezaschka/cds-caching.git"
8
+ },
9
+ "keywords": [
10
+ "sap",
11
+ "cap",
12
+ "cds",
13
+ "caching",
14
+ "redis",
15
+ "keyv"
16
+ ],
17
+ "author": "Mike Zaschka <opensource@mikezaschka.com>",
18
+ "license": "MIT",
19
+ "bugs": {
20
+ "url": "https://github.com/mikezaschka/cds-caching/issues"
21
+ },
22
+ "homepage": "https://github.com/mikezaschka/cds-caching#readme",
23
+ "main": "cds-plugin.js",
24
+ "files": [
25
+ "srv/**",
26
+ "index.cds"
27
+ ],
28
+ "scripts": {
29
+ "test": "jest --runInBand --silent",
30
+ "test:watch": "jest --watch",
31
+ "lint": "eslint ."
32
+ },
33
+ "peerDependencies": {
34
+ "@sap/cds": ">=8"
35
+ },
36
+ "workspaces": [
37
+ ".",
38
+ "test/**"
39
+ ],
40
+ "dependencies": {
41
+ "@keyv/compress-gzip": "^2.0.2",
42
+ "@keyv/compress-lz4": "^1.0.0",
43
+ "@keyv/redis": "^4.2.0",
44
+ "keyv": "^5.2.3"
45
+ },
46
+ "devDependencies": {
47
+ "chai": "^4.5.0",
48
+ "chai-as-promised": "^7.1.2",
49
+ "chai-subset": "^1.6.0",
50
+ "eslint": "^8.57.0",
51
+ "husky": "^9.0.11",
52
+ "jest": "^29.7.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=16"
56
+ }
57
+ }
@@ -0,0 +1,213 @@
1
+ const cds = require('@sap/cds');
2
+
3
+ class CacheStatisticsHandler {
4
+ constructor(options = {}) {
5
+ console.log(options);
6
+ this.options = {
7
+ persistenceInterval: 5 * 60 * 60 * 1000, // 5 minutes
8
+ maxLatencies: 1000,
9
+ ...options
10
+ };
11
+
12
+ this.stats = {
13
+ current: {
14
+ hits: 0,
15
+ misses: 0,
16
+ sets: 0,
17
+ deletes: 0,
18
+ errors: 0,
19
+ latencies: []
20
+ },
21
+ lastPersisted: Date.now()
22
+ };
23
+
24
+ if (this.options.enabled) {
25
+ cds.once('served', () => {
26
+ this.persistInterval = setInterval(
27
+ () => this.persistStats(),
28
+ this.options.persistenceInterval
29
+ );
30
+ })
31
+ cds.on('shutdown', () => {
32
+ clearInterval(this.persistInterval)
33
+ })
34
+ }
35
+ }
36
+
37
+ recordHit(latencyMs) {
38
+ this.stats.current.hits++;
39
+ this.recordLatency(latencyMs);
40
+ }
41
+
42
+ recordMiss() {
43
+ this.stats.current.misses++;
44
+ }
45
+
46
+ recordSet() {
47
+ this.stats.current.sets++;
48
+ }
49
+
50
+ recordDelete() {
51
+ this.stats.current.deletes++;
52
+ }
53
+
54
+ recordError() {
55
+ this.stats.current.errors++;
56
+ }
57
+
58
+ recordLatency(ms) {
59
+ this.stats.current.latencies.push(ms);
60
+ if (this.stats.current.latencies.length > this.options.maxLatencies) {
61
+ this.stats.current.latencies.shift();
62
+ }
63
+ }
64
+
65
+ async persistStats() {
66
+ if (!this.options.enabled) return;
67
+
68
+ const now = new Date().toISOString();
69
+ const hourlyId = `hourly:${now.slice(0, 13)}`;
70
+ const dailyId = `daily:${now.slice(0, 10)}`;
71
+
72
+ const stats = await this.calculateStats();
73
+
74
+ try {
75
+
76
+ // Persist hourly stats
77
+ const existingHourly = await SELECT.one.from("cds_caching_Statistics")
78
+ .where({ ID: hourlyId, cache: this.options.cache });
79
+
80
+ if (!existingHourly) {
81
+ await INSERT.into('cds_caching_Statistics').entries([{
82
+ ID: hourlyId,
83
+ cache: this.options.cache,
84
+ timestamp: now,
85
+ period: 'hourly',
86
+ ...stats
87
+ }]);
88
+ } else {
89
+ await UPDATE('cds_caching_Statistics')
90
+ .set({
91
+ hits: { '+=': stats.hits },
92
+ misses: { '+=': stats.misses },
93
+ sets: { '+=': stats.sets },
94
+ deletes: { '+=': stats.deletes },
95
+ errors: { '+=': stats.errors },
96
+ avgLatency: (existingHourly.avgLatency + stats.avgLatency) / 2,
97
+ p95Latency: Math.max(existingHourly.p95Latency, stats.p95Latency),
98
+ memoryUsage: stats.memoryUsage,
99
+ itemCount: stats.itemCount
100
+ })
101
+ .where({ ID: hourlyId, cache: this.options.cache });
102
+ }
103
+
104
+ // Update or insert daily stats
105
+ const existingDaily = await SELECT.one.from("cds_caching_Statistics")
106
+ .where({ ID: dailyId, cache: this.options.cache });
107
+
108
+ if (existingDaily) {
109
+ await UPDATE('cds_caching_Statistics')
110
+ .set({
111
+ hits: { '+=': stats.hits },
112
+ misses: { '+=': stats.misses },
113
+ sets: { '+=': stats.sets },
114
+ deletes: { '+=': stats.deletes },
115
+ errors: { '+=': stats.errors },
116
+ avgLatency: (existingDaily.avgLatency + stats.avgLatency) / 2,
117
+ p95Latency: Math.max(existingDaily.p95Latency, stats.p95Latency),
118
+ memoryUsage: stats.memoryUsage,
119
+ itemCount: stats.itemCount
120
+ })
121
+ .where({ ID: dailyId, cache: this.options.cache });
122
+ } else {
123
+ await INSERT.into("cds_caching_Statistics").entries({
124
+ ID: dailyId,
125
+ cache: this.options.cache,
126
+ timestamp: now,
127
+ period: 'daily',
128
+ ...stats
129
+ });
130
+ }
131
+
132
+ this.resetCurrentStats();
133
+
134
+ } catch (error) {
135
+ cds.log('caching').error('Error persisting cache statistics:', error);
136
+ }
137
+ }
138
+
139
+ async calculateStats() {
140
+ const latencies = this.stats.current.latencies;
141
+ const avgLatency = latencies.length > 0
142
+ ? latencies.reduce((a, b) => a + b, 0) / latencies.length
143
+ : 0;
144
+ const p95Latency = latencies.length > 0
145
+ ? latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)]
146
+ : 0;
147
+
148
+ return {
149
+ hits: this.stats.current.hits,
150
+ misses: this.stats.current.misses,
151
+ sets: this.stats.current.sets,
152
+ deletes: this.stats.current.deletes,
153
+ errors: this.stats.current.errors,
154
+ avgLatency,
155
+ p95Latency,
156
+ memoryUsage: process.memoryUsage().heapUsed,
157
+ itemCount: await this.options.getItemCount?.() || 0
158
+ };
159
+ }
160
+
161
+ resetCurrentStats() {
162
+ this.stats.current = {
163
+ hits: 0,
164
+ misses: 0,
165
+ sets: 0,
166
+ deletes: 0,
167
+ errors: 0,
168
+ latencies: []
169
+ };
170
+ this.stats.lastPersisted = Date.now();
171
+ }
172
+
173
+ async getStats(period = 'hourly', from, to) {
174
+ if (!this.options.enabled) return null;
175
+
176
+ const query = SELECT.from("cds_caching_Statistics")
177
+ .where({ period: period });
178
+
179
+ if (from) query.and({ timestamp: { '>=': from } });
180
+ if (to) query.and({ timestamp: { '<=': to } });
181
+
182
+ query.orderBy({ timestamp: 'desc' });
183
+
184
+ return await query;
185
+ }
186
+
187
+ async getCurrentStats() {
188
+ if (!this.options.enabled) return null;
189
+
190
+ const { current, lastPersisted } = this.stats;
191
+ const latencies = current.latencies;
192
+
193
+ return {
194
+ ...current,
195
+ avgLatency: latencies.length > 0
196
+ ? latencies.reduce((a, b) => a + b, 0) / latencies.length
197
+ : 0,
198
+ p95Latency: latencies.length > 0
199
+ ? latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)]
200
+ : 0,
201
+ hitRatio: (current.hits / (current.hits + current.misses)) || 0,
202
+ lastPersisted: new Date(lastPersisted)
203
+ };
204
+ }
205
+
206
+ dispose() {
207
+ if (this.persistInterval) {
208
+ clearInterval(this.persistInterval);
209
+ }
210
+ }
211
+ }
212
+
213
+ module.exports = CacheStatisticsHandler;
@@ -0,0 +1,528 @@
1
+ const cds = require('@sap/cds');
2
+ const { Keyv } = require('keyv');
3
+ const { default: KeyvRedis } = require('@keyv/redis');
4
+ const { default: KeyvLz4 } = require('@keyv/compress-lz4');
5
+ const { default: KeyvGzip } = require('@keyv/compress-gzip');
6
+ const crypto = require('crypto');
7
+ const CacheStatisticsHandler = require('./CacheStatisticsHandler');
8
+
9
+ class CachingService extends cds.Service {
10
+
11
+ // Store annotated functions with metadata as requests do not contain a target
12
+ cacheAnnotatedFunctions = {
13
+ bound: [],
14
+ unbound: []
15
+ };
16
+
17
+ init() {
18
+ super.init()
19
+ this.LOG = cds.log('cds-caching')
20
+ this.options = this.options || {
21
+ store: null,
22
+ compression: null,
23
+ credentials: {
24
+ }
25
+ };
26
+
27
+ let cacheOptions = {
28
+ 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
+ }) } : {}),
34
+ compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
35
+ }
36
+
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
+
48
+ this.cache = new Keyv(cacheOptions);
49
+ this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
50
+
51
+ this.cache.on('error', err => {
52
+ this.LOG._error && this.LOG.error('Cache error', err);
53
+ });
54
+
55
+
56
+ this.on('SET', async (event) => {
57
+ this.LOG._debug && this.LOG.debug(`SET ${event.data.key}`);
58
+ if (typeof event.data.value === "object") {
59
+ event.data.value = JSON.stringify(event.data.value);
60
+ }
61
+ await this.cache.set(event.data.key, event.data.value, (event.data.ttl || 0))
62
+ });
63
+
64
+ this.on('GET', async (event) => {
65
+ const value = await this.cache.get(event.data.key);
66
+ this.LOG._debug && this.LOG.debug(`GET ${event.data.key}`);
67
+ if (typeof value === "string") {
68
+ return JSON.parse(value);
69
+ }
70
+ return value;
71
+ });
72
+
73
+ this.on('DELETE', async (event) => {
74
+ this.LOG._debug && this.LOG.debug(`DELETE ${event.data.key}`);
75
+ await this.cache.delete(event.data.key);
76
+ });
77
+
78
+ this.on('CLEAR', async (event) => {
79
+ this.LOG._debug && this.LOG.debug(`CLEAR`);
80
+ await this.cache.clear();
81
+ });
82
+
83
+ // Initialize statistics if enabled
84
+ if (this.options.statistics?.enabled) {
85
+ this.statistics = new CacheStatisticsHandler({
86
+ enabled: true,
87
+ ...(this.options.statistics.persistenceInterval ? { persistenceInterval: this.options.statistics.persistenceInterval } : {}),
88
+ ...(this.options.statistics.maxLatencies ? { maxLatencies: this.options.statistics.maxLatencies } : {}),
89
+ getItemCount: async () => {
90
+ let count = 0;
91
+ for await (const _ of this.iterator()) {
92
+ count++;
93
+ }
94
+ return count;
95
+ }
96
+ });
97
+
98
+ // Enhance methods with statistics
99
+ this.after('GET', async (result, req) => {
100
+ const startTime = process.hrtime();
101
+ try {
102
+ if (result === undefined) {
103
+ this.statistics.recordMiss();
104
+ } else {
105
+ this.statistics.recordHit(this.getElapsedMs(startTime));
106
+ }
107
+ } catch (error) {
108
+ this.statistics.recordError();
109
+ throw error;
110
+ }
111
+ });
112
+
113
+ this.after('SET', () => this.statistics.recordSet());
114
+ this.after('DELETE', () => this.statistics.recordDelete());
115
+ }
116
+ }
117
+
118
+ addCachableFunction(name, options, isBound = false) {
119
+
120
+ this.cacheAnnotatedFunctions[isBound ? 'bound' : 'unbound'].push({ name, options });
121
+ }
122
+
123
+ getElapsedMs(startTime) {
124
+ const [seconds, nanoseconds] = process.hrtime(startTime);
125
+ return seconds * 1000 + nanoseconds / 1000000;
126
+ }
127
+
128
+ async getStats(period, from, to) {
129
+ return this.statistics?.getStats(period, from, to);
130
+ }
131
+
132
+ async getCurrentStats() {
133
+ return this.statistics?.getCurrentStats();
134
+ }
135
+
136
+ async dispose() {
137
+ this.statistics?.dispose();
138
+ await super.dispose();
139
+ }
140
+
141
+ /**
142
+ * Overloaded send method that caches the response of a remote service.
143
+ *
144
+ * @returns {Promise<any>} - the result
145
+ */
146
+ async send() {
147
+ const arg1 = arguments[0];
148
+ const service = arguments[1];
149
+ const options = {
150
+ ttl: 0,
151
+ ...(arguments[2] || {}),
152
+ }
153
+
154
+ if (typeof arg1 !== "object" || !service.send || typeof options !== "object" || arg1.method !== "GET") {
155
+ return super.send(...arguments);
156
+ }
157
+
158
+ const key = this.createKey(arg1, options.key);
159
+
160
+ if (await this.has(key)) {
161
+ return this.get(key);
162
+ }
163
+ const response = await service.send(arg1);
164
+ await this.set(key, response, options);
165
+ return response;
166
+ }
167
+
168
+ // Function to extract the cache options from the request
169
+ extractFunctionCacheOptions(req, options) {
170
+ const functionType = req.query ? 'bound' : 'unbound';
171
+ const functionOptions = this.cacheAnnotatedFunctions[functionType].find(f => f.name === req.event);
172
+
173
+ return {
174
+ ttl: functionOptions?.['@cache.ttl'] || 0,
175
+ key: functionOptions?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
176
+ tags: functionOptions?.['@cache.tags'] || [],
177
+ ...(options || {}),
178
+ }
179
+ }
180
+
181
+ extractEntityCacheOptions(req, options) {
182
+ return {
183
+ ttl: req.target?.['@cache.ttl'] || 0,
184
+ key: req.target?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
185
+ tags: req.target?.['@cache.tags'] || [],
186
+ ...(options || {}),
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Overloaded run method that caches multiple things magically in the background
192
+ *
193
+ * @param {cds.ql} query - the query to run
194
+ * @param {Service} service - service instance to run the query on
195
+ * @param {object} options - additional options
196
+ *
197
+ *
198
+ * @param {Request} request - the request to run
199
+ * @param {next} function - the next fuction
200
+ * @param {object} options - additional options
201
+
202
+ * @returns {Promise<any>} - the result
203
+ */
204
+
205
+ async run() {
206
+ const arg1 = arguments[0];
207
+ if (typeof arg1 === "object") {
208
+ switch (arg1.constructor.name) {
209
+ case "Request":
210
+ case "NoaRequest":
211
+ const req = arg1;
212
+ const next = arguments[1];
213
+
214
+ if (req.query?.UPDATE || req.query?.INSERT || req.query?.DELETE) {
215
+ return next();
216
+ }
217
+
218
+ req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
219
+ req.cacheKey = this.createKey(req, req.cacheOptions.key);
220
+ req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
221
+ if (await this.has(req.cacheKey)) {
222
+ return this.get(req.cacheKey);
223
+ }
224
+ const response = await next();
225
+ req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, req.params);
226
+ await this.set(req.cacheKey, response, req.cacheOptions);
227
+ return response;
228
+ case "cds.ql":
229
+ const query = arg1;
230
+ const srv = arguments[1];
231
+
232
+ if (query.SELECT) {
233
+
234
+ let options = {
235
+ ttl: 0,
236
+ tags: [],
237
+ key: { template: '{hash}' },
238
+ ...(arguments[2] || {}),
239
+ };
240
+ query.cacheKey = this.createKey(query, options.key);
241
+ if (await this.has(query.cacheKey)) {
242
+ return this.get(query.cacheKey);
243
+ }
244
+ const data = await srv.run(query);
245
+ options.tags = this.resolveTags(options.tags, data, query.params);
246
+ await this.set(query.cacheKey, data, options);
247
+ return data;
248
+ } else {
249
+ return srv.run(query);
250
+ }
251
+
252
+ }
253
+ }
254
+ return super.run(arg1);
255
+ }
256
+
257
+ /**
258
+ * Wraps an async function and caches the result
259
+ *
260
+ * @param {string} key - the key to cache
261
+ * @param {function} asyncFunction - the async function to cache
262
+ * @param {object} options - additional options
263
+ * @returns {function} - the wrapped function
264
+ */
265
+ wrap(key, asyncFunction, options = {}) {
266
+ const cacheKey = this.createKey(key, options.key);
267
+ return async (...args) => {
268
+ if (await this.has(cacheKey)) {
269
+ return this.get(cacheKey);
270
+ }
271
+ const response = await asyncFunction(...args);
272
+ await this.set(cacheKey, response, options);
273
+ return response;
274
+ }
275
+ }
276
+
277
+ async set(key, value, options = {}) {
278
+ const wrappedValue = {
279
+ value,
280
+ tags: options.tags || [],
281
+ timestamp: Date.now()
282
+ };
283
+ await this.send('SET', {
284
+ key: this.createKey(key, options.key),
285
+ value: wrappedValue,
286
+ ttl: options.ttl || 0
287
+ });
288
+ }
289
+
290
+ async get(key) {
291
+ const wrappedValue = await this.send('GET', { key: this.createKey(key) });
292
+ return wrappedValue?.value;
293
+ }
294
+
295
+ async has(key) {
296
+ return this.cache.has(this.createKey(key));
297
+ }
298
+
299
+ async delete(key) {
300
+ await this.send('DELETE', { key: this.createKey(key) });
301
+ }
302
+
303
+ async clear() {
304
+ await this.send('CLEAR');
305
+ }
306
+
307
+
308
+ async deleteByTag(tag) {
309
+ for await (const [key, wrappedValue] of this.iterator()) {
310
+ if (wrappedValue?.tags?.includes(tag)) {
311
+ await this.delete(key);
312
+ }
313
+ }
314
+ }
315
+
316
+ // Metadata
317
+ async metadata(key) {
318
+ const wrappedValue = await this.send('GET', { key: this.createKey(key) });
319
+ if (!wrappedValue) return null;
320
+
321
+ const { value, ...metadata } = wrappedValue;
322
+ return metadata;
323
+ }
324
+
325
+ async tags(key) {
326
+ const wrappedValue = await this.send('GET', { key: this.createKey(key) });
327
+ return wrappedValue?.tags || [];
328
+ }
329
+
330
+ // Iterators
331
+ async *iterator() {
332
+ for await (const [key, value] of this.cache.iterator()) {
333
+ if (typeof value === "string") {
334
+ yield [key, JSON.parse(value)];
335
+ } else {
336
+ yield [key, value];
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Resolves tags from tag configurations and data
343
+ * @param {Array} tagConfigs - Array of tag configuration objects
344
+ * @param {Object|Array} data - Data object(s) to extract data values from
345
+ * @param {Object} params - Parameters object to extract values from
346
+ * @returns {string[]} Array of resolved tags
347
+ */
348
+ 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
+ }
358
+
359
+ // Convert data to array if single object
360
+ const dataArray = data ? (Array.isArray(data) ? data : [data]) : [];
361
+
362
+ // Process each tag configuration
363
+ tagConfigs.forEach(config => {
364
+ 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])
415
+ .filter(Boolean);
416
+
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
+ }
427
+ }
428
+ });
429
+
430
+ // Remove duplicates
431
+ return [...new Set(resolvedTags)];
432
+ }
433
+
434
+ // Basic cache operations
435
+ createKey(keyOrObject, options = {}) {
436
+
437
+ // If the key is a string, use it
438
+ if (typeof keyOrObject === "string") {
439
+ return keyOrObject;
440
+ }
441
+
442
+ // Otherwise, create a key based on the object
443
+ if (typeof keyOrObject === "object") {
444
+
445
+ // If the key is provided in the options, use it
446
+ if (keyOrObject.cacheKey) {
447
+ return keyOrObject.cacheKey;
448
+ }
449
+
450
+ switch (keyOrObject.constructor.name) {
451
+ case "Request":
452
+ case "NoaRequest":
453
+
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
461
+ });
462
+ case "cds.ql":
463
+ if (keyOrObject.SELECT) {
464
+ return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { query: keyOrObject });
465
+ } else {
466
+ return undefined;
467
+ }
468
+ default:
469
+ return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { data: keyOrObject });
470
+ }
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Creates a cache key based on configuration and context
476
+ * @param {Object} keyConfig - Key configuration object
477
+ * @param {Object} context - Context containing data, params, and request info
478
+ * @returns {string} Generated cache key
479
+ */
480
+ createCacheKey(keyConfig = {}, context = {}) {
481
+ const { data, params, req, query, locale, user, tenant } = context;
482
+
483
+ // If a static key value is provided, use it
484
+ if (keyConfig.value) {
485
+ return keyConfig.value;
486
+ }
487
+
488
+ let keyValue = '';
489
+
490
+ const hashParts = [
491
+ ...(data ? [data] : []),
492
+ ...(params ? [params] : []),
493
+ ...(query ? [query] : [])
494
+ ];
495
+
496
+ const createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
497
+
498
+ // Handle template with placeholders
499
+ if (keyConfig.template) {
500
+ const contextVars = {
501
+ tenant: tenant || 'global',
502
+ user: user || 'anonymous',
503
+ locale: locale || 'en',
504
+ hash: createHash(hashParts)
505
+ };
506
+
507
+ keyValue = keyConfig.template.replace(
508
+ /\{(tenant|user|locale|hash)\}/g,
509
+ (match, variable) => contextVars[variable]
510
+ );
511
+ }
512
+
513
+ // If no key value generated, create hash from input
514
+ if (!keyValue) {
515
+ keyValue = createHash(hashParts);
516
+ }
517
+
518
+ // Combine with prefix/suffix
519
+ return [
520
+ keyConfig.prefix,
521
+ keyValue,
522
+ keyConfig.suffix
523
+ ].filter(Boolean).join('');
524
+ }
525
+
526
+ }
527
+
528
+ module.exports = CachingService;
@@ -0,0 +1,37 @@
1
+ using {cds_caching as stats} from '../index.cds';
2
+
3
+ @path : 'cache-stats'
4
+ //@requires: 'cache-admin'
5
+ service StatisticsService {
6
+ @readonly
7
+ entity Statistics as projection on stats.Statistics;
8
+
9
+ /*
10
+ @readonly
11
+ @cds.persistence.skip
12
+ entity CurrentStats {
13
+ key ID : String default 'current';
14
+ key cache : String;
15
+ hits : Integer default 0;
16
+ misses : Integer default 0;
17
+ sets : Integer default 0;
18
+ deletes : Integer default 0;
19
+ errors : Integer default 0;
20
+ latencies : array of Double; // Rolling window of recent latencies
21
+ }
22
+
23
+ // Action to get stats for a specific time range
24
+ function getStats(period : String enum {
25
+ hourly;
26
+ daily;
27
+ monthly;
28
+ },
29
+ from : DateTime,
30
+ to : DateTime) returns array of Statistics;
31
+
32
+ // Action to get current statistics
33
+ function getCurrentStats() returns CurrentStats;
34
+ // Action to manually trigger stats persistence
35
+ action persistStats() returns Boolean;
36
+ */
37
+ }
@@ -0,0 +1,72 @@
1
+ const cds = require('@sap/cds')
2
+
3
+ class StatisticsService extends cds.ApplicationService {
4
+ async init() {
5
+ const { Statistics, Current } = this.entities
6
+
7
+ // Get reference to cache service
8
+ const cache = await cds.connect.to('caching')
9
+
10
+ // Handle getStats function
11
+ this.on('getStats', async (req) => {
12
+ const { period, from, to } = req.data
13
+ return cache.getStats(period, from, to)
14
+ })
15
+
16
+ // Handle getCurrentStats function
17
+ this.on('getCurrentStats', async () => {
18
+ const stats = await cache.getCurrentStats()
19
+ if (!stats) return null
20
+
21
+ // Convert to CurrentStats entity format
22
+ return {
23
+ id: 'current',
24
+ hits: stats.hits,
25
+ misses: stats.misses,
26
+ sets: stats.sets,
27
+ deletes: stats.deletes,
28
+ errors: stats.errors,
29
+ latencies: stats.latencies
30
+ }
31
+ })
32
+
33
+ // Handle persistStats action
34
+ this.on('persistStats', async (req) => {
35
+ if (!cache.statistics?.persistStats) {
36
+ req.warn('Statistics are not enabled')
37
+ return false
38
+ }
39
+
40
+ await cache.statistics.persistStats()
41
+ return true
42
+ })
43
+
44
+ // Add custom handlers for the entities if needed
45
+ this.before('READ', 'Statistics', req => {
46
+ if (!cache.statistics) {
47
+ req.warn('Statistics are not enabled')
48
+ return []
49
+ }
50
+ })
51
+
52
+ this.on('READ', 'CurrentStats', async req => {
53
+ const stats = await cache.getCurrentStats()
54
+ if (!stats) return []
55
+
56
+ // Convert to CurrentStats entity format
57
+ return {
58
+ id: 'current',
59
+ hits: stats.hits,
60
+ misses: stats.misses,
61
+ sets: stats.sets,
62
+ deletes: stats.deletes,
63
+ errors: stats.errors,
64
+ latencies: stats.latencies
65
+ }
66
+ })
67
+
68
+ await super.init()
69
+ }
70
+ }
71
+
72
+ module.exports = StatisticsService
package/srv/util.js ADDED
@@ -0,0 +1,83 @@
1
+ const cds = require("@sap/cds")
2
+
3
+ const bindFunction = async (service, action, isBound = false) => {
4
+ const cache = await cds.connect.to(action['@cache.service'] || "caching");
5
+ cache.addCachableFunction(action.name.split('.').pop(), action, isBound);
6
+
7
+ service.prepend(function () {
8
+ service.on(action.name.split('.').pop(), async (req, next) => {
9
+ const cache = await cds.connect.to(action['@cache.service'] || "caching");
10
+ const data = await cache.run(req, next, {
11
+ ttl: action['@cache.ttl'],
12
+ tags: action['@cache.tags'],
13
+ key: action['@cache.key']
14
+ });
15
+ return data;
16
+ })
17
+ })
18
+ }
19
+
20
+ const bindEntity = async (service, entity) => {
21
+ service.prepend(function () {
22
+ service.on('READ', entity.name, async (req, next) => {
23
+ const cache = await cds.connect.to(entity['@cache.service'] || "caching");
24
+ const data = await cache.run(req, next, {
25
+ ttl: entity['@cache.ttl'],
26
+ tags: entity['@cache.tags'],
27
+ key: entity['@cache.key']
28
+ });
29
+ return data;
30
+ })
31
+ })
32
+ }
33
+
34
+ const scanCachingAnnotations = async (srvs) => {
35
+ LOG = cds.log('cds-caching')
36
+
37
+ // Grep all app services
38
+ const services = [];
39
+ for (const [name, config] of Object.entries(srvs)) {
40
+ const service = await cds.connect.to(name);
41
+
42
+ if (service.definition?.kind === 'service') {
43
+ services.push(service);
44
+ }
45
+ }
46
+
47
+ // Grep all external services
48
+ for (const [name, config] of Object.entries(cds.env.requires)) {
49
+ if (config.kind === 'odata-v2' || config.kind === 'odata' || config.kind === 'rest') {
50
+ const service = await cds.connect.to(name);
51
+ services.push(service);
52
+ }
53
+ }
54
+
55
+ for (const service of services) {
56
+
57
+ // functions
58
+ for (const [name, action] of Object.entries(service.actions)) {
59
+ if (Object.keys(action).some(key => key.startsWith('@cache')) && action.kind === 'function') {
60
+ LOG._debug && LOG.debug(`Caching enabled for function ${action.name}`);
61
+ await bindFunction(service, action);
62
+ }
63
+ }
64
+
65
+ // entities
66
+ for (const entity of service.entities) {
67
+ if (Object.keys(entity).some(key => key.startsWith('@cache'))) {
68
+ await bindEntity(service, entity);
69
+ LOG._debug && LOG.debug(`Caching enabled for entity ${entity.name}`);
70
+ }
71
+
72
+ // bound functions
73
+ for (const [name, action] of Object.entries(entity.actions || {})) {
74
+ if (Object.keys(action).some(key => key.startsWith('@cache')) && action.kind === 'function') {
75
+ bindFunction(service, action, true);
76
+ LOG._debug && LOG.debug(`Caching enabled for bound function ${action.name}`);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ module.exports = { scanCachingAnnotations }