cds-caching 1.2.1 → 1.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.
Files changed (80) hide show
  1. package/README.md +195 -1114
  2. package/app/dashboard/Component-dbg.js +59 -0
  3. package/app/dashboard/Component-dbg.js.map +1 -0
  4. package/app/dashboard/Component.js +2 -0
  5. package/app/dashboard/Component.js.map +1 -0
  6. package/app/dashboard/Component.ts +59 -0
  7. package/app/dashboard/controller/App-dbg.controller.js +132 -0
  8. package/app/dashboard/controller/App-dbg.controller.js.map +1 -0
  9. package/app/dashboard/controller/App.controller.js +2 -0
  10. package/app/dashboard/controller/App.controller.js.map +1 -0
  11. package/app/dashboard/controller/App.controller.ts +147 -0
  12. package/app/dashboard/controller/BaseController-dbg.js +75 -0
  13. package/app/dashboard/controller/BaseController-dbg.js.map +1 -0
  14. package/app/dashboard/controller/BaseController.js +2 -0
  15. package/app/dashboard/controller/BaseController.js.map +1 -0
  16. package/app/dashboard/controller/BaseController.ts +88 -0
  17. package/app/dashboard/controller/Cache-dbg.controller.js +421 -0
  18. package/app/dashboard/controller/Cache-dbg.controller.js.map +1 -0
  19. package/app/dashboard/controller/Cache.controller.js +2 -0
  20. package/app/dashboard/controller/Cache.controller.js.map +1 -0
  21. package/app/dashboard/controller/Cache.controller.ts +490 -0
  22. package/app/dashboard/controller/Main-dbg.controller.js +32 -0
  23. package/app/dashboard/controller/Main-dbg.controller.js.map +1 -0
  24. package/app/dashboard/controller/Main.controller.js +2 -0
  25. package/app/dashboard/controller/Main.controller.js.map +1 -0
  26. package/app/dashboard/controller/Main.controller.ts +31 -0
  27. package/app/dashboard/controller/SingleMetric-dbg.controller.js +306 -0
  28. package/app/dashboard/controller/SingleMetric-dbg.controller.js.map +1 -0
  29. package/app/dashboard/controller/SingleMetric.controller.js +2 -0
  30. package/app/dashboard/controller/SingleMetric.controller.js.map +1 -0
  31. package/app/dashboard/controller/SingleMetric.controller.ts +335 -0
  32. package/app/dashboard/i18n/i18n.properties +61 -0
  33. package/app/dashboard/i18n/i18n_de.properties +3 -0
  34. package/app/dashboard/i18n/i18n_en.properties +3 -0
  35. package/app/dashboard/index-cdn.html +28 -0
  36. package/app/dashboard/index.html +19 -0
  37. package/app/dashboard/manifest.json +145 -0
  38. package/app/dashboard/model/formatter-dbg.js +253 -0
  39. package/app/dashboard/model/formatter-dbg.js.map +1 -0
  40. package/app/dashboard/model/formatter.js +2 -0
  41. package/app/dashboard/model/formatter.js.map +1 -0
  42. package/app/dashboard/model/formatter.ts +275 -0
  43. package/app/dashboard/model/models-dbg.js +13 -0
  44. package/app/dashboard/model/models-dbg.js.map +1 -0
  45. package/app/dashboard/model/models.js +2 -0
  46. package/app/dashboard/model/models.js.map +1 -0
  47. package/app/dashboard/model/models.ts +13 -0
  48. package/app/dashboard/resources/sap-ui-custom-dbg.js +4695 -0
  49. package/app/dashboard/resources/sap-ui-custom-dbg.js.map +1 -0
  50. package/app/dashboard/resources/sap-ui-custom.js +5794 -0
  51. package/app/dashboard/resources/sap-ui-custom.js.map +1 -0
  52. package/app/dashboard/service/CacheStatisticsService-dbg.js +267 -0
  53. package/app/dashboard/service/CacheStatisticsService-dbg.js.map +1 -0
  54. package/app/dashboard/service/CacheStatisticsService.js +2 -0
  55. package/app/dashboard/service/CacheStatisticsService.js.map +1 -0
  56. package/app/dashboard/service/CacheStatisticsService.ts +397 -0
  57. package/app/dashboard/view/App.view.xml +18 -0
  58. package/app/dashboard/view/Cache.view.xml +777 -0
  59. package/app/dashboard/view/Main.view.xml +57 -0
  60. package/app/dashboard/view/SingleMetric.view.xml +337 -0
  61. package/cds-plugin.js +52 -0
  62. package/db/cache-store.cds +12 -0
  63. package/db/statistics.cds +128 -0
  64. package/index.cds +2 -128
  65. package/lib/CachingService.js +24 -10
  66. package/lib/add.js +17 -0
  67. package/lib/operations/AsyncOperations.js +162 -111
  68. package/lib/operations/BasicOperations.js +42 -4
  69. package/lib/operations/CapOperations.js +92 -40
  70. package/lib/support/CacheStatisticsHandler.js +35 -10
  71. package/lib/support/CacheStoreManager.js +29 -0
  72. package/lib/support/KeyManager.js +40 -2
  73. package/lib/support/KeyvCDS.js +149 -0
  74. package/lib/support/MultitenancyDetector.js +25 -0
  75. package/lib/support/RuntimeConfigurationManager.js +69 -37
  76. package/lib/support/StatisticsPersistenceManager.js +18 -2
  77. package/lib/support/Telemetry.js +137 -0
  78. package/lib/util.js +29 -11
  79. package/package.json +43 -11
  80. package/srv/caching-api-service.js +47 -2
@@ -1,4 +1,5 @@
1
1
  const TagResolver = require('../support/TagResolver');
2
+ const telemetry = require('../support/Telemetry');
2
3
  /**
3
4
  * Manages CAP-specific cache operations
4
5
  */
@@ -36,10 +37,11 @@ class CapOperations {
36
37
  stack: error.stack,
37
38
  context: context
38
39
  });
39
-
40
- return {
41
- success: false,
42
- result: null,
40
+ telemetry.recordError({ 'cache.error_operation': operationName });
41
+
42
+ return {
43
+ success: false,
44
+ result: null,
43
45
  error: {
44
46
  message: error.message,
45
47
  operation: operationName,
@@ -51,13 +53,38 @@ class CapOperations {
51
53
 
52
54
  /**
53
55
  * Send a request with caching with read-through capabilities.
54
- *
56
+ *
55
57
  * @param {object} arg1 - the request object
56
58
  * @param {Service} service - the service to send the request to
57
59
  * @param {object} options - the options for the request
58
60
  * @returns {Promise<any>} - the result
59
61
  */
60
62
  async send(request, service, options) {
63
+ const tracer = telemetry.getTracer('cds-caching');
64
+ if (tracer) {
65
+ return tracer.startActiveSpan('cds-caching - send', async (span) => {
66
+ try {
67
+ const result = await this._send(request, service, options);
68
+ const hasCacheKey = typeof result.cacheKey === 'string' && result.cacheKey.length > 0;
69
+ span.setAttribute('cache.hit', result.metadata.hit);
70
+ if (hasCacheKey) span.setAttribute('cache.key', result.cacheKey);
71
+ span.setAttribute('cache.operation', 'send');
72
+ span.setAttribute('cache.operation_type', 'read_through');
73
+ if (hasCacheKey && !result.metadata.hit) span.setAttribute('cache.ttl_ms', options?.ttl || 0);
74
+ return result;
75
+ } catch (e) {
76
+ span.setStatus({ code: telemetry.SpanStatusCode.ERROR, message: e.message });
77
+ throw e;
78
+ } finally {
79
+ span.end();
80
+ }
81
+ });
82
+ }
83
+ return this._send(request, service, options);
84
+ }
85
+
86
+ /** @private */
87
+ async _send(request, service, options) {
61
88
  const requestOptions = {
62
89
  ttl: 0,
63
90
  ...(options || {}),
@@ -140,9 +167,9 @@ class CapOperations {
140
167
  );
141
168
 
142
169
  if (getResult.success && getResult.result?.value !== undefined) {
143
- return {
144
- result: getResult.result.value,
145
- cacheKey: key,
170
+ return {
171
+ result: getResult.result.value,
172
+ cacheKey: key,
146
173
  metadata: { hit: true, latency: latency },
147
174
  cacheErrors: cacheErrors
148
175
  };
@@ -153,7 +180,7 @@ class CapOperations {
153
180
  try {
154
181
  const response = await service.send(request);
155
182
  const totalLatency = this.getElapsedMs(startTime);
156
-
183
+
157
184
  // Safely record miss statistics
158
185
  const missStatsResult = await this.safeCacheOperation(
159
186
  () => this.statistics.recordMiss(totalLatency, key, metadata),
@@ -170,7 +197,7 @@ class CapOperations {
170
197
  tags: this.tagResolver.resolveTags(requestOptions.tags, response, { ...request.params, user: request.user?.id, tenant: request.tenant, locale: request.locale, hash: this.keyManager.createContentHash(request) }),
171
198
  timestamp: Date.now()
172
199
  };
173
-
200
+
174
201
  const setResult = await this.safeCacheOperation(
175
202
  () => this.cache.send("SET", { key, value: wrappedValue, ttl: requestOptions.ttl || 0 }),
176
203
  'set',
@@ -180,9 +207,9 @@ class CapOperations {
180
207
  cacheErrors.push(setResult.error);
181
208
  }
182
209
 
183
- return {
184
- result: response,
185
- cacheKey: key,
210
+ return {
211
+ result: response,
212
+ cacheKey: key,
186
213
  metadata: { hit: false, latency: totalLatency },
187
214
  cacheErrors: cacheErrors
188
215
  };
@@ -206,6 +233,31 @@ class CapOperations {
206
233
  * @returns {Promise<any>} - the result
207
234
  */
208
235
  async run() {
236
+ const tracer = telemetry.getTracer('cds-caching');
237
+ if (tracer) {
238
+ return tracer.startActiveSpan('cds-caching - run', async (span) => {
239
+ try {
240
+ const result = await this._run(...arguments);
241
+ if (result?.cacheKey) {
242
+ span.setAttribute('cache.hit', result.metadata?.hit ?? false);
243
+ span.setAttribute('cache.key', result.cacheKey);
244
+ }
245
+ span.setAttribute('cache.operation', 'run');
246
+ span.setAttribute('cache.operation_type', 'read_through');
247
+ return result;
248
+ } catch (e) {
249
+ span.setStatus({ code: telemetry.SpanStatusCode.ERROR, message: e.message });
250
+ throw e;
251
+ } finally {
252
+ span.end();
253
+ }
254
+ });
255
+ }
256
+ return this._run(...arguments);
257
+ }
258
+
259
+ /** @private */
260
+ async _run() {
209
261
  const arg1 = arguments[0];
210
262
  if (typeof arg1 === "object") {
211
263
  switch (arg1.constructor.name) {
@@ -226,14 +278,14 @@ class CapOperations {
226
278
 
227
279
  // Track cache operation timing
228
280
  const startTime = process.hrtime();
229
-
281
+
230
282
  // Safely get value from cache
231
283
  const getResult = await this.safeCacheOperation(
232
284
  () => this.cache.send("GET", { key: req.cacheKey }),
233
285
  'get',
234
286
  { key: req.cacheKey, serviceName: req.target?.name }
235
287
  );
236
-
288
+
237
289
  const cacheHit = getResult.success && getResult.result?.value !== undefined;
238
290
  const cacheLatency = this.getElapsedMs(startTime);
239
291
  const metadata = this.extractMetadataFromRequest(req);
@@ -249,11 +301,11 @@ class CapOperations {
249
301
  if (!hitStatsResult.success) {
250
302
  cacheErrors.push(hitStatsResult.error);
251
303
  }
252
-
304
+
253
305
  req.res?.setHeader('x-sap-cap-cache', "hit");
254
- return {
255
- result: getResult.result.value,
256
- cacheKey: req.cacheKey,
306
+ return {
307
+ result: getResult.result.value,
308
+ cacheKey: req.cacheKey,
257
309
  metadata: { hit: true, latency: cacheLatency },
258
310
  cacheErrors: cacheErrors
259
311
  };
@@ -272,7 +324,7 @@ class CapOperations {
272
324
  if (!missStatsResult.success) {
273
325
  cacheErrors.push(missStatsResult.error);
274
326
  }
275
-
327
+
276
328
  req.res?.setHeader('x-sap-cap-cache', "miss");
277
329
 
278
330
  // Safely store in cache
@@ -281,7 +333,7 @@ class CapOperations {
281
333
  tags: this.tagResolver.resolveTags(req.cacheOptions.tags, response, { ...req.params, hash: this.keyManager.createContentHash(req) }),
282
334
  timestamp: Date.now()
283
335
  };
284
-
336
+
285
337
  const setResult = await this.safeCacheOperation(
286
338
  () => this.cache.send("SET", { key: req.cacheKey, value: wrappedValue, ttl: req.cacheOptions.ttl || 0 }),
287
339
  'set',
@@ -291,10 +343,10 @@ class CapOperations {
291
343
  if (!setResult.success) {
292
344
  cacheErrors.push(setResult.error);
293
345
  }
294
-
295
- return {
296
- result: response,
297
- cacheKey: req.cacheKey,
346
+
347
+ return {
348
+ result: response,
349
+ cacheKey: req.cacheKey,
298
350
  metadata: { hit: false, latency: totalLatency },
299
351
  cacheErrors: cacheErrors
300
352
  };
@@ -323,14 +375,14 @@ class CapOperations {
323
375
 
324
376
  // Track cache operation timing
325
377
  const startTime = process.hrtime();
326
-
378
+
327
379
  // Safely check if key exists in cache
328
380
  const hasCachedValueResult = await this.safeCacheOperation(
329
381
  () => this.cache.has(query.cacheKey),
330
382
  'has',
331
383
  { key: query.cacheKey, serviceName: srv?.name }
332
384
  );
333
-
385
+
334
386
  const hasCachedValue = hasCachedValueResult.success && hasCachedValueResult.result;
335
387
  const cacheLatency = this.getElapsedMs(startTime);
336
388
  const metadata = {
@@ -369,14 +421,14 @@ class CapOperations {
369
421
  'get',
370
422
  { key: query.cacheKey }
371
423
  );
372
-
424
+
373
425
  if (getResult.success && getResult.result?.value !== undefined) {
374
- return {
375
- result: getResult.result.value,
376
- cacheKey: query.cacheKey,
377
- metadata: { hit: true, latency: cacheLatency },
378
- cacheErrors: cacheErrors
379
- };
426
+ return {
427
+ result: getResult.result.value,
428
+ cacheKey: query.cacheKey,
429
+ metadata: { hit: true, latency: cacheLatency },
430
+ cacheErrors: cacheErrors
431
+ };
380
432
  }
381
433
  }
382
434
 
@@ -401,7 +453,7 @@ class CapOperations {
401
453
  tags: this.tagResolver.resolveTags(options.tags, data, { ...query.params, hash: this.keyManager.createKey(query, { serviceName: srv?.name, template: '{hash}' }) }),
402
454
  timestamp: Date.now()
403
455
  };
404
-
456
+
405
457
  const setResult = await this.safeCacheOperation(
406
458
  () => this.cache.send("SET", { key: query.cacheKey, value: wrappedValue, ttl: options.ttl || 0 }),
407
459
  'set',
@@ -412,10 +464,10 @@ class CapOperations {
412
464
  }
413
465
 
414
466
  this.log.info('REEEESULT', { setResult, cacheErrors, wrappedValue });
415
-
416
- return {
417
- result: data,
418
- cacheKey: query.cacheKey,
467
+
468
+ return {
469
+ result: data,
470
+ cacheKey: query.cacheKey,
419
471
  metadata: { hit: false, latency: totalLatency },
420
472
  cacheErrors: cacheErrors
421
473
  };
@@ -535,4 +587,4 @@ class CapOperations {
535
587
  }
536
588
  }
537
589
 
538
- module.exports = CapOperations;
590
+ module.exports = CapOperations;
@@ -1,6 +1,8 @@
1
1
  const cds = require('@sap/cds');
2
2
  const StatisticsPersistenceManager = require('./StatisticsPersistenceManager');
3
+ const telemetry = require('./Telemetry');
3
4
  const { query } = require('@sap/cds/lib/env/defaults');
5
+ const { isPluginModelAvailable } = require('../util');
4
6
 
5
7
  /**
6
8
  * Class to handle all things caching statistics.
@@ -118,6 +120,9 @@ class CacheStatisticsHandler {
118
120
  this.recordKeyAccess(key, 'hit', { ...metadata, latency });
119
121
  }
120
122
 
123
+ // Emit OTel metric (independent of metricsEnabled)
124
+ telemetry.recordHit(latency, { 'cache.name': this.options.cache });
125
+
121
126
  // Log for debugging
122
127
  this.log.debug(`Recorded HIT for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
123
128
  }
@@ -141,6 +146,9 @@ class CacheStatisticsHandler {
141
146
  this.recordKeyAccess(key, 'miss', { ...metadata, latency });
142
147
  }
143
148
 
149
+ // Emit OTel metric (independent of metricsEnabled)
150
+ telemetry.recordMiss(latency, { 'cache.name': this.options.cache });
151
+
144
152
  // Log for debugging
145
153
  this.log.debug(`Recorded MISS for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
146
154
  }
@@ -164,6 +172,9 @@ class CacheStatisticsHandler {
164
172
  this.recordKeyAccess(key, 'set', { ...metadata, latency });
165
173
  }
166
174
 
175
+ // Emit OTel metric (independent of metricsEnabled)
176
+ telemetry.recordSet({ 'cache.name': this.options.cache });
177
+
167
178
  // Log for debugging
168
179
  this.log.debug(`Recorded SET for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
169
180
  }
@@ -187,6 +198,9 @@ class CacheStatisticsHandler {
187
198
  this.recordKeyAccess(key, 'delete', { ...metadata, latency });
188
199
  }
189
200
 
201
+ // Emit OTel metric (independent of metricsEnabled)
202
+ telemetry.recordDelete({ 'cache.name': this.options.cache });
203
+
190
204
  // Log for debugging
191
205
  this.log.debug(`Recorded DELETE for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
192
206
  }
@@ -210,6 +224,9 @@ class CacheStatisticsHandler {
210
224
  this.recordKeyAccess(key, 'nativeSet', metadata);
211
225
  }
212
226
 
227
+ // Emit OTel metric (independent of metricsEnabled)
228
+ telemetry.recordSet({ 'cache.name': this.options.cache });
229
+
213
230
  // Log for debugging
214
231
  this.log.debug(`Recorded NATIVE SET for key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
215
232
  }
@@ -257,6 +274,9 @@ class CacheStatisticsHandler {
257
274
  this.recordKeyAccess(key, 'nativeDelete', metadata);
258
275
  }
259
276
 
277
+ // Emit OTel metric (independent of metricsEnabled)
278
+ telemetry.recordDelete({ 'cache.name': this.options.cache });
279
+
260
280
  // Log for debugging
261
281
  this.log.debug(`Recorded NATIVE DELETE for key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
262
282
  }
@@ -301,6 +321,9 @@ class CacheStatisticsHandler {
301
321
  if (this.options.metricsEnabled) {
302
322
  this.stats.current.errors++;
303
323
  }
324
+
325
+ // Emit OTel metric (independent of metricsEnabled)
326
+ telemetry.recordError({ 'cache.name': this.options.cache });
304
327
  }
305
328
 
306
329
  /**
@@ -652,8 +675,8 @@ class CacheStatisticsHandler {
652
675
  * @returns {Array} - array of all tracked keys with enhanced data
653
676
  */
654
677
  async getAllTrackedKeys() {
655
- // If key metrics is enabled, try to get from database first
656
- if (this.options.keyMetricsEnabled) {
678
+ // If key metrics is enabled and the model is available, try to get from database first
679
+ if (this.options.keyMetricsEnabled && isPluginModelAvailable()) {
657
680
  try {
658
681
  const dbKeys = await SELECT.from("plugin_cds_caching_KeyMetrics")
659
682
  .where({ cache: this.options.cache });
@@ -729,8 +752,8 @@ class CacheStatisticsHandler {
729
752
  * @returns {object|null} - detailed key information or null if not found
730
753
  */
731
754
  async getKeyDetails(keyName) {
732
- // If key metrics is enabled, try to get from database first
733
- if (this.options.keyMetricsEnabled) {
755
+ // If key metrics is enabled and the model is available, try to get from database first
756
+ if (this.options.keyMetricsEnabled && isPluginModelAvailable()) {
734
757
  try {
735
758
  const keyDetails = await SELECT.one.from("plugin_cds_caching_KeyMetrics")
736
759
  .where({ cache: this.options.cache, keyName: keyName });
@@ -938,7 +961,7 @@ class CacheStatisticsHandler {
938
961
  }
939
962
 
940
963
  async getMetrics(period = 'hourly', from, to) {
941
- if (!this.options.metricsEnabled) return null;
964
+ if (!this.options.metricsEnabled || !isPluginModelAvailable()) return null;
942
965
 
943
966
  const query = SELECT.from("plugin_cds_caching_Metrics")
944
967
  .where({ period: period });
@@ -952,7 +975,7 @@ class CacheStatisticsHandler {
952
975
  }
953
976
 
954
977
  async getKeyMetrics(key, from, to) {
955
- if (!this.options.keyMetricsEnabled) return null;
978
+ if (!this.options.keyMetricsEnabled || !isPluginModelAvailable()) return null;
956
979
  const query = SELECT.from("plugin_cds_caching_KeyMetrics")
957
980
  .where({ cache: this.options.cache, keyName: key });
958
981
 
@@ -1093,14 +1116,16 @@ class CacheStatisticsHandler {
1093
1116
  }
1094
1117
 
1095
1118
  async clearMetrics() {
1096
- await this.persistenceManager.deleteMetrics();
1097
- // Also reset the current stats
1119
+ if (isPluginModelAvailable()) {
1120
+ await this.persistenceManager.deleteMetrics();
1121
+ }
1098
1122
  this.resetCurrentStats();
1099
1123
  }
1100
1124
 
1101
1125
  async clearKeyMetrics() {
1102
- await this.persistenceManager.deleteKeyMetrics();
1103
- // Also reset the current stats
1126
+ if (isPluginModelAvailable()) {
1127
+ await this.persistenceManager.deleteKeyMetrics();
1128
+ }
1104
1129
  this.resetCurrentStats();
1105
1130
  }
1106
1131
 
@@ -1,5 +1,7 @@
1
1
  const { Keyv } = require('keyv');
2
2
  const { requireOptional, requireAnyOptional } = require('./optionalRequire');
3
+ const KeyvCDS = require('./KeyvCDS');
4
+ const { isMultitenantMode } = require('./MultitenancyDetector');
3
5
 
4
6
  /**
5
7
  * Manages cache store initialization and configuration
@@ -81,6 +83,33 @@ class CacheStoreManager {
81
83
  ...(options.credentials?.table ? { table: options.credentials?.table } : {}),
82
84
  ...{ throwOnConnectErrors: options.throwOnErrors, useKeyPrefix: false, throwOnErrors: options.throwOnErrors }
83
85
  });
86
+ case "cds":
87
+ return new KeyvCDS(options);
88
+ case "hana":
89
+ if (isMultitenantMode()) {
90
+ this.log.warn(
91
+ `Store 'hana' uses direct connections that bypass CAP's tenant-aware connection management. ` +
92
+ `Consider using store: 'cds' instead for multi-tenant deployments.`
93
+ );
94
+ }
95
+ const KeyvHana = requireOptional('keyv-hana', { feature: 'store', value: 'hana' });
96
+ // BTP HANA service bindings use "user"/"password"; keyv-hana expects "uid"/"pwd"
97
+ return new KeyvHana({
98
+ host: options.credentials?.host,
99
+ port: options.credentials?.port,
100
+ uid: options.credentials?.uid || options.credentials?.user,
101
+ pwd: options.credentials?.pwd || options.credentials?.password,
102
+ schema: options.credentials?.schema,
103
+ table: options.credentials?.table || 'KEYV',
104
+ keySize: options.credentials?.keySize || 255,
105
+ createTable: options.credentials?.createTable ?? false,
106
+ connectOptions: {
107
+ ...(options.credentials?.certificate ? { ca: options.credentials.certificate } : {}),
108
+ ...(options.credentials?.encrypt !== undefined ? { encrypt: options.credentials.encrypt } : {}),
109
+ ...(options.credentials?.sslValidateCertificate !== undefined ? { sslValidateCertificate: options.credentials.sslValidateCertificate } : {}),
110
+ ...options.credentials?.connectOptions,
111
+ },
112
+ });
84
113
  default:
85
114
  return new Map();
86
115
  }
@@ -78,13 +78,34 @@ class KeyManager {
78
78
  case "Request":
79
79
  case "NoaRequest":
80
80
  case "ODataRequest":
81
- // Only hash the request information, not user/tenant/locale
81
+ // Hash request information including the HTTP URL which
82
+ // contains $filter/$select/$orderby query parameters.
83
+ //
84
+ // Why req.query (CQN) is unreliable for hashing:
85
+ // NoaRequest ("New OData Adapter Request", defined in
86
+ // @sap/cds/libx/odata/ODataAdapter.js) carries cds.ql
87
+ // query objects where SELECT.where lives on the prototype
88
+ // chain, not as an own property. JSON.stringify only
89
+ // serializes own enumerable properties, so the where clause
90
+ // from $filter is silently dropped — two requests that
91
+ // differ only by $filter produce the same hash.
92
+ // This does NOT affect programmatic CQL (e.g.
93
+ // SELECT.from(Entity).where(...)) where `where` is an own
94
+ // property. It only affects CQN built from OData URL
95
+ // parsing in the adapter pipeline.
96
+ //
97
+ // The HTTP URL (req._.req.url / req.http.req.url) always
98
+ // contains the full query string as a plain string and is
99
+ // the reliable source for all OData query options. When a
100
+ // URL is available it supersedes req.query.
101
+ const url = this.normalizeUrl(keyOrObject._?.req?.url || keyOrObject.http?.req?.url);
82
102
  return this.createHash({
83
103
  method: keyOrObject.method,
84
104
  path: keyOrObject.path || keyOrObject.http?.req?.path,
105
+ url,
85
106
  data: keyOrObject.data,
86
107
  params: keyOrObject.params,
87
- query: keyOrObject.query,
108
+ query: url ? undefined : keyOrObject.query,
88
109
  event: keyOrObject.event,
89
110
  target: keyOrObject.target?.name
90
111
  });
@@ -105,6 +126,23 @@ class KeyManager {
105
126
  return this.createHash({ content: keyOrObject });
106
127
  }
107
128
 
129
+ /**
130
+ * Normalize a URL by sorting its query parameters alphabetically by key.
131
+ * Ensures the same parameters in different order produce the same cache key.
132
+ * @param {string} url - URL string to normalize
133
+ * @returns {string|undefined} - Normalized URL or undefined if input is falsy
134
+ */
135
+ normalizeUrl(url) {
136
+ if (!url) return undefined;
137
+ try {
138
+ const urlObj = new URL(url, 'http://localhost');
139
+ urlObj.searchParams.sort();
140
+ return urlObj.pathname + (urlObj.searchParams.size ? '?' + urlObj.searchParams.toString() : '');
141
+ } catch {
142
+ return url;
143
+ }
144
+ }
145
+
108
146
  /**
109
147
  * Serialize function arguments for cache key generation
110
148
  * @param {any} arg - argument to serialize
@@ -0,0 +1,149 @@
1
+ const cds = require('@sap/cds');
2
+
3
+ /**
4
+ * Keyv-compatible store adapter that uses CAP's managed DB connection (CQL).
5
+ *
6
+ * Benefits:
7
+ * - Works with any DB backend CAP supports (HANA, PostgreSQL, SQLite)
8
+ * - In MTX mode, automatically routes to the correct tenant's HDI container
9
+ * - No separate credentials needed — reuses the app's existing DB connection
10
+ */
11
+ class KeyvCDS {
12
+ constructor(options = {}) {
13
+ this.log = cds.log('cds-caching');
14
+ this._entity = null;
15
+ // Keyv checks store.opts.dialect to enable iterator support
16
+ this.opts = { dialect: 'sqlite', url: '' };
17
+ }
18
+
19
+ /**
20
+ * Lazily resolve the CacheStore entity and DB service.
21
+ * Both are only available after the CDS server has fully bootstrapped.
22
+ * @private
23
+ */
24
+ async _getDB() {
25
+ if (!cds.db) await cds.connect.to('db');
26
+ const db = cds.db;
27
+ if (!this._entity) {
28
+ this._entity = cds.entities('plugin.cds_caching')?.CacheStore;
29
+ }
30
+ return { db, entity: this._entity };
31
+ }
32
+
33
+ /**
34
+ * Get a value by key.
35
+ * Returns undefined if the key does not exist or is expired.
36
+ */
37
+ async get(key) {
38
+ try {
39
+ const { db, entity } = await this._getDB();
40
+ if (!db || !entity) return undefined;
41
+ const row = await db.run(SELECT.one.from(entity).where({ ID: key }));
42
+ if (!row) return undefined;
43
+ if (row.expiresAt && row.expiresAt <= Date.now()) {
44
+ await db.run(DELETE.from(entity).where({ ID: key }));
45
+ return undefined;
46
+ }
47
+ return row.value;
48
+ } catch (error) {
49
+ this.log.error(`KeyvCDS get error for key ${key}:`, error);
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Set a key-value pair with optional TTL (in milliseconds).
56
+ */
57
+ async set(key, value, ttl) {
58
+ try {
59
+ const { db, entity } = await this._getDB();
60
+ if (!db || !entity) return;
61
+ const expiresAt = ttl ? Date.now() + ttl : null;
62
+ const existing = await db.run(SELECT.one.from(entity).columns('ID').where({ ID: key }));
63
+ if (existing) {
64
+ await db.run(UPDATE(entity).set({ value, expiresAt }).where({ ID: key }));
65
+ } else {
66
+ await db.run(INSERT.into(entity).entries({ ID: key, value, expiresAt }));
67
+ }
68
+ } catch (error) {
69
+ this.log.error(`KeyvCDS set error for key ${key}:`, error);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Delete a key. Returns true if the key existed, false otherwise.
75
+ */
76
+ async delete(key) {
77
+ try {
78
+ const { db, entity } = await this._getDB();
79
+ if (!db || !entity) return false;
80
+ const result = await db.run(DELETE.from(entity).where({ ID: key }));
81
+ return !!result;
82
+ } catch (error) {
83
+ this.log.error(`KeyvCDS delete error for key ${key}:`, error);
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Clear all entries from the store.
90
+ */
91
+ async clear() {
92
+ try {
93
+ const { db, entity } = await this._getDB();
94
+ if (!db || !entity) return;
95
+ await db.run(DELETE.from(entity));
96
+ } catch (error) {
97
+ this.log.error('KeyvCDS clear error:', error);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Check if a key exists and is not expired.
103
+ */
104
+ async has(key) {
105
+ try {
106
+ const { db, entity } = await this._getDB();
107
+ if (!db || !entity) return false;
108
+ const row = await db.run(SELECT.one.from(entity).columns('ID', 'expiresAt').where({ ID: key }));
109
+ if (!row) return false;
110
+ if (row.expiresAt && row.expiresAt <= Date.now()) {
111
+ await db.run(DELETE.from(entity).where({ ID: key }));
112
+ return false;
113
+ }
114
+ return true;
115
+ } catch (error) {
116
+ this.log.error(`KeyvCDS has error for key ${key}:`, error);
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Async iterator over all non-expired [key, value] pairs.
123
+ */
124
+ async *iterator() {
125
+ try {
126
+ const { db, entity } = await this._getDB();
127
+ if (!db || !entity) return;
128
+ const rows = await db.run(SELECT.from(entity));
129
+ const now = Date.now();
130
+ for (const row of rows) {
131
+ if (row.expiresAt && row.expiresAt <= now) {
132
+ continue;
133
+ }
134
+ yield [row.ID, row.value];
135
+ }
136
+ } catch (error) {
137
+ this.log.error('KeyvCDS iterator error:', error);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * No-op — CAP manages the DB connection lifecycle.
143
+ */
144
+ async disconnect() {
145
+ // Nothing to do; the DB connection is managed by the CAP runtime
146
+ }
147
+ }
148
+
149
+ module.exports = KeyvCDS;
@@ -0,0 +1,25 @@
1
+ const cds = require('@sap/cds');
2
+
3
+ /**
4
+ * Detects whether the application is running in multi-tenant mode (MTX)
5
+ * and whether a tenant context is currently available.
6
+ */
7
+
8
+ /**
9
+ * Check if the application is configured for multi-tenancy.
10
+ * Returns true when @sap/cds-mtxs sets cds.env.requires.multitenancy.
11
+ * @returns {boolean}
12
+ */
13
+ function isMultitenantMode() {
14
+ return !!(cds.env.requires?.multitenancy || cds.env.requires?.['cds.xt.SaasProvisioningService']);
15
+ }
16
+
17
+ /**
18
+ * Check if a tenant context is currently available (i.e., we are inside a tenant request).
19
+ * @returns {boolean}
20
+ */
21
+ function hasTenantContext() {
22
+ return !!cds.context?.tenant;
23
+ }
24
+
25
+ module.exports = { isMultitenantMode, hasTenantContext };