cds-caching 1.1.0 → 1.2.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 +70 -1
- package/lib/CachingService.d.ts +16 -10
- package/lib/CachingService.js +62 -10
- package/lib/operations/BasicOperations.js +156 -20
- package/lib/operations/CapOperations.js +1 -3
- package/lib/support/CacheStoreManager.js +26 -10
- package/lib/support/optionalRequire.js +45 -0
- package/lib/util.js +1 -1
- package/package.json +39 -12
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
|
|
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)
|
package/lib/CachingService.d.ts
CHANGED
|
@@ -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)
|
package/lib/CachingService.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
async
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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 {
|
|
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
|
|
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 } =
|
|
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
|
|
3
|
+
"version": "1.2.1",
|
|
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,19 +69,20 @@
|
|
|
43
69
|
"examples/app"
|
|
44
70
|
],
|
|
45
71
|
"dependencies": {
|
|
46
|
-
"
|
|
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.
|
|
54
|
-
"@release-it/conventional-changelog": "^10.0.
|
|
55
|
-
"eslint": "^
|
|
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
|
|
58
|
-
"release-it": "^19.
|
|
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
88
|
"node": ">=20"
|