slonik-interceptor-query-cache 3.3.1 → 3.3.2

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.
package/package.json CHANGED
@@ -41,6 +41,7 @@
41
41
  "node": ">=16.0"
42
42
  },
43
43
  "files": [
44
+ "src",
44
45
  "dist"
45
46
  ],
46
47
  "keywords": [
@@ -66,5 +67,5 @@
66
67
  "test:ava": "nyc ava --verbose --serial"
67
68
  },
68
69
  "types": "./dist/index.d.ts",
69
- "version": "3.3.1"
70
+ "version": "3.3.2"
70
71
  }
package/src/Logger.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { Roarr } from 'roarr';
2
+
3
+ export const Logger = Roarr.child({
4
+ package: 'slonik-interceptor-query-cache',
5
+ });
@@ -0,0 +1,129 @@
1
+ import { Logger } from '../Logger';
2
+ import { extractCacheAttributes, normalizeCacheAttributes } from '../utilities';
3
+ import {
4
+ type Interceptor,
5
+ type Query,
6
+ type QueryResult,
7
+ type QueryResultRow,
8
+ } from 'slonik';
9
+
10
+ const log = Logger.child({
11
+ namespace: 'createQueryCacheInterceptor',
12
+ });
13
+
14
+ type Sandbox = {
15
+ cache: {
16
+ cacheAttributes: CacheAttributes;
17
+ };
18
+ };
19
+
20
+ export type CacheAttributes = {
21
+ discardEmpty: boolean;
22
+ key: string;
23
+ ttl: number;
24
+ };
25
+
26
+ type Storage = {
27
+ get: (
28
+ query: Query,
29
+ cacheAttributes: CacheAttributes,
30
+ ) => Promise<QueryResult<QueryResultRow> | null>;
31
+ set: (
32
+ query: Query,
33
+ cacheAttributes: CacheAttributes,
34
+ queryResult: QueryResult<QueryResultRow>,
35
+ ) => Promise<void>;
36
+ };
37
+
38
+ type ConfigurationInput = {
39
+ storage: Storage;
40
+ };
41
+
42
+ type Configuration = {
43
+ storage: Storage;
44
+ };
45
+
46
+ export const createQueryCacheInterceptor = (
47
+ configurationInput: ConfigurationInput,
48
+ ): Interceptor => {
49
+ const configuration: Configuration = {
50
+ ...configurationInput,
51
+ };
52
+
53
+ return {
54
+ beforeQueryExecution: async (context, query) => {
55
+ if (context.transactionId) {
56
+ return null;
57
+ }
58
+
59
+ const cacheAttributes = (context.sandbox as Sandbox).cache
60
+ ?.cacheAttributes;
61
+
62
+ if (!cacheAttributes) {
63
+ return null;
64
+ }
65
+
66
+ const maybeResult = await configuration.storage.get(
67
+ query,
68
+ cacheAttributes,
69
+ );
70
+
71
+ if (maybeResult) {
72
+ log.debug(
73
+ {
74
+ queryId: context.queryId,
75
+ },
76
+ 'query is served from cache',
77
+ );
78
+
79
+ return maybeResult;
80
+ }
81
+
82
+ return null;
83
+ },
84
+ beforeQueryResult: async (context, query, result) => {
85
+ if (context.transactionId) {
86
+ return null;
87
+ }
88
+
89
+ const cacheAttributes = (context.sandbox as Sandbox).cache
90
+ ?.cacheAttributes;
91
+
92
+ if (cacheAttributes) {
93
+ if (cacheAttributes.discardEmpty && result.rowCount === 0) {
94
+ log.debug(
95
+ '@cache-discard-empty is set and the query result is empty; not caching',
96
+ );
97
+ } else {
98
+ await configuration.storage.set(query, cacheAttributes, result);
99
+ }
100
+ }
101
+
102
+ return null;
103
+ },
104
+ beforeTransformQuery: async (context, query) => {
105
+ if (context.transactionId) {
106
+ return null;
107
+ }
108
+
109
+ const extractedCacheAttributes = extractCacheAttributes(
110
+ query.sql,
111
+ query.values,
112
+ );
113
+
114
+ if (!extractedCacheAttributes) {
115
+ return null;
116
+ }
117
+
118
+ const cacheAttributes = normalizeCacheAttributes(
119
+ extractedCacheAttributes,
120
+ );
121
+
122
+ context.sandbox.cache = {
123
+ cacheAttributes,
124
+ };
125
+
126
+ return null;
127
+ },
128
+ };
129
+ };
@@ -0,0 +1,16 @@
1
+ import { type QueryContext } from 'slonik';
2
+
3
+ export default () => {
4
+ return {
5
+ connectionId: '1',
6
+ log: {
7
+ getContext: () => {
8
+ return {
9
+ connectionId: '1',
10
+ poolId: '1',
11
+ };
12
+ },
13
+ },
14
+ poolId: '1',
15
+ } as unknown as QueryContext;
16
+ };
@@ -0,0 +1 @@
1
+ export { createQueryCacheInterceptor } from './createQueryCacheInterceptor';
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { createQueryCacheInterceptor } from './factories';
@@ -0,0 +1,92 @@
1
+ import { extractCacheAttributes } from './extractCacheAttributes';
2
+ import test from 'ava';
3
+
4
+ test('returns null when query does not contain cache attributes', (t) => {
5
+ t.is(extractCacheAttributes('', []), null);
6
+ });
7
+
8
+ test('extracts @cache-ttl', (t) => {
9
+ t.deepEqual(extractCacheAttributes('-- @cache-ttl 60', []), {
10
+ bodyHash: '46b9dd2b0ba88d13233b3feb',
11
+ discardEmpty: false,
12
+ key: 'query:$bodyHash:$valueHash',
13
+ ttl: 60,
14
+ valueHash: 'ec784925b52067bce01fd820',
15
+ });
16
+ });
17
+
18
+ test('extracts @cache-discard-empty', (t) => {
19
+ t.deepEqual(
20
+ extractCacheAttributes(
21
+ '-- @cache-ttl 60\n-- @cache-discard-empty false',
22
+ [],
23
+ ),
24
+ {
25
+ bodyHash: '46b9dd2b0ba88d13233b3feb',
26
+ discardEmpty: false,
27
+ key: 'query:$bodyHash:$valueHash',
28
+ ttl: 60,
29
+ valueHash: 'ec784925b52067bce01fd820',
30
+ },
31
+ );
32
+
33
+ t.deepEqual(
34
+ extractCacheAttributes(
35
+ '-- @cache-ttl 60\n-- @cache-discard-empty true',
36
+ [],
37
+ ),
38
+ {
39
+ bodyHash: '46b9dd2b0ba88d13233b3feb',
40
+ discardEmpty: true,
41
+ key: 'query:$bodyHash:$valueHash',
42
+ ttl: 60,
43
+ valueHash: 'ec784925b52067bce01fd820',
44
+ },
45
+ );
46
+ });
47
+
48
+ test('computes the parameter value hash', (t) => {
49
+ t.deepEqual(extractCacheAttributes('-- @cache-ttl 60', [1]), {
50
+ bodyHash: '46b9dd2b0ba88d13233b3feb',
51
+ discardEmpty: false,
52
+ key: 'query:$bodyHash:$valueHash',
53
+ ttl: 60,
54
+ valueHash: '62eb54746ae2932850f8d6ff',
55
+ });
56
+ });
57
+
58
+ test('computes the body hash; white spaces do not affect the body hash', (t) => {
59
+ t.deepEqual(extractCacheAttributes('-- @cache-ttl 60\nSELECT 1', []), {
60
+ bodyHash: '553ebdb024592064ca4c2c3a',
61
+ discardEmpty: false,
62
+ key: 'query:$bodyHash:$valueHash',
63
+ ttl: 60,
64
+ valueHash: 'ec784925b52067bce01fd820',
65
+ });
66
+
67
+ t.deepEqual(extractCacheAttributes('-- @cache-ttl 60\n\nSELECT 1', []), {
68
+ bodyHash: '553ebdb024592064ca4c2c3a',
69
+ discardEmpty: false,
70
+ key: 'query:$bodyHash:$valueHash',
71
+ ttl: 60,
72
+ valueHash: 'ec784925b52067bce01fd820',
73
+ });
74
+ });
75
+
76
+ test('computes the body hash; comments do not affect the body hash', (t) => {
77
+ t.deepEqual(extractCacheAttributes('-- @cache-ttl 60\nSELECT 1', []), {
78
+ bodyHash: '553ebdb024592064ca4c2c3a',
79
+ discardEmpty: false,
80
+ key: 'query:$bodyHash:$valueHash',
81
+ ttl: 60,
82
+ valueHash: 'ec784925b52067bce01fd820',
83
+ });
84
+
85
+ t.deepEqual(extractCacheAttributes('-- @cache-ttl 120\nSELECT 1', []), {
86
+ bodyHash: '553ebdb024592064ca4c2c3a',
87
+ discardEmpty: false,
88
+ key: 'query:$bodyHash:$valueHash',
89
+ ttl: 120,
90
+ valueHash: 'ec784925b52067bce01fd820',
91
+ });
92
+ });
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { type PrimitiveValueExpression } from 'slonik';
3
+ import stripComments from 'strip-comments';
4
+
5
+ const hash = (subject: string) => {
6
+ return createHash('shake256', {
7
+ outputLength: 12,
8
+ })
9
+ .update(subject)
10
+ .digest('hex');
11
+ };
12
+
13
+ export type ExtractedCacheAttributes = {
14
+ bodyHash: string;
15
+ discardEmpty: boolean;
16
+ key: string;
17
+ ttl: number;
18
+ valueHash: string;
19
+ };
20
+
21
+ // TODO throw an error if an unknown attribute is used
22
+ export const extractCacheAttributes = (
23
+ subject: string,
24
+ values: readonly PrimitiveValueExpression[],
25
+ ): ExtractedCacheAttributes | null => {
26
+ const ttl = /-- @cache-ttl (\d+)/u.exec(subject)?.[1];
27
+
28
+ // https://github.com/jonschlinkert/strip-comments/issues/71
29
+ const bodyHash = hash(
30
+ stripComments(subject)
31
+ .replaceAll(/^\s*--.*$/gmu, '')
32
+ .replaceAll(/\s/gu, ''),
33
+ );
34
+
35
+ const discardEmpty =
36
+ (/-- @cache-discard-empty (true|false)/u.exec(subject)?.[1] ?? 'false') ===
37
+ 'true';
38
+
39
+ const valueHash = hash(JSON.stringify(values));
40
+
41
+ if (ttl) {
42
+ const key =
43
+ /-- @cache-key ([$\w\-:/]+)/iu.exec(subject)?.[1] ??
44
+ 'query:$bodyHash:$valueHash';
45
+
46
+ return {
47
+ bodyHash,
48
+ discardEmpty,
49
+ key,
50
+ ttl: Number(ttl),
51
+ valueHash,
52
+ };
53
+ }
54
+
55
+ return null;
56
+ };
@@ -0,0 +1,2 @@
1
+ export { extractCacheAttributes } from './extractCacheAttributes';
2
+ export { normalizeCacheAttributes } from './normalizeCacheAttributes';
@@ -0,0 +1,19 @@
1
+ import { normalizeCacheAttributes } from './normalizeCacheAttributes';
2
+ import test from 'ava';
3
+
4
+ test('replaces $bodyHash and $valueHash', (t) => {
5
+ t.deepEqual(
6
+ normalizeCacheAttributes({
7
+ bodyHash: 'foo',
8
+ discardEmpty: false,
9
+ key: '$bodyHash:$valueHash',
10
+ ttl: 60,
11
+ valueHash: 'bar',
12
+ }),
13
+ {
14
+ discardEmpty: false,
15
+ key: 'foo:bar',
16
+ ttl: 60,
17
+ },
18
+ );
19
+ });
@@ -0,0 +1,14 @@
1
+ import { type CacheAttributes } from '../factories/createQueryCacheInterceptor';
2
+ import { type ExtractedCacheAttributes } from './extractCacheAttributes';
3
+
4
+ export const normalizeCacheAttributes = (
5
+ extractedCacheAttributes: ExtractedCacheAttributes,
6
+ ): CacheAttributes => {
7
+ return {
8
+ discardEmpty: extractedCacheAttributes.discardEmpty,
9
+ key: extractedCacheAttributes.key
10
+ .replaceAll('$bodyHash', extractedCacheAttributes.bodyHash)
11
+ .replaceAll('$valueHash', extractedCacheAttributes.valueHash),
12
+ ttl: extractedCacheAttributes.ttl,
13
+ };
14
+ };