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.
@@ -0,0 +1,540 @@
1
+ const TagResolver = require('../support/TagResolver');
2
+ /**
3
+ * Manages CAP-specific cache operations
4
+ */
5
+ class CapOperations {
6
+
7
+ cacheAnnotatedFunctions = {
8
+ bound: [],
9
+ unbound: []
10
+ };
11
+
12
+ constructor(cache, keyManager, statistics, log, runtimeConfigManager) {
13
+ this.cache = cache;
14
+ this.keyManager = keyManager;
15
+ this.statistics = statistics;
16
+ this.tagResolver = new TagResolver();
17
+ this.runtimeConfigManager = runtimeConfigManager;
18
+ this.log = log || console;
19
+ }
20
+
21
+ /**
22
+ * Safely execute cache operations with error handling
23
+ * @param {Function} operation - The cache operation to execute
24
+ * @param {string} operationName - Name of the operation for logging
25
+ * @param {object} context - Context information for logging
26
+ * @returns {Promise<object>} - The result with error information
27
+ */
28
+ async safeCacheOperation(operation, operationName, context = {}) {
29
+ try {
30
+ const result = await operation();
31
+ this.log.info('REEEESULT', { result });
32
+ return { success: true, result, error: null };
33
+ } catch (error) {
34
+ this.log.warn(`Cache ${operationName} failed:`, {
35
+ error: error.message,
36
+ stack: error.stack,
37
+ context: context
38
+ });
39
+
40
+ return {
41
+ success: false,
42
+ result: null,
43
+ error: {
44
+ message: error.message,
45
+ operation: operationName,
46
+ context: context
47
+ }
48
+ };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Send a request with caching with read-through capabilities.
54
+ *
55
+ * @param {object} arg1 - the request object
56
+ * @param {Service} service - the service to send the request to
57
+ * @param {object} options - the options for the request
58
+ * @returns {Promise<any>} - the result
59
+ */
60
+ async send(request, service, options) {
61
+ const requestOptions = {
62
+ ttl: 0,
63
+ ...(options || {}),
64
+ }
65
+
66
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method?.toUpperCase()) || !service.send) {
67
+ return { result: null, cacheKey: null, metadata: { hit: false, latency: 0 }, cacheErrors: [] };
68
+ }
69
+
70
+ const keyParts = {
71
+ user: cds.context?.user?.id || cds.context?.user,
72
+ tenant: cds.context?.tenant,
73
+ locale: cds.context?.locale,
74
+ serviceName: service.name,
75
+ path: request.path || request.http?.req?.path,
76
+ method: request.method,
77
+ data: request.data,
78
+ params: request.params,
79
+ query: request.query,
80
+ event: request.event,
81
+ };
82
+
83
+ const key = this.keyManager.createKey(request, keyParts, requestOptions.key);
84
+ const startTime = process.hrtime();
85
+
86
+ // Track the miss with total latency (cache lookup + backend operation)
87
+ const metadata = {
88
+ dataType: request.send ? request.constructor.name : 'SendRequest',
89
+ operation: 'SEND',
90
+ operationType: 'READ_THROUGH',
91
+ tenant: request.tenant || cds.context?.tenant,
92
+ user: request.user?.id || cds.context?.user?.id,
93
+ locale: request.locale || cds.context?.locale,
94
+ target: request.target?.name,
95
+ query: request.query ? JSON.stringify(request.query) : undefined,
96
+ subject: request.subject ? JSON.stringify(request.subject) : undefined,
97
+ metadata: JSON.stringify({
98
+ serviceName: service.name,
99
+ path: request.path || request.http?.req?.path,
100
+ method: request.method,
101
+ url: request.url,
102
+ data: request.data,
103
+ params: request.params,
104
+ subject: request.subject,
105
+ query: request.query,
106
+ event: request.event,
107
+ headers: request.headers,
108
+ }),
109
+ cacheOptions: JSON.stringify(requestOptions)
110
+ };
111
+
112
+ // Safely check if key exists in cache
113
+ const hasKeyResult = await this.safeCacheOperation(
114
+ () => this.cache.has(key),
115
+ 'has',
116
+ { key, serviceName: service.name }
117
+ );
118
+
119
+ const hasKey = hasKeyResult.success && hasKeyResult.result;
120
+ const cacheErrors = [];
121
+
122
+ if (hasKey) {
123
+ const latency = this.getElapsedMs(startTime);
124
+
125
+ // Safely record hit statistics
126
+ const hitStatsResult = await this.safeCacheOperation(
127
+ () => this.statistics.recordHit(latency, key, metadata),
128
+ 'recordHit',
129
+ { key, latency }
130
+ );
131
+ if (!hitStatsResult.success) {
132
+ cacheErrors.push(hitStatsResult.error);
133
+ }
134
+
135
+ // Safely get value from cache
136
+ const getResult = await this.safeCacheOperation(
137
+ () => this.cache.send("GET", { key }),
138
+ 'get',
139
+ { key }
140
+ );
141
+
142
+ if (getResult.success && getResult.result?.value !== undefined) {
143
+ return {
144
+ result: getResult.result.value,
145
+ cacheKey: key,
146
+ metadata: { hit: true, latency: latency },
147
+ cacheErrors: cacheErrors
148
+ };
149
+ }
150
+ }
151
+
152
+ // Cache miss or cache error - delegate to underlying service
153
+ try {
154
+ const response = await service.send(request);
155
+ const totalLatency = this.getElapsedMs(startTime);
156
+
157
+ // Safely record miss statistics
158
+ const missStatsResult = await this.safeCacheOperation(
159
+ () => this.statistics.recordMiss(totalLatency, key, metadata),
160
+ 'recordMiss',
161
+ { key, latency: totalLatency }
162
+ );
163
+ if (!missStatsResult.success) {
164
+ cacheErrors.push(missStatsResult.error);
165
+ }
166
+
167
+ // Safely store in cache
168
+ const wrappedValue = {
169
+ value: response,
170
+ 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
+ timestamp: Date.now()
172
+ };
173
+
174
+ const setResult = await this.safeCacheOperation(
175
+ () => this.cache.send("SET", { key, value: wrappedValue, ttl: requestOptions.ttl || 0 }),
176
+ 'set',
177
+ { key, ttl: requestOptions.ttl }
178
+ );
179
+ if (!setResult.success) {
180
+ cacheErrors.push(setResult.error);
181
+ }
182
+
183
+ return {
184
+ result: response,
185
+ cacheKey: key,
186
+ metadata: { hit: false, latency: totalLatency },
187
+ cacheErrors: cacheErrors
188
+ };
189
+ } catch (serviceError) {
190
+ // If the underlying service fails, throw the error
191
+ this.log.error('Service operation failed:', {
192
+ error: serviceError.message,
193
+ serviceName: service.name,
194
+ key: key
195
+ });
196
+ throw serviceError;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Run a cached operation with automatic key generation
202
+ * @param {object} req - the request object
203
+ * @param {function} handler - the handler function
204
+ * @param {object} options - cache options
205
+ * @param {object} cache - the cache instance
206
+ * @returns {Promise<any>} - the result
207
+ */
208
+ async run() {
209
+ const arg1 = arguments[0];
210
+ if (typeof arg1 === "object") {
211
+ switch (arg1.constructor.name) {
212
+ case "Request":
213
+ case "ODataRequest":
214
+ case "NoaRequest":
215
+ const req = arg1;
216
+ const next = arguments[1];
217
+
218
+ if (req.query?.UPDATE || req.query?.INSERT || req.query?.DELETE) {
219
+ return next();
220
+ }
221
+
222
+ req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
223
+
224
+ req.cacheKey = this.keyManager.createKey(req, {}, req.cacheOptions.key);
225
+ req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
226
+
227
+ // Track cache operation timing
228
+ const startTime = process.hrtime();
229
+
230
+ // Safely get value from cache
231
+ const getResult = await this.safeCacheOperation(
232
+ () => this.cache.send("GET", { key: req.cacheKey }),
233
+ 'get',
234
+ { key: req.cacheKey, serviceName: req.target?.name }
235
+ );
236
+
237
+ const cacheHit = getResult.success && getResult.result?.value !== undefined;
238
+ const cacheLatency = this.getElapsedMs(startTime);
239
+ const metadata = this.extractMetadataFromRequest(req);
240
+ const cacheErrors = [];
241
+
242
+ if (cacheHit) {
243
+ // Cache hit
244
+ const hitStatsResult = await this.safeCacheOperation(
245
+ () => this.statistics.recordHit(cacheLatency, req.cacheKey, metadata),
246
+ 'recordHit',
247
+ { key: req.cacheKey, latency: cacheLatency }
248
+ );
249
+ if (!hitStatsResult.success) {
250
+ cacheErrors.push(hitStatsResult.error);
251
+ }
252
+
253
+ req.res?.setHeader('x-sap-cap-cache', "hit");
254
+ return {
255
+ result: getResult.result.value,
256
+ cacheKey: req.cacheKey,
257
+ metadata: { hit: true, latency: cacheLatency },
258
+ cacheErrors: cacheErrors
259
+ };
260
+ } else {
261
+ // Cache miss - track the backend operation
262
+ try {
263
+ const response = await next();
264
+ const totalLatency = this.getElapsedMs(startTime);
265
+
266
+ // Safely record miss statistics
267
+ const missStatsResult = await this.safeCacheOperation(
268
+ () => this.statistics.recordMiss(totalLatency, req.cacheKey, metadata),
269
+ 'recordMiss',
270
+ { key: req.cacheKey, latency: totalLatency }
271
+ );
272
+ if (!missStatsResult.success) {
273
+ cacheErrors.push(missStatsResult.error);
274
+ }
275
+
276
+ req.res?.setHeader('x-sap-cap-cache', "miss");
277
+
278
+ // Safely store in cache
279
+ const wrappedValue = {
280
+ value: response,
281
+ tags: this.tagResolver.resolveTags(req.cacheOptions.tags, response, { ...req.params, hash: this.keyManager.createContentHash(req) }),
282
+ timestamp: Date.now()
283
+ };
284
+
285
+ const setResult = await this.safeCacheOperation(
286
+ () => this.cache.send("SET", { key: req.cacheKey, value: wrappedValue, ttl: req.cacheOptions.ttl || 0 }),
287
+ 'set',
288
+ { key: req.cacheKey, ttl: req.cacheOptions.ttl }
289
+ );
290
+
291
+ if (!setResult.success) {
292
+ cacheErrors.push(setResult.error);
293
+ }
294
+
295
+ return {
296
+ result: response,
297
+ cacheKey: req.cacheKey,
298
+ metadata: { hit: false, latency: totalLatency },
299
+ cacheErrors: cacheErrors
300
+ };
301
+ } catch (serviceError) {
302
+ // If the underlying service fails, throw the error
303
+ this.log.error('Service operation failed:', {
304
+ error: serviceError.message,
305
+ serviceName: req.target?.name,
306
+ key: req.cacheKey
307
+ });
308
+ throw serviceError;
309
+ }
310
+ }
311
+ case "cds.ql":
312
+ const query = arg1;
313
+ let srv = arguments[1] || cds;
314
+
315
+ if (query.SELECT) {
316
+ let options = {
317
+ ttl: 0,
318
+ tags: [],
319
+ ...(arguments[2] || {}),
320
+ };
321
+
322
+ query.cacheKey = this.keyManager.createKey(query, { serviceName: srv?.name }, options.key);
323
+
324
+ // Track cache operation timing
325
+ const startTime = process.hrtime();
326
+
327
+ // Safely check if key exists in cache
328
+ const hasCachedValueResult = await this.safeCacheOperation(
329
+ () => this.cache.has(query.cacheKey),
330
+ 'has',
331
+ { key: query.cacheKey, serviceName: srv?.name }
332
+ );
333
+
334
+ const hasCachedValue = hasCachedValueResult.success && hasCachedValueResult.result;
335
+ const cacheLatency = this.getElapsedMs(startTime);
336
+ const metadata = {
337
+ dataType: 'Query',
338
+ operation: 'SELECT',
339
+ operationType: 'READ_THROUGH',
340
+ user: cds.context?.user?.id,
341
+ tenant: cds.context?.tenant,
342
+ locale: cds.context?.locale,
343
+ query: JSON.stringify(query.SELECT),
344
+ metadata: JSON.stringify({
345
+ query: query.SELECT,
346
+ user: cds.context?.user?.id,
347
+ tenant: cds.context?.tenant,
348
+ locale: cds.context?.locale,
349
+ serviceName: srv?.name,
350
+ path: query.path,
351
+ }),
352
+ cacheOptions: JSON.stringify(options)
353
+ };
354
+ const cacheErrors = [];
355
+
356
+ if (hasCachedValue) {
357
+ // Cache hit
358
+ const hitStatsResult = await this.safeCacheOperation(
359
+ () => this.statistics.recordHit(cacheLatency, query.cacheKey, metadata),
360
+ 'recordHit',
361
+ { key: query.cacheKey, latency: cacheLatency }
362
+ );
363
+ if (!hitStatsResult.success) {
364
+ cacheErrors.push(hitStatsResult.error);
365
+ }
366
+
367
+ const getResult = await this.safeCacheOperation(
368
+ () => this.cache.send("GET", { key: query.cacheKey }),
369
+ 'get',
370
+ { key: query.cacheKey }
371
+ );
372
+
373
+ 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
+ };
380
+ }
381
+ }
382
+
383
+ // Cache miss or cache error
384
+ try {
385
+ const data = await srv.run(query);
386
+ const totalLatency = this.getElapsedMs(startTime);
387
+
388
+ // Safely record miss statistics
389
+ const missStatsResult = await this.safeCacheOperation(
390
+ () => this.statistics.recordMiss(totalLatency, query.cacheKey, metadata),
391
+ 'recordMiss',
392
+ { key: query.cacheKey, latency: totalLatency }
393
+ );
394
+ if (!missStatsResult.success) {
395
+ cacheErrors.push(missStatsResult.error);
396
+ }
397
+
398
+ // Safely store in cache
399
+ const wrappedValue = {
400
+ value: data,
401
+ tags: this.tagResolver.resolveTags(options.tags, data, { ...query.params, hash: this.keyManager.createKey(query, { serviceName: srv?.name, template: '{hash}' }) }),
402
+ timestamp: Date.now()
403
+ };
404
+
405
+ const setResult = await this.safeCacheOperation(
406
+ () => this.cache.send("SET", { key: query.cacheKey, value: wrappedValue, ttl: options.ttl || 0 }),
407
+ 'set',
408
+ { key: query.cacheKey, ttl: options.ttl }
409
+ );
410
+ if (!setResult.success) {
411
+ cacheErrors.push(setResult.error);
412
+ }
413
+
414
+ this.log.info('REEEESULT', { setResult, cacheErrors, wrappedValue });
415
+
416
+ return {
417
+ result: data,
418
+ cacheKey: query.cacheKey,
419
+ metadata: { hit: false, latency: totalLatency },
420
+ cacheErrors: cacheErrors
421
+ };
422
+ } catch (serviceError) {
423
+ // If the underlying service fails, throw the error
424
+ this.log.error('Service operation failed:', {
425
+ error: serviceError.message,
426
+ serviceName: srv?.name,
427
+ key: query.cacheKey
428
+ });
429
+ throw serviceError;
430
+ }
431
+ } else {
432
+ return srv.run(query);
433
+ }
434
+ }
435
+ }
436
+ return { result: null, cacheKey: null, metadata: { hit: false, latency: 0 }, cacheErrors: [] };
437
+ }
438
+
439
+ /**
440
+ * Extract cache options from request
441
+ * @param {object} req - the request object
442
+ * @param {object} options - default options
443
+ * @returns {object} - cache options
444
+ */
445
+ extractCacheOptions(req, options = {}) {
446
+ const extractedOptions = { ...options };
447
+
448
+ // Extract from function annotations
449
+ if (req.target && req.target.name) {
450
+ const functionOptions = this.extractFunctionCacheOptions(req, options);
451
+ Object.assign(extractedOptions, functionOptions);
452
+ }
453
+
454
+ // Extract from entity annotations
455
+ if (req.target && req.target.name) {
456
+ const entityOptions = this.extractEntityCacheOptions(req, options);
457
+ Object.assign(extractedOptions, entityOptions);
458
+ }
459
+
460
+ return extractedOptions;
461
+ }
462
+
463
+ /**
464
+ * Extract function cache options from request
465
+ * @param {object} req - the request object
466
+ * @param {object} options - default options
467
+ * @returns {object} - function cache options
468
+ */
469
+ extractFunctionCacheOptions(req, options = {}) {
470
+ const functionType = req.query ? 'bound' : 'unbound';
471
+ const functionOptions = this.cacheAnnotatedFunctions[functionType].find(f => f.name === req.event);
472
+
473
+ return {
474
+ ttl: functionOptions?.['@cache.ttl'] || 0,
475
+ key: functionOptions?.['@cache.key'] || null,
476
+ tags: functionOptions?.['@cache.tags'] || [],
477
+ ...(options || {}),
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Extract entity cache options from request
483
+ * @param {object} req - the request object
484
+ * @param {object} options - default options
485
+ * @returns {object} - entity cache options
486
+ */
487
+ extractEntityCacheOptions(req, options = {}) {
488
+ return {
489
+ ttl: req.target?.['@cache.ttl'] || 0,
490
+ key: req.target?.['@cache.key'] || null,
491
+ tags: req.target?.['@cache.tags'] || [],
492
+ ...(options || {}),
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Extract metadata from request for statistics
498
+ * @param {object} req - the request object
499
+ * @returns {object} - metadata object
500
+ */
501
+ extractMetadataFromRequest(req) {
502
+
503
+ console.log(JSON.stringify(req.http?.req, null, 2));
504
+
505
+ const metadata = {
506
+ dataType: req.constructor.name,
507
+ serviceName: req.target?.name || '',
508
+ operation: 'RUN',
509
+ operationType: 'READ_THROUGH',
510
+ tenant: req.tenant,
511
+ user: req.user?.id,
512
+ locale: req.locale,
513
+ target: req.target?.name,
514
+ subject: req.subject ? JSON.stringify(req.subject) : undefined,
515
+ query: req.query?.SELECT ? JSON.stringify(req.query?.SELECT) : undefined,
516
+ metadata: JSON.stringify({
517
+ method: req.method,
518
+ data: req.data,
519
+ params: req.params,
520
+ path: req.http?.req?.path,
521
+ url: req.http?.req?.url
522
+ }),
523
+ cacheOptions: JSON.stringify(req.cacheOptions)
524
+ };
525
+
526
+ return metadata;
527
+ }
528
+
529
+ /**
530
+ * Get elapsed time in milliseconds
531
+ * @param {[number, number]} startTime - start time from process.hrtime()
532
+ * @returns {number} - elapsed time in milliseconds
533
+ */
534
+ getElapsedMs(startTime) {
535
+ const [seconds, nanoseconds] = process.hrtime(startTime);
536
+ return (seconds * 1000) + (nanoseconds / 1000000);
537
+ }
538
+ }
539
+
540
+ module.exports = CapOperations;