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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Manages basic cache operations with statistics tracking
3
+ */
4
+ class BasicOperations {
5
+ constructor(cache, keyManager, tagResolver, statistics) {
6
+ this.cache = cache;
7
+ this.keyManager = keyManager;
8
+ this.tagResolver = tagResolver;
9
+ this.statistics = statistics;
10
+ }
11
+
12
+ /**
13
+ * Set a value in the cache
14
+ * @param {string|object} key - the key to set
15
+ * @param {any} value - the value to cache
16
+ * @param {object} options - cache options
17
+ * @returns {Promise<void>}
18
+ */
19
+ async set(key, value, options = {}) {
20
+ const wrappedValue = {
21
+ value,
22
+ tags: this.tagResolver.resolveTags(options.tags, value, options.params) || [],
23
+ timestamp: Date.now()
24
+ };
25
+ const createdKey = this.keyManager.createKey(key, {}, options.key);
26
+ await this.cache.send('SET', {
27
+ key: createdKey,
28
+ value: wrappedValue,
29
+ ttl: options.ttl || 0
30
+ });
31
+
32
+ const metadata = {
33
+ dataType: 'Operation',
34
+ operation: 'SET',
35
+ operationType: 'BASIC',
36
+ metadata: JSON.stringify({ key: createdKey, ttl: options.ttl || 0 }),
37
+ cacheOptions: JSON.stringify(options)
38
+ };
39
+ this.statistics.recordNativeSet(createdKey, metadata);
40
+ }
41
+
42
+ /**
43
+ * Get a value from the cache
44
+ * @param {string|object} key - the key to get
45
+ * @returns {Promise<any>} - the cached value
46
+ */
47
+ async get(key) {
48
+ const createdKey = this.keyManager.createKey(key);
49
+ const wrappedValue = await this.cache.send('GET', {
50
+ key: createdKey
51
+ });
52
+
53
+ // Record the native get operation
54
+ const metadata = {
55
+ dataType: 'Operation',
56
+ operation: 'GET',
57
+ operationType: 'BASIC',
58
+ metadata: JSON.stringify({ key: createdKey })
59
+ };
60
+ const isHit = wrappedValue !== undefined && wrappedValue !== null;
61
+ this.statistics.recordNativeGet(createdKey, isHit, metadata);
62
+
63
+ return wrappedValue?.value;
64
+ }
65
+
66
+ /**
67
+ * Check if a key exists in the cache
68
+ * @param {string|object} key - the key to check
69
+ * @returns {Promise<boolean>} - whether the key exists
70
+ */
71
+ async has(key) {
72
+ const createdKey = this.keyManager.createKey(key);
73
+ return this.cache.cache.has(createdKey);
74
+ }
75
+
76
+ /**
77
+ * Delete a key from the cache
78
+ * @param {string|object} key - the key to delete
79
+ * @returns {Promise<boolean>} - whether the key was deleted
80
+ */
81
+ async delete(key) {
82
+ const createdKey = this.keyManager.createKey(key);
83
+ const result = await this.cache.send('DELETE', {
84
+ key: createdKey
85
+ });
86
+
87
+ // Record the native delete operation
88
+ const metadata = {
89
+ dataType: 'Operation',
90
+ operation: 'DELETE',
91
+ operationType: 'BASIC',
92
+ metadata: JSON.stringify({ key: createdKey })
93
+ };
94
+ this.statistics.recordNativeDelete(createdKey, metadata);
95
+
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Clear all cache entries
101
+ * @returns {Promise<void>}
102
+ */
103
+ async clear() {
104
+ await this.cache.send('CLEAR');
105
+
106
+ // Record the native clear operation
107
+ const metadata = {
108
+ dataType: 'Operation',
109
+ operation: 'CLEAR',
110
+ operationType: 'BASIC',
111
+ metadata: JSON.stringify({ cache: this.cache.name })
112
+ };
113
+ this.statistics.recordNativeClear(metadata);
114
+
115
+ }
116
+
117
+ /**
118
+ * Delete all keys that have a specific tag
119
+ * @param {string} tag - the tag to match
120
+ * @returns {Promise<void>}
121
+ */
122
+ async deleteByTag(tag) {
123
+ for await (const [key, wrappedValue] of this.iterator()) {
124
+ if (wrappedValue?.tags?.includes(tag)) {
125
+ await this.delete(key);
126
+ }
127
+ }
128
+
129
+ // Record the native deleteByTag operation
130
+ const metadata = {
131
+ dataType: 'Operation',
132
+ operation: 'DELETE_BY_TAG',
133
+ operationType: 'BASIC',
134
+ metadata: JSON.stringify({ tag: tag, cache: this.cache.name })
135
+ };
136
+ this.statistics.recordNativeDeleteByTag(tag, metadata);
137
+
138
+ }
139
+
140
+ /**
141
+ * Get metadata for a key
142
+ * @param {string|object} key - the key to get metadata for
143
+ * @returns {Promise<object|null>} - the metadata or null if not found
144
+ */
145
+ async metadata(key) {
146
+ const createdKey = this.keyManager.createKey(key);
147
+ const wrappedValue = await this.cache.send('GET', {
148
+ key: createdKey
149
+ });
150
+ if (!wrappedValue) return null;
151
+
152
+ const { value, ...metadata } = wrappedValue;
153
+ return metadata;
154
+ }
155
+
156
+ /**
157
+ * Get tags for a key
158
+ * @param {string|object} key - the key to get tags for
159
+ * @returns {Promise<string[]>} - the tags
160
+ */
161
+ async tags(key) {
162
+ const createdKey = this.keyManager.createKey(key);
163
+ const wrappedValue = await this.cache.send('GET', {
164
+ key: createdKey
165
+ });
166
+ return wrappedValue?.tags || [];
167
+ }
168
+
169
+ /**
170
+ * Get a raw value from the cache without statistics tracking
171
+ * @param {string|object} key - the key to get
172
+ * @returns {Promise<any>} - the raw cached value
173
+ */
174
+ async getRaw(key) {
175
+ const createdKey = this.keyManager.createKey(key);
176
+ const wrappedValue = await this.cache.send('GET', {
177
+ key: createdKey
178
+ });
179
+ return wrappedValue?.value;
180
+ }
181
+
182
+ /**
183
+ * Iterator for all cache entries
184
+ * @returns {AsyncIterator} - iterator for cache entries
185
+ */
186
+ async *iterator() {
187
+ for await (const [key, value] of this.cache.cache.iterator()) {
188
+ if (typeof value === "string") {
189
+ try {
190
+ yield [key, JSON.parse(value)];
191
+ } catch (error) {
192
+ yield [key, value];
193
+ }
194
+ } else {
195
+ yield [key, value];
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ module.exports = BasicOperations;
@@ -0,0 +1,332 @@
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
+ }
19
+
20
+
21
+
22
+ /**
23
+ * Send a request with caching with read-through capabilities.
24
+ *
25
+ * @param {object} arg1 - the request object
26
+ * @param {Service} service - the service to send the request to
27
+ * @param {object} options - the options for the request
28
+ * @returns {Promise<any>} - the result
29
+ */
30
+ async send(request, service, options) {
31
+ const requestOptions = {
32
+ ttl: 0,
33
+ ...(options || {}),
34
+ }
35
+
36
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method?.toUpperCase()) || !service.send) {
37
+ return { result: null, cacheKey: null, metadata: { hit: false, latency: 0 } };
38
+ }
39
+
40
+ const keyParts = {
41
+ user: cds.context?.user?.id || cds.context?.user,
42
+ tenant: cds.context?.tenant,
43
+ locale: cds.context?.locale,
44
+ serviceName: service.name,
45
+ path: request.path || request.http?.req?.path,
46
+ method: request.method,
47
+ data: request.data,
48
+ params: request.params,
49
+ query: request.query,
50
+ event: request.event,
51
+ };
52
+
53
+ const key = this.keyManager.createKey(request, keyParts, requestOptions.key);
54
+ const startTime = process.hrtime();
55
+
56
+ // Track the miss with total latency (cache lookup + backend operation)
57
+ const metadata = {
58
+ dataType: request.send ? request.constructor.name : 'SendRequest',
59
+ operation: 'SEND',
60
+ operationType: 'READ_THROUGH',
61
+ tenant: request.tenant || cds.context?.tenant,
62
+ user: request.user?.id || cds.context?.user?.id,
63
+ locale: request.locale || cds.context?.locale,
64
+ target: request.target?.name,
65
+ query: request.query ? JSON.stringify(request.query) : undefined,
66
+ subject: request.subject ? JSON.stringify(request.subject) : undefined,
67
+ metadata: JSON.stringify({
68
+ serviceName: service.name,
69
+ path: request.path || request.http?.req?.path,
70
+ method: request.method,
71
+ url: request.url,
72
+ data: request.data,
73
+ params: request.params,
74
+ subject: request.subject,
75
+ query: request.query,
76
+ event: request.event,
77
+ headers: request.headers,
78
+ }),
79
+ cacheOptions: JSON.stringify(requestOptions)
80
+ };
81
+
82
+ if (await this.cache.has(key)) {
83
+ const latency = this.getElapsedMs(startTime);
84
+
85
+ this.statistics.recordHit(latency, key, metadata);
86
+
87
+ const wrappedValue = await this.cache.send("GET", { key });
88
+ return { result: wrappedValue?.value, cacheKey: key, metadata: { hit: true, latency: latency } };
89
+ } else {
90
+ const response = await service.send(request);
91
+ const totalLatency = this.getElapsedMs(startTime);
92
+ this.statistics.recordMiss(totalLatency, key, metadata);
93
+
94
+ const wrappedValue = {
95
+ value: response,
96
+ tags: this.tagResolver.resolveTags(requestOptions.tags, response, { ...request.params, user: request.user?.id, tenant: request.tenant, locale: request.locale, hash: this.keyManager.createContentHash(request) }),
97
+ timestamp: Date.now()
98
+ };
99
+ await this.cache.send("SET", { key, value: wrappedValue, ttl: requestOptions.ttl || 0 });
100
+
101
+ return { result: response, cacheKey: key, metadata: { hit: false, latency: totalLatency } };
102
+ }
103
+ }
104
+
105
+
106
+
107
+ /**
108
+ * Run a cached operation with automatic key generation
109
+ * @param {object} req - the request object
110
+ * @param {function} handler - the handler function
111
+ * @param {object} options - cache options
112
+ * @param {object} cache - the cache instance
113
+ * @returns {Promise<any>} - the result
114
+ */
115
+ async run() {
116
+ const arg1 = arguments[0];
117
+ if (typeof arg1 === "object") {
118
+ switch (arg1.constructor.name) {
119
+ case "Request":
120
+ case "ODataRequest":
121
+ case "NoaRequest":
122
+ const req = arg1;
123
+ const next = arguments[1];
124
+
125
+ if (req.query?.UPDATE || req.query?.INSERT || req.query?.DELETE) {
126
+ return next();
127
+ }
128
+
129
+ req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
130
+
131
+ req.cacheKey = this.keyManager.createKey(req, {}, req.cacheOptions.key);
132
+ req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
133
+
134
+ // Track cache operation timing
135
+ const startTime = process.hrtime();
136
+ const wrappedValue = await this.cache.send("GET", { key: req.cacheKey });
137
+ const cacheHit = wrappedValue?.value !== undefined;
138
+ const cacheLatency = this.getElapsedMs(startTime);
139
+ const metadata = this.extractMetadataFromRequest(req);
140
+
141
+ if (cacheHit) {
142
+ // Cache hit
143
+ this.statistics.recordHit(cacheLatency, req.cacheKey, metadata);
144
+ req.res?.setHeader('x-sap-cap-cache', "hit");
145
+
146
+ const wrappedValue = await this.cache.send("GET", { key: req.cacheKey });
147
+ return { result: wrappedValue?.value, cacheKey: req.cacheKey, metadata: { hit: true, latency: cacheLatency } };
148
+ } else {
149
+ // Cache miss - track the backend operation
150
+ const response = await next();
151
+ const totalLatency = this.getElapsedMs(startTime);
152
+
153
+ // Track the miss with total latency (cache lookup + backend operation)
154
+ this.statistics.recordMiss(totalLatency, req.cacheKey, metadata);
155
+ req.res?.setHeader('x-sap-cap-cache', "miss");
156
+
157
+ const wrappedValue = {
158
+ value: response,
159
+ tags: this.tagResolver.resolveTags(req.cacheOptions.tags, response, { ...req.params, hash: this.keyManager.createContentHash(req) }),
160
+ timestamp: Date.now()
161
+ };
162
+ await this.cache.send("SET", { key: req.cacheKey, value: wrappedValue, ttl: req.cacheOptions.ttl || 0 });
163
+ return { result: response, cacheKey: req.cacheKey, metadata: { hit: false, latency: totalLatency } };
164
+ }
165
+ case "cds.ql":
166
+ const query = arg1;
167
+ let srv = arguments[1] || cds;
168
+
169
+ if (query.SELECT) {
170
+ let options = {
171
+ ttl: 0,
172
+ tags: [],
173
+ ...(arguments[2] || {}),
174
+ };
175
+
176
+ query.cacheKey = this.keyManager.createKey(query, { serviceName: srv?.name }, options.key);
177
+
178
+ // Track cache operation timing
179
+ const startTime = process.hrtime();
180
+ const hasCachedValue = await this.cache.has(query.cacheKey);
181
+ const cacheLatency = this.getElapsedMs(startTime);
182
+ const metadata = {
183
+ dataType: 'Query',
184
+ operation: 'SELECT',
185
+ operationType: 'READ_THROUGH',
186
+ user: cds.context?.user?.id,
187
+ tenant: cds.context?.tenant,
188
+ locale: cds.context?.locale,
189
+ query: JSON.stringify(query.SELECT),
190
+ metadata: JSON.stringify({
191
+ query: query.SELECT,
192
+ user: cds.context?.user?.id,
193
+ tenant: cds.context?.tenant,
194
+ locale: cds.context?.locale,
195
+ serviceName: srv?.name,
196
+ path: query.path,
197
+ }),
198
+ cacheOptions: JSON.stringify(options)
199
+ };
200
+
201
+ if (hasCachedValue) {
202
+ // Cache hit
203
+ this.statistics.recordHit(cacheLatency, query.cacheKey, metadata);
204
+
205
+ const wrappedValue = await this.cache.send("GET", { key: query.cacheKey });
206
+ return { result: wrappedValue?.value, cacheKey: query.cacheKey, metadata: { hit: true, latency: cacheLatency } };
207
+ } else {
208
+ // Cache miss
209
+ const data = await srv.run(query);
210
+ const totalLatency = this.getElapsedMs(startTime);
211
+
212
+ // Track the miss with total latency (cache lookup + backend operation)
213
+ this.statistics.recordMiss(totalLatency, query.cacheKey, metadata);
214
+
215
+ const wrappedValue = {
216
+ value: data,
217
+ tags: this.tagResolver.resolveTags(options.tags, data, { ...query.params, hash: this.keyManager.createKey(query, { serviceName: srv?.name, template: '{hash}' }) }),
218
+ timestamp: Date.now()
219
+ };
220
+ await this.cache.send("SET", { key: query.cacheKey, value: wrappedValue, ttl: options.ttl || 0 });
221
+ return { result: data, cacheKey: query.cacheKey, metadata: { hit: false, latency: totalLatency } };
222
+ }
223
+ } else {
224
+ return srv.run(query);
225
+ }
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Extract cache options from request
233
+ * @param {object} req - the request object
234
+ * @param {object} options - default options
235
+ * @returns {object} - cache options
236
+ */
237
+ extractCacheOptions(req, options = {}) {
238
+ const extractedOptions = { ...options };
239
+
240
+ // Extract from function annotations
241
+ if (req.target && req.target.name) {
242
+ const functionOptions = this.extractFunctionCacheOptions(req, options);
243
+ Object.assign(extractedOptions, functionOptions);
244
+ }
245
+
246
+ // Extract from entity annotations
247
+ if (req.target && req.target.name) {
248
+ const entityOptions = this.extractEntityCacheOptions(req, options);
249
+ Object.assign(extractedOptions, entityOptions);
250
+ }
251
+
252
+ return extractedOptions;
253
+ }
254
+
255
+ /**
256
+ * Extract function cache options from request
257
+ * @param {object} req - the request object
258
+ * @param {object} options - default options
259
+ * @returns {object} - function cache options
260
+ */
261
+ extractFunctionCacheOptions(req, options = {}) {
262
+ const functionType = req.query ? 'bound' : 'unbound';
263
+ const functionOptions = this.cacheAnnotatedFunctions[functionType].find(f => f.name === req.event);
264
+
265
+ return {
266
+ ttl: functionOptions?.['@cache.ttl'] || 0,
267
+ key: functionOptions?.['@cache.key'] || null,
268
+ tags: functionOptions?.['@cache.tags'] || [],
269
+ ...(options || {}),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Extract entity cache options from request
275
+ * @param {object} req - the request object
276
+ * @param {object} options - default options
277
+ * @returns {object} - entity cache options
278
+ */
279
+ extractEntityCacheOptions(req, options = {}) {
280
+ return {
281
+ ttl: req.target?.['@cache.ttl'] || 0,
282
+ key: req.target?.['@cache.key'] || null,
283
+ tags: req.target?.['@cache.tags'] || [],
284
+ ...(options || {}),
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Extract metadata from request for statistics
290
+ * @param {object} req - the request object
291
+ * @returns {object} - metadata object
292
+ */
293
+ extractMetadataFromRequest(req) {
294
+
295
+ console.log(JSON.stringify(req.http?.req, null, 2));
296
+
297
+ const metadata = {
298
+ dataType: req.constructor.name,
299
+ serviceName: req.target?.name || '',
300
+ operation: 'RUN',
301
+ operationType: 'READ_THROUGH',
302
+ tenant: req.tenant,
303
+ user: req.user?.id,
304
+ locale: req.locale,
305
+ target: req.target?.name,
306
+ subject: req.subject ? JSON.stringify(req.subject) : undefined,
307
+ query: req.query?.SELECT ? JSON.stringify(req.query?.SELECT) : undefined,
308
+ metadata: JSON.stringify({
309
+ method: req.method,
310
+ data: req.data,
311
+ params: req.params,
312
+ path: req.http?.req?.path,
313
+ url: req.http?.req?.url
314
+ }),
315
+ cacheOptions: JSON.stringify(req.cacheOptions)
316
+ };
317
+
318
+ return metadata;
319
+ }
320
+
321
+ /**
322
+ * Get elapsed time in milliseconds
323
+ * @param {[number, number]} startTime - start time from process.hrtime()
324
+ * @returns {number} - elapsed time in milliseconds
325
+ */
326
+ getElapsedMs(startTime) {
327
+ const [seconds, nanoseconds] = process.hrtime(startTime);
328
+ return (seconds * 1000) + (nanoseconds / 1000000);
329
+ }
330
+ }
331
+
332
+ module.exports = CapOperations;