cds-caching 0.3.3 → 1.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.
@@ -1,587 +0,0 @@
1
- const cds = require('@sap/cds');
2
- const { Keyv } = require('keyv');
3
- const { default: KeyvRedis } = require('@keyv/redis');
4
- const { default: KeyvSqlite } = require('@keyv/sqlite');
5
- const { default: KeyvLz4 } = require('@keyv/compress-lz4');
6
- const { default: KeyvGzip } = require('@keyv/compress-gzip');
7
- const crypto = require('crypto');
8
- const CacheStatisticsHandler = require('./CacheStatisticsHandler');
9
-
10
- class CachingService extends cds.Service {
11
-
12
- // Store annotated functions with metadata as requests do not contain a target
13
- cacheAnnotatedFunctions = {
14
- bound: [],
15
- unbound: []
16
- };
17
-
18
- init() {
19
- super.init()
20
- this.LOG = cds.log('cds-caching')
21
- this.options = this.options || {
22
- store: null,
23
- compression: null,
24
- credentials: {}
25
- };
26
-
27
- let store;
28
-
29
- switch (this.options.store) {
30
- case "sqlite":
31
- store = new KeyvSqlite({
32
- url: this.options.credentials?.url,
33
- table: this.options.credentials?.table || 'cache',
34
- busyTimeout: this.options.credentials?.busyTimeout || 10000
35
- });
36
- break;
37
- case "redis":
38
- store = new KeyvRedis({
39
- ...this.options.credentials,
40
- // Redis, Hyperscaler Option on BTP provides a URI
41
- ...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
42
- });
43
-
44
- cds.once("shutdown", async () => {
45
- if (this.cache.store?.disconnect) {
46
- await this.cache.store.disconnect().catch((err) => {
47
- this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
48
- });
49
- }
50
- });
51
- break;
52
- default:
53
- store = new Map();
54
- break;
55
- }
56
-
57
- let cacheOptions = {
58
- namespace: this.options.namespace || this.name,
59
- store: store,
60
- compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
61
- }
62
-
63
-
64
- this.cache = new Keyv(cacheOptions);
65
- this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
66
-
67
- this.cache.on('error', err => {
68
- this.LOG._error && this.LOG.error('Cache error', err);
69
- });
70
-
71
-
72
- this.on('SET', async (event) => {
73
- this.LOG._debug && this.LOG.debug(`SET ${event.data.key}`);
74
- if (typeof event.data.value === "object") {
75
- event.data.value = JSON.stringify(event.data.value);
76
- }
77
- await this.cache.set(event.data.key, event.data.value, (event.data.ttl || 0))
78
- });
79
-
80
- this.on('GET', async (event) => {
81
- const value = await this.cache.get(event.data.key);
82
- this.LOG._debug && this.LOG.debug(`GET ${event.data.key}`);
83
- if (typeof value === "string") {
84
- return JSON.parse(value);
85
- }
86
- return value;
87
- });
88
-
89
- this.on('DELETE', async (event) => {
90
- this.LOG._debug && this.LOG.debug(`DELETE ${event.data.key}`);
91
- await this.cache.delete(event.data.key);
92
- });
93
-
94
- this.on('CLEAR', async (event) => {
95
- this.LOG._debug && this.LOG.debug(`CLEAR`);
96
- await this.cache.clear();
97
- });
98
-
99
- // Initialize statistics if enabled
100
- if (this.options.statistics?.enabled) {
101
- this.statistics = new CacheStatisticsHandler({
102
- enabled: true,
103
- ...(this.options.statistics.persistenceInterval ? { persistenceInterval: this.options.statistics.persistenceInterval } : {}),
104
- ...(this.options.statistics.maxLatencies ? { maxLatencies: this.options.statistics.maxLatencies } : {}),
105
- getItemCount: async () => {
106
- let count = 0;
107
- for await (const _ of this.iterator()) {
108
- count++;
109
- }
110
- return count;
111
- }
112
- });
113
-
114
- // Enhance methods with statistics
115
- this.after('GET', async (result, req) => {
116
- const startTime = process.hrtime();
117
- try {
118
- if (result === undefined) {
119
- this.statistics.recordMiss();
120
- } else {
121
- this.statistics.recordHit(this.getElapsedMs(startTime));
122
- }
123
- } catch (error) {
124
- this.statistics.recordError();
125
- throw error;
126
- }
127
- });
128
-
129
- this.after('SET', () => this.statistics.recordSet());
130
- this.after('DELETE', () => this.statistics.recordDelete());
131
- }
132
- }
133
-
134
- addCachableFunction(name, options, isBound = false) {
135
-
136
- this.cacheAnnotatedFunctions[isBound ? 'bound' : 'unbound'].push({ name, options });
137
- }
138
-
139
- getElapsedMs(startTime) {
140
- const [seconds, nanoseconds] = process.hrtime(startTime);
141
- return seconds * 1000 + nanoseconds / 1000000;
142
- }
143
-
144
- async getStats(period, from, to) {
145
- return this.statistics?.getStats(period, from, to);
146
- }
147
-
148
- async getCurrentStats() {
149
- return this.statistics?.getCurrentStats();
150
- }
151
-
152
- async dispose() {
153
- this.statistics?.dispose();
154
- await super.dispose();
155
- }
156
-
157
- /**
158
- * Overloaded send method that caches the response of a remote service.
159
- *
160
- * @returns {Promise<any>} - the result
161
- */
162
- async send() {
163
- const arg1 = arguments[0];
164
- const service = arguments[1];
165
- const options = {
166
- ttl: 0,
167
- ...(arguments[2] || {}),
168
- }
169
-
170
- if (typeof arg1 !== "object" || !service.send || typeof options !== "object" || arg1.method !== "GET") {
171
- return super.send(...arguments);
172
- }
173
-
174
- const key = this.createKey(arg1, options.key);
175
-
176
- if (await this.has(key)) {
177
- return this.get(key);
178
- }
179
- const response = await service.send(arg1);
180
- await this.set(key, response, options);
181
- return response;
182
- }
183
-
184
- // Function to extract the cache options from the request
185
- extractFunctionCacheOptions(req, options) {
186
- const functionType = req.query ? 'bound' : 'unbound';
187
- const functionOptions = this.cacheAnnotatedFunctions[functionType].find(f => f.name === req.event);
188
-
189
- return {
190
- ttl: functionOptions?.['@cache.ttl'] || 0,
191
- key: functionOptions?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
192
- tags: functionOptions?.['@cache.tags'] || [],
193
- ...(options || {}),
194
- }
195
- }
196
-
197
- extractEntityCacheOptions(req, options) {
198
- return {
199
- ttl: req.target?.['@cache.ttl'] || 0,
200
- key: req.target?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
201
- tags: req.target?.['@cache.tags'] || [],
202
- ...(options || {}),
203
- }
204
- }
205
-
206
- /**
207
- * Overloaded run method that caches multiple things magically in the background
208
- *
209
- * @param {cds.ql} query - the query to run
210
- * @param {Service} service - service instance to run the query on
211
- * @param {object} options - additional options
212
- *
213
- *
214
- * @param {Request} request - the request to run
215
- * @param {next} function - the next fuction
216
- * @param {object} options - additional options
217
-
218
- * @returns {Promise<any>} - the result
219
- */
220
-
221
- async run() {
222
- const arg1 = arguments[0];
223
- if (typeof arg1 === "object") {
224
- switch (arg1.constructor.name) {
225
- case "Request":
226
- case "NoaRequest":
227
- case "ODataRequest":
228
- const req = arg1;
229
- const next = arguments[1];
230
-
231
- if (req.query?.UPDATE || req.query?.INSERT || req.query?.DELETE) {
232
- return next();
233
- }
234
-
235
- req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
236
- req.cacheKey = this.createKey(req, req.cacheOptions.key);
237
- req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
238
- const cachedValue = await this.get(req.cacheKey);
239
- if (cachedValue) {
240
- return cachedValue;
241
- }
242
- const response = await next();
243
- req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, { ...req.params, user: req.user.id, tenant: req.tenant, locale: req.locale, hash: this.createKey(req, { template: '{hash}' }) });
244
- await this.set(req.cacheKey, response, req.cacheOptions);
245
- return response;
246
- case "cds.ql":
247
- const query = arg1;
248
- const srv = arguments[1];
249
-
250
- if (query.SELECT) {
251
-
252
- let options = {
253
- ttl: 0,
254
- tags: [],
255
- key: { template: '{hash}' },
256
- ...(arguments[2] || {}),
257
- };
258
- query.cacheKey = this.createKey(query, options.key);
259
- if (await this.has(query.cacheKey)) {
260
- return this.get(query.cacheKey);
261
- }
262
- const data = await srv.run(query);
263
- options.tags = this.resolveTags(options.tags, data, { ...query.params, hash: this.createKey(query, { template: '{hash}' }) });
264
- await this.set(query.cacheKey, data, options);
265
- return data;
266
- } else {
267
- return srv.run(query);
268
- }
269
-
270
- }
271
- }
272
- return super.run(arg1);
273
- }
274
-
275
- /**
276
- * Wraps an async function and caches the result
277
- *
278
- * @param {string} key - the key to cache
279
- * @param {function} asyncFunction - the async function to cache
280
- * @param {object} options - additional options
281
- * @returns {function} - the wrapped function
282
- */
283
- wrap(key, asyncFunction, options = {}) {
284
- const cacheKey = this.createKey(key, options.key);
285
- return async (...args) => {
286
- if (await this.has(cacheKey)) {
287
- return this.get(cacheKey);
288
- }
289
- const response = await asyncFunction(...args);
290
- await this.set(cacheKey, response, options);
291
- return response;
292
- }
293
- }
294
-
295
- async set(key, value, options = {}) {
296
- const wrappedValue = {
297
- value,
298
- tags: this.resolveTags(options.tags, value, options.params) || [],
299
- timestamp: Date.now()
300
- };
301
- await this.send('SET', {
302
- key: this.createKey(key, options.key),
303
- value: wrappedValue,
304
- ttl: options.ttl || 0
305
- });
306
- }
307
-
308
- async get(key) {
309
- const wrappedValue = await this.send('GET', { key: this.createKey(key) });
310
- return wrappedValue?.value;
311
- }
312
-
313
- async has(key) {
314
- return this.cache.has(this.createKey(key));
315
- }
316
-
317
- async delete(key) {
318
- await this.send('DELETE', { key: this.createKey(key) });
319
- }
320
-
321
- async clear() {
322
- await this.send('CLEAR');
323
- }
324
-
325
-
326
- async deleteByTag(tag) {
327
- for await (const [key, wrappedValue] of this.iterator()) {
328
- if (wrappedValue?.tags?.includes(tag)) {
329
- await this.delete(key);
330
- }
331
- }
332
- }
333
-
334
- // Metadata
335
- async metadata(key) {
336
- const wrappedValue = await this.send('GET', { key: this.createKey(key) });
337
- if (!wrappedValue) return null;
338
-
339
- const { value, ...metadata } = wrappedValue;
340
- return metadata;
341
- }
342
-
343
- async tags(key) {
344
- const wrappedValue = await this.send('GET', { key: this.createKey(key) });
345
- return wrappedValue?.tags || [];
346
- }
347
-
348
- // Iterators
349
- async *iterator() {
350
- for await (const [key, value] of this.cache.iterator()) {
351
- if (typeof value === "string") {
352
- yield [key, JSON.parse(value)];
353
- } else {
354
- yield [key, value];
355
- }
356
- }
357
- }
358
-
359
- /**
360
- * Resolves tags from tag configurations and data
361
- * @param {Array} tagConfigs - Array of tag configuration objects
362
- * @param {Object|Array} data - Data object(s) to extract data values from
363
- * @param {Object} params - Parameters object to extract values from
364
- * @returns {string[]} Array of resolved tags
365
- */
366
- resolveTags(tagConfigs = [], data, params = {}) {
367
- // Handle empty/invalid configs
368
- if (!tagConfigs?.length) return [];
369
-
370
- // Convert data to array if single object or string
371
- const dataArray = !data ? [] :
372
- Array.isArray(data) ? data :
373
- typeof data === 'string' ? [data] : [data];
374
-
375
- // Process each tag configuration
376
- const resolvedTags = tagConfigs.flatMap(config => {
377
- // Handle string tags
378
- if (typeof config === 'string') {
379
- return [config];
380
- }
381
-
382
- // Handle invalid/empty config objects
383
- if (!config || typeof config !== 'object') {
384
- return [];
385
- }
386
-
387
- // Handle static value tags
388
- if (config.value) {
389
- const tag = [
390
- config.prefix,
391
- config.value,
392
- config.suffix
393
- ].filter(Boolean).join('');
394
- return [tag];
395
- }
396
-
397
- // Handle template-based tags
398
- if (config.template) {
399
- const createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
400
-
401
- const hashParts = [
402
- ...(data ? [data] : []),
403
- ...(params ? [params] : [])
404
- ];
405
-
406
- const contextVars = {
407
- tenant: params.tenant || 'global',
408
- user: params.user || 'anonymous',
409
- locale: params.locale || 'en',
410
- hash: createHash(hashParts)
411
- };
412
-
413
- const value = config.template.replace(
414
- /\{(tenant|user|locale|hash)\}/g,
415
- (match, variable) => contextVars[variable]
416
- );
417
-
418
- const tag = [
419
- config.prefix,
420
- value,
421
- config.suffix
422
- ].filter(Boolean).join('');
423
-
424
- return [tag];
425
- }
426
-
427
- // Handle data-based tags
428
- if (config.data && dataArray.length) {
429
- return dataArray.flatMap(item => {
430
- if (typeof item !== 'object') return [];
431
-
432
- const dataFields = Array.isArray(config.data) ? config.data : [config.data];
433
- const values = dataFields
434
- .map(field => item[field])
435
- .filter(Boolean);
436
-
437
- if (!values.length) return [];
438
-
439
- const value = values.join(config.separator || ':');
440
- const tag = [
441
- config.prefix,
442
- value,
443
- config.suffix
444
- ].filter(Boolean).join('');
445
- return [tag];
446
- });
447
- }
448
-
449
- // Handle param-based tags
450
- if (config.param) {
451
- const paramFields = Array.isArray(config.param) ? config.param : [config.param];
452
- const values = paramFields
453
- .map(field => params[field])
454
- .filter(Boolean);
455
-
456
- if (!values.length) return [];
457
-
458
- const value = values.join(config.separator || ':');
459
- const tag = [
460
- config.prefix,
461
- value,
462
- config.suffix
463
- ].filter(Boolean).join('');
464
- return [tag];
465
- }
466
-
467
- return [];
468
- });
469
-
470
- // Remove duplicates
471
- return [...new Set(resolvedTags)];
472
- }
473
-
474
- // Basic cache operations
475
- createKey(keyOrObject, options = {}) {
476
-
477
- // If the key is a string, use it
478
- if (typeof keyOrObject === "string") {
479
- return keyOrObject;
480
- }
481
-
482
- // Otherwise, create a key based on the object
483
- if (typeof keyOrObject === "object") {
484
-
485
- // If the key is provided in the options, use it
486
- if (keyOrObject.cacheKey) {
487
- return keyOrObject.cacheKey;
488
- }
489
-
490
- switch (keyOrObject.constructor.name) {
491
- case "Request":
492
- case "NoaRequest":
493
- case "ODataRequest":
494
-
495
- return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
496
- req: keyOrObject,
497
- params: keyOrObject.params,
498
- data: keyOrObject.data,
499
- locale: keyOrObject.locale,
500
- user: keyOrObject.user.id,
501
- tenant: keyOrObject.tenant
502
- });
503
- case "cds.ql":
504
- if (keyOrObject.SELECT) {
505
- return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { query: keyOrObject });
506
- } else {
507
- return undefined;
508
- }
509
- default:
510
- return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { data: keyOrObject });
511
- }
512
- }
513
- }
514
-
515
- /**
516
- * Creates a cache key based on configuration and context
517
- * @param {Object} keyConfig - Key configuration object
518
- * @param {Object} context - Context containing data, params, and request info
519
- * @returns {string} Generated cache key
520
- */
521
- createCacheKey(keyConfig = {}, context = {}) {
522
- const { data, params, req, query, locale, user, tenant } = context;
523
-
524
- // If a static key value is provided, use it
525
- if (keyConfig.value) {
526
- return keyConfig.value;
527
- }
528
-
529
- let keyValue = '';
530
-
531
- const hashParts = [
532
- ...(data ? [data] : []),
533
- ...(params ? [params] : []),
534
- ...(query ? [query] : [])
535
- ];
536
-
537
- const createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
538
-
539
- // Handle template with placeholders
540
- if (keyConfig.template) {
541
- const contextVars = {
542
- tenant: tenant || 'global',
543
- user: user || 'anonymous',
544
- locale: locale || 'en',
545
- hash: createHash(hashParts)
546
- };
547
-
548
- keyValue = keyConfig.template.replace(
549
- /\{(tenant|user|locale|hash)\}/g,
550
- (match, variable) => contextVars[variable]
551
- );
552
- }
553
-
554
- // If no key value generated, create hash from input
555
- if (!keyValue) {
556
- keyValue = createHash(hashParts);
557
- }
558
-
559
- // Combine with prefix/suffix
560
- return [
561
- keyConfig.prefix,
562
- keyValue,
563
- keyConfig.suffix
564
- ].filter(Boolean).join('');
565
- }
566
-
567
- /**
568
- * Executes an async function and caches its result
569
- *
570
- * @param {string} key - the key to cache
571
- * @param {function} asyncFunction - the async function to execute
572
- * @param {object} options - additional options
573
- * @returns {Promise<any>} - the result
574
- */
575
- async exec(key, asyncFunction, options = {}) {
576
- const cacheKey = this.createKey(key, options.key);
577
- if (await this.has(cacheKey)) {
578
- return this.get(cacheKey);
579
- }
580
- const response = await asyncFunction();
581
- await this.set(cacheKey, response, options);
582
- return response;
583
- }
584
-
585
- }
586
-
587
- module.exports = CachingService;
@@ -1,37 +0,0 @@
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
- }
@@ -1,72 +0,0 @@
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