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