@smartive/datocms-utils 3.0.0-next.9 → 3.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.
- package/README.md +17 -12
- package/dist/cache-tags/provider/base.d.ts +15 -0
- package/dist/cache-tags/provider/base.js +35 -0
- package/dist/cache-tags/provider/base.js.map +1 -0
- package/dist/cache-tags/provider/neon.d.ts +14 -5
- package/dist/cache-tags/provider/neon.js +52 -24
- package/dist/cache-tags/provider/neon.js.map +1 -1
- package/dist/cache-tags/provider/redis.d.ts +14 -5
- package/dist/cache-tags/provider/redis.js +63 -26
- package/dist/cache-tags/provider/redis.js.map +1 -1
- package/dist/cache-tags/types.d.ts +20 -1
- package/dist/cache-tags/utils.d.ts +4 -3
- package/dist/cache-tags/utils.js +5 -3
- package/dist/cache-tags/utils.js.map +1 -1
- package/package.json +3 -3
- package/src/cache-tags/provider/base.ts +48 -0
- package/src/cache-tags/provider/neon.ts +87 -31
- package/src/cache-tags/provider/redis.ts +100 -36
- package/src/cache-tags/types.ts +18 -1
- package/src/cache-tags/utils.ts +9 -3
package/README.md
CHANGED
|
@@ -14,13 +14,13 @@ npm install @smartive/datocms-utils
|
|
|
14
14
|
|
|
15
15
|
#### `classNames`
|
|
16
16
|
|
|
17
|
-
Cleans and joins an array of class names, filtering out undefined and boolean values.
|
|
17
|
+
Cleans and joins an array of class names (strings and numbers), filtering out undefined and boolean values.
|
|
18
18
|
|
|
19
19
|
```typescript
|
|
20
20
|
import { classNames } from '@smartive/datocms-utils';
|
|
21
21
|
|
|
22
|
-
const className = classNames('btn', isActive && 'btn-active', undefined, 'btn-primary');
|
|
23
|
-
// Result: "btn btn-active btn-primary"
|
|
22
|
+
const className = classNames('btn', isActive && 'btn-active', 42, undefined, 'btn-primary');
|
|
23
|
+
// Result: "btn btn-active 42 btn-primary"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
#### `getTelLink`
|
|
@@ -41,10 +41,10 @@ Utilities for managing [DatoCMS cache tags](https://www.datocms.com/docs/content
|
|
|
41
41
|
#### Core Utilities
|
|
42
42
|
|
|
43
43
|
```typescript
|
|
44
|
-
import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache';
|
|
44
|
+
import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags';
|
|
45
45
|
|
|
46
46
|
// Generate a unique ID for a GraphQL query
|
|
47
|
-
const queryId = generateQueryId(document, variables);
|
|
47
|
+
const queryId = generateQueryId(document, variables, headers);
|
|
48
48
|
|
|
49
49
|
// Parse DatoCMS's X-Cache-Tags header
|
|
50
50
|
const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag');
|
|
@@ -53,7 +53,7 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag');
|
|
|
53
53
|
|
|
54
54
|
#### Storage Providers
|
|
55
55
|
|
|
56
|
-
The package provides
|
|
56
|
+
The package provides multiple storage backends for cache tags: **Neon (Postgres)**, **Redis**, and **Noop**. All implement the same `CacheTagsProvider` interface, with the Noop provider being especially useful for testing and development.
|
|
57
57
|
|
|
58
58
|
##### Neon (Postgres) Provider
|
|
59
59
|
|
|
@@ -83,8 +83,12 @@ npm install @neondatabase/serverless
|
|
|
83
83
|
import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon';
|
|
84
84
|
|
|
85
85
|
const provider = new NeonCacheTagsProvider({
|
|
86
|
-
|
|
86
|
+
connectionUrl: process.env.DATABASE_URL!,
|
|
87
87
|
table: 'query_cache_tags',
|
|
88
|
+
throwOnError: false, // Optional: Disable error throwing, defaults to `true`
|
|
89
|
+
onError(error, ctx) { // Optional: Custom error callback
|
|
90
|
+
console.error('CacheTagsProvider error', { error, context: ctx });
|
|
91
|
+
},
|
|
88
92
|
});
|
|
89
93
|
|
|
90
94
|
// Store cache tags for a query
|
|
@@ -118,13 +122,14 @@ npm install ioredis
|
|
|
118
122
|
import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';
|
|
119
123
|
|
|
120
124
|
const provider = new RedisCacheTagsProvider({
|
|
121
|
-
|
|
125
|
+
connectionUrl: process.env.REDIS_URL!,
|
|
122
126
|
keyPrefix: 'prod:', // Optional: namespace for multi-environment setups
|
|
127
|
+
throwOnError: process.env.NODE_ENV === 'development', // Optional: Disable error throwing in production - defaults to `true`
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
// Same API as Neon provider
|
|
126
131
|
await provider.storeQueryCacheTags(queryId, ['item:42', 'product']);
|
|
127
|
-
const queries = await
|
|
132
|
+
const queries = await provider.queriesReferencingCacheTags(['item:42']);
|
|
128
133
|
await provider.deleteCacheTags(['item:42']);
|
|
129
134
|
await provider.truncateCacheTags();
|
|
130
135
|
```
|
|
@@ -154,16 +159,16 @@ Both providers implement:
|
|
|
154
159
|
### Complete Example
|
|
155
160
|
|
|
156
161
|
```typescript
|
|
157
|
-
import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache';
|
|
162
|
+
import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags';
|
|
158
163
|
import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';
|
|
159
164
|
|
|
160
165
|
const provider = new RedisCacheTagsProvider({
|
|
161
|
-
|
|
166
|
+
connectionUrl: process.env.REDIS_URL!,
|
|
162
167
|
keyPrefix: 'myapp:',
|
|
163
168
|
});
|
|
164
169
|
|
|
165
170
|
// After making a DatoCMS query
|
|
166
|
-
const queryId = generateQueryId(
|
|
171
|
+
const queryId = generateQueryId(document, variables, request.headers);
|
|
167
172
|
const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']);
|
|
168
173
|
await provider.storeQueryCacheTags(queryId, cacheTags);
|
|
169
174
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* An abstract base class for `CacheTagsProvider` implementations that adds error handling and logging.
|
|
4
|
+
*/
|
|
5
|
+
export declare abstract class AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
|
|
6
|
+
protected readonly providerName: string;
|
|
7
|
+
protected readonly throwOnError: boolean;
|
|
8
|
+
protected readonly onError?: CacheTagsProviderErrorHandlingConfig['onError'];
|
|
9
|
+
protected constructor(providerName: string, config?: CacheTagsProviderErrorHandlingConfig);
|
|
10
|
+
abstract storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void>;
|
|
11
|
+
abstract queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise<string[]>;
|
|
12
|
+
abstract deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
|
|
13
|
+
abstract truncateCacheTags(): Promise<number>;
|
|
14
|
+
protected wrap<T>(method: keyof CacheTagsProvider, args: unknown[], fn: () => Promise<T>, fallback: T): Promise<T>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An abstract base class for `CacheTagsProvider` implementations that adds error handling and logging.
|
|
3
|
+
*/
|
|
4
|
+
export class AbstractErrorHandlingCacheTagsProvider {
|
|
5
|
+
providerName;
|
|
6
|
+
throwOnError;
|
|
7
|
+
onError;
|
|
8
|
+
constructor(providerName, config = {}) {
|
|
9
|
+
this.providerName = providerName;
|
|
10
|
+
this.throwOnError = config.throwOnError ?? true;
|
|
11
|
+
this.onError = config.onError;
|
|
12
|
+
}
|
|
13
|
+
async wrap(method, args, fn, fallback) {
|
|
14
|
+
try {
|
|
15
|
+
return await fn();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const provider = this.providerName;
|
|
19
|
+
// Call onError callback if provided, but guard against exceptions
|
|
20
|
+
// to prevent masking the original provider error
|
|
21
|
+
try {
|
|
22
|
+
this.onError?.(error, { provider, method, args });
|
|
23
|
+
}
|
|
24
|
+
catch (handlerError) {
|
|
25
|
+
console.error(`Error handler itself failed in ${provider}.${method}.`, { handlerError });
|
|
26
|
+
}
|
|
27
|
+
if (this.throwOnError) {
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
console.warn(`Error occurred in ${provider}.${method}.`, { error, args });
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=base.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/base.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,OAAgB,sCAAsC;IAKrC;IAJF,YAAY,CAAU;IACtB,OAAO,CAAmD;IAE7E,YACqB,YAAoB,EACvC,SAA+C,EAAE;QAD9B,iBAAY,GAAZ,YAAY,CAAQ;QAGvC,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC,YAAY,IAAI,IAAI,CAAC;QAChD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IAChC,CAAC;IAUS,KAAK,CAAC,IAAI,CAAI,MAA+B,EAAE,IAAe,EAAE,EAAoB,EAAE,QAAW;QACzG,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAC;QACpB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,CAAC;YAEnC,kEAAkE;YAClE,iDAAiD;YACjD,IAAI,CAAC;gBACH,IAAI,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YACpD,CAAC;YAAC,OAAO,YAAY,EAAE,CAAC;gBACtB,OAAO,CAAC,KAAK,CAAC,kCAAkC,QAAQ,IAAI,MAAM,GAAG,EAAE,EAAE,YAAY,EAAE,CAAC,CAAC;YAC3F,CAAC;YAED,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;gBACtB,MAAM,KAAK,CAAC;YACd,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,qBAAqB,QAAQ,IAAI,MAAM,GAAG,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAE1E,OAAO,QAAQ,CAAC;QAClB,CAAC;IACH,CAAC;CACF"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
|
|
2
|
+
import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
|
|
3
|
+
type NeonCacheTagsProviderBaseConfig = {
|
|
3
4
|
/**
|
|
4
5
|
* Neon connection string. You can find it in the "Connection" tab of your Neon project dashboard.
|
|
5
6
|
* Has the format `postgresql://user:pass@host/db`
|
|
@@ -18,16 +19,24 @@ type NeonCacheTagsProviderConfig = {
|
|
|
18
19
|
*/
|
|
19
20
|
readonly table: string;
|
|
20
21
|
};
|
|
22
|
+
export type NeonCacheTagsProviderConfig = NeonCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
|
|
21
23
|
/**
|
|
22
24
|
* A `CacheTagsProvider` implementation that uses Neon as the storage backend.
|
|
23
25
|
*/
|
|
24
|
-
export declare class NeonCacheTagsProvider implements CacheTagsProvider {
|
|
26
|
+
export declare class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
|
|
25
27
|
private readonly sql;
|
|
26
28
|
private readonly table;
|
|
27
|
-
constructor({ connectionUrl, table }: NeonCacheTagsProviderConfig);
|
|
28
|
-
storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void>;
|
|
29
|
+
constructor({ connectionUrl, table, throwOnError, onError }: NeonCacheTagsProviderConfig);
|
|
30
|
+
storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void | undefined>;
|
|
29
31
|
queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise<string[]>;
|
|
30
32
|
deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
|
|
31
33
|
truncateCacheTags(): Promise<number>;
|
|
34
|
+
/**
|
|
35
|
+
* Validates and quotes a PostgreSQL identifier (table name, column name, etc.) to prevent SQL injection.
|
|
36
|
+
* @param identifier The identifier to validate and quote
|
|
37
|
+
* @returns The properly quoted identifier
|
|
38
|
+
* @throws Error if the identifier is invalid
|
|
39
|
+
*/
|
|
40
|
+
private static quoteIdentifier;
|
|
32
41
|
}
|
|
33
42
|
export {};
|
|
@@ -1,44 +1,72 @@
|
|
|
1
1
|
import { neon } from '@neondatabase/serverless';
|
|
2
|
+
import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
|
|
2
3
|
/**
|
|
3
4
|
* A `CacheTagsProvider` implementation that uses Neon as the storage backend.
|
|
4
5
|
*/
|
|
5
|
-
export class NeonCacheTagsProvider {
|
|
6
|
+
export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider {
|
|
6
7
|
sql;
|
|
7
8
|
table;
|
|
8
|
-
constructor({ connectionUrl, table }) {
|
|
9
|
+
constructor({ connectionUrl, table, throwOnError, onError }) {
|
|
10
|
+
super('NeonCacheTagsProvider', { throwOnError, onError });
|
|
9
11
|
this.sql = neon(connectionUrl, { fullResults: true });
|
|
10
|
-
this.table = table;
|
|
12
|
+
this.table = NeonCacheTagsProvider.quoteIdentifier(table);
|
|
11
13
|
}
|
|
12
14
|
async storeQueryCacheTags(queryId, cacheTags) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
return this.wrap('storeQueryCacheTags', [queryId, cacheTags], async () => {
|
|
16
|
+
if (!cacheTags?.length) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]);
|
|
20
|
+
const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(',');
|
|
21
|
+
await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags);
|
|
22
|
+
}, undefined);
|
|
19
23
|
}
|
|
20
24
|
async queriesReferencingCacheTags(cacheTags) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
|
|
25
|
-
const { rows } = await this.sql.query(`SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags);
|
|
26
|
-
return rows.reduce((queryIds, row) => {
|
|
27
|
-
if (typeof row.query_id === 'string') {
|
|
28
|
-
queryIds.push(row.query_id);
|
|
25
|
+
return this.wrap('queriesReferencingCacheTags', [cacheTags], async () => {
|
|
26
|
+
if (!cacheTags?.length) {
|
|
27
|
+
return [];
|
|
29
28
|
}
|
|
30
|
-
|
|
29
|
+
const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
|
|
30
|
+
const { rows } = await this.sql.query(`SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags);
|
|
31
|
+
return rows.reduce((queryIds, row) => {
|
|
32
|
+
if (typeof row.query_id === 'string') {
|
|
33
|
+
queryIds.push(row.query_id);
|
|
34
|
+
}
|
|
35
|
+
return queryIds;
|
|
36
|
+
}, []);
|
|
31
37
|
}, []);
|
|
32
38
|
}
|
|
33
39
|
async deleteCacheTags(cacheTags) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
return this.wrap('deleteCacheTags', [cacheTags], async () => {
|
|
41
|
+
if (!cacheTags?.length) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
|
|
45
|
+
return ((await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0);
|
|
46
|
+
}, 0);
|
|
39
47
|
}
|
|
40
48
|
async truncateCacheTags() {
|
|
41
|
-
return
|
|
49
|
+
return this.wrap('truncateCacheTags', [], async () => {
|
|
50
|
+
return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0;
|
|
51
|
+
}, 0);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Validates and quotes a PostgreSQL identifier (table name, column name, etc.) to prevent SQL injection.
|
|
55
|
+
* @param identifier The identifier to validate and quote
|
|
56
|
+
* @returns The properly quoted identifier
|
|
57
|
+
* @throws Error if the identifier is invalid
|
|
58
|
+
*/
|
|
59
|
+
static quoteIdentifier(identifier) {
|
|
60
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)?$/.test(identifier)) {
|
|
61
|
+
throw new Error(`Invalid table name: ${identifier}. Table names must start with a letter, underscore, or dollar sign and contain only letters, digits, underscores, and dollar signs. Schema-qualified names (e.g., "schema.table") are supported.`);
|
|
62
|
+
}
|
|
63
|
+
// Quote the identifier using double quotes to prevent SQL injection
|
|
64
|
+
// Handle schema-qualified names (e.g., "schema.table")
|
|
65
|
+
// Escape any double quotes within the identifier by doubling them
|
|
66
|
+
return identifier
|
|
67
|
+
.split('.')
|
|
68
|
+
.map((part) => `"${part.replace(/"/g, '""')}"`)
|
|
69
|
+
.join('.');
|
|
42
70
|
}
|
|
43
71
|
}
|
|
44
72
|
//# sourceMappingURL=neon.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"neon.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/neon.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"neon.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/neon.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
|
|
2
|
+
import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
|
|
3
|
+
type RedisCacheTagsProviderBaseConfig = {
|
|
3
4
|
/**
|
|
4
5
|
* Redis connection string. For example, `redis://user:pass@host:port/db`.
|
|
5
6
|
*/
|
|
@@ -11,16 +12,24 @@ type RedisCacheTagsProviderConfig = {
|
|
|
11
12
|
*/
|
|
12
13
|
readonly keyPrefix?: string;
|
|
13
14
|
};
|
|
15
|
+
export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
|
|
14
16
|
/**
|
|
15
17
|
* A `CacheTagsProvider` implementation that uses Redis as the storage backend.
|
|
16
18
|
*/
|
|
17
|
-
export declare class RedisCacheTagsProvider implements CacheTagsProvider {
|
|
19
|
+
export declare class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
|
|
18
20
|
private readonly redis;
|
|
19
21
|
private readonly keyPrefix;
|
|
20
|
-
constructor({ connectionUrl, keyPrefix }: RedisCacheTagsProviderConfig);
|
|
21
|
-
storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void>;
|
|
22
|
+
constructor({ connectionUrl, keyPrefix, throwOnError, onError }: RedisCacheTagsProviderConfig);
|
|
23
|
+
storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void | undefined>;
|
|
22
24
|
queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise<string[]>;
|
|
23
25
|
deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
|
|
24
26
|
truncateCacheTags(): Promise<number>;
|
|
27
|
+
/**
|
|
28
|
+
* Retrieves all keys matching the given pattern using the Redis SCAN command.
|
|
29
|
+
* This method is more efficient than using the KEYS command, especially for large datasets.
|
|
30
|
+
*
|
|
31
|
+
* @returns An array of matching keys
|
|
32
|
+
*/
|
|
33
|
+
private getKeys;
|
|
25
34
|
}
|
|
26
35
|
export {};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
|
+
import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
|
|
2
3
|
/**
|
|
3
4
|
* A `CacheTagsProvider` implementation that uses Redis as the storage backend.
|
|
4
5
|
*/
|
|
5
|
-
export class RedisCacheTagsProvider {
|
|
6
|
+
export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider {
|
|
6
7
|
redis;
|
|
7
8
|
keyPrefix;
|
|
8
|
-
constructor({ connectionUrl, keyPrefix }) {
|
|
9
|
+
constructor({ connectionUrl, keyPrefix, throwOnError, onError }) {
|
|
10
|
+
super('RedisCacheTagsProvider', { throwOnError, onError });
|
|
9
11
|
this.redis = new Redis(connectionUrl, {
|
|
10
12
|
maxRetriesPerRequest: 3,
|
|
11
13
|
lazyConnect: true,
|
|
@@ -13,36 +15,71 @@ export class RedisCacheTagsProvider {
|
|
|
13
15
|
this.keyPrefix = keyPrefix ?? '';
|
|
14
16
|
}
|
|
15
17
|
async storeQueryCacheTags(queryId, cacheTags) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
return this.wrap('storeQueryCacheTags', [queryId, cacheTags], async () => {
|
|
19
|
+
if (!cacheTags?.length) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const pipeline = this.redis.pipeline();
|
|
23
|
+
for (const tag of cacheTags) {
|
|
24
|
+
pipeline.sadd(`${this.keyPrefix}${tag}`, queryId);
|
|
25
|
+
}
|
|
26
|
+
const results = await pipeline.exec();
|
|
27
|
+
const error = results?.find(([err]) => err)?.[0];
|
|
28
|
+
if (error) {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}, undefined);
|
|
24
32
|
}
|
|
25
33
|
async queriesReferencingCacheTags(cacheTags) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
return this.wrap('queriesReferencingCacheTags', [cacheTags], async () => {
|
|
35
|
+
if (!cacheTags?.length) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
|
|
39
|
+
return this.redis.sunion(...keys);
|
|
40
|
+
}, []);
|
|
31
41
|
}
|
|
32
42
|
async deleteCacheTags(cacheTags) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
return this.wrap('deleteCacheTags', [cacheTags], async () => {
|
|
44
|
+
if (!cacheTags?.length) {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
|
|
48
|
+
return this.redis.del(...keys);
|
|
49
|
+
}, 0);
|
|
38
50
|
}
|
|
39
51
|
async truncateCacheTags() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
return this.wrap('truncateCacheTags', [], async () => {
|
|
53
|
+
const keys = await this.getKeys();
|
|
54
|
+
if (keys.length === 0) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
return await this.redis.del(...keys);
|
|
58
|
+
}, 0);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Retrieves all keys matching the given pattern using the Redis SCAN command.
|
|
62
|
+
* This method is more efficient than using the KEYS command, especially for large datasets.
|
|
63
|
+
*
|
|
64
|
+
* @returns An array of matching keys
|
|
65
|
+
*/
|
|
66
|
+
async getKeys() {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const keys = [];
|
|
69
|
+
const stream = this.redis.scanStream({
|
|
70
|
+
match: `${this.keyPrefix}*`,
|
|
71
|
+
count: 1000,
|
|
72
|
+
});
|
|
73
|
+
stream.on('data', (resultKeys) => {
|
|
74
|
+
keys.push(...resultKeys);
|
|
75
|
+
});
|
|
76
|
+
stream.on('end', () => {
|
|
77
|
+
resolve(keys);
|
|
78
|
+
});
|
|
79
|
+
stream.on('error', (err) => {
|
|
80
|
+
reject(err);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
46
83
|
}
|
|
47
84
|
}
|
|
48
85
|
//# sourceMappingURL=redis.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redis.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/redis.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"redis.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/redis.ts"],"names":[],"mappings":"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"}
|
|
@@ -50,7 +50,7 @@ export interface CacheTagsProvider {
|
|
|
50
50
|
* run again, fresh cache tag mappings will be created.
|
|
51
51
|
*
|
|
52
52
|
* @param {CacheTag[]} cacheTags Array of cache tags to delete
|
|
53
|
-
* @returns Number of keys deleted
|
|
53
|
+
* @returns Number of keys deleted
|
|
54
54
|
*
|
|
55
55
|
*/
|
|
56
56
|
deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
|
|
@@ -61,3 +61,22 @@ export interface CacheTagsProvider {
|
|
|
61
61
|
*/
|
|
62
62
|
truncateCacheTags(): Promise<number>;
|
|
63
63
|
}
|
|
64
|
+
export type CacheTagsProviderErrorHandlingConfig = {
|
|
65
|
+
/**
|
|
66
|
+
* If false, errors are suppressed and a fallback value is returned.
|
|
67
|
+
* Default: true
|
|
68
|
+
*/
|
|
69
|
+
throwOnError?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Optional callback invoked when an error occurs in a `CacheTagsProvider` method,
|
|
72
|
+
* useful for logging and telemetry.
|
|
73
|
+
*
|
|
74
|
+
* Called before the error is either thrown (when `throwOnError` is true or
|
|
75
|
+
* undefined) or suppressed (when `throwOnError` is false).
|
|
76
|
+
*/
|
|
77
|
+
onError?: (error: unknown, ctx: {
|
|
78
|
+
provider: string;
|
|
79
|
+
method: keyof CacheTagsProvider;
|
|
80
|
+
args: unknown[];
|
|
81
|
+
}) => void;
|
|
82
|
+
};
|
|
@@ -9,10 +9,11 @@ import { type CacheTag } from './types.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const parseXCacheTagsResponseHeader: (string?: null | string) => CacheTag[];
|
|
11
11
|
/**
|
|
12
|
-
* Generates a unique query ID based on the query document and
|
|
12
|
+
* Generates a unique query ID based on the query document, its variables, and optional HTTP headers.
|
|
13
13
|
*
|
|
14
14
|
* @param {DocumentNode} document Query document
|
|
15
|
-
* @param {TVariables} variables
|
|
15
|
+
* @param {TVariables} variables Optional query variables
|
|
16
|
+
* @param {HeadersInit} headers Optional HTTP headers that might affect the query result (e.g., for authentication)
|
|
16
17
|
* @returns Unique query ID
|
|
17
18
|
*/
|
|
18
|
-
export declare const generateQueryId: <TVariables = unknown>(document: DocumentNode, variables?: TVariables) => string;
|
|
19
|
+
export declare const generateQueryId: <TVariables = unknown>(document: DocumentNode, variables?: TVariables, headers?: HeadersInit) => string;
|
package/dist/cache-tags/utils.js
CHANGED
|
@@ -9,16 +9,18 @@ import { createHash } from 'node:crypto';
|
|
|
9
9
|
*/
|
|
10
10
|
export const parseXCacheTagsResponseHeader = (string) => (string?.split(' ') ?? []).map((tag) => tag);
|
|
11
11
|
/**
|
|
12
|
-
* Generates a unique query ID based on the query document and
|
|
12
|
+
* Generates a unique query ID based on the query document, its variables, and optional HTTP headers.
|
|
13
13
|
*
|
|
14
14
|
* @param {DocumentNode} document Query document
|
|
15
|
-
* @param {TVariables} variables
|
|
15
|
+
* @param {TVariables} variables Optional query variables
|
|
16
|
+
* @param {HeadersInit} headers Optional HTTP headers that might affect the query result (e.g., for authentication)
|
|
16
17
|
* @returns Unique query ID
|
|
17
18
|
*/
|
|
18
|
-
export const generateQueryId = (document, variables) => {
|
|
19
|
+
export const generateQueryId = (document, variables, headers) => {
|
|
19
20
|
return createHash('sha1')
|
|
20
21
|
.update(print(document))
|
|
21
22
|
.update(JSON.stringify(variables) || '')
|
|
23
|
+
.update(JSON.stringify(headers) || '')
|
|
22
24
|
.digest('hex');
|
|
23
25
|
};
|
|
24
26
|
//# sourceMappingURL=utils.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/cache-tags/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAqB,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,MAAsB,EAAE,EAAE,CACtE,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAe,CAAC,CAAC;AAE3D
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/cache-tags/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAqB,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,MAAsB,EAAE,EAAE,CACtE,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAe,CAAC,CAAC;AAE3D;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,QAAsB,EACtB,SAAsB,EACtB,OAAqB,EACb,EAAE;IACV,OAAO,UAAU,CAAC,MAAM,CAAC;SACtB,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;SACvB,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACvC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;SACrC,MAAM,CAAC,KAAK,CAAC,CAAC;AACnB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartive/datocms-utils",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "A set of utilities and helpers to work with DatoCMS in a Next.js project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"source": "./src/index.ts",
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
"@neondatabase/serverless": "1.0.2",
|
|
54
54
|
"@smartive/eslint-config": "7.0.1",
|
|
55
55
|
"@smartive/prettier-config": "3.1.2",
|
|
56
|
-
"@types/node": "24.10.
|
|
56
|
+
"@types/node": "24.10.13",
|
|
57
57
|
"eslint": "9.39.2",
|
|
58
58
|
"eslint-import-resolver-typescript": "4.4.4",
|
|
59
59
|
"graphql": "16.12.0",
|
|
60
|
-
"ioredis": "5.9.
|
|
60
|
+
"ioredis": "5.9.3",
|
|
61
61
|
"prettier": "3.8.1",
|
|
62
62
|
"rimraf": "6.1.2",
|
|
63
63
|
"typescript": "5.9.3"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An abstract base class for `CacheTagsProvider` implementations that adds error handling and logging.
|
|
5
|
+
*/
|
|
6
|
+
export abstract class AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
|
|
7
|
+
protected readonly throwOnError: boolean;
|
|
8
|
+
protected readonly onError?: CacheTagsProviderErrorHandlingConfig['onError'];
|
|
9
|
+
|
|
10
|
+
protected constructor(
|
|
11
|
+
protected readonly providerName: string,
|
|
12
|
+
config: CacheTagsProviderErrorHandlingConfig = {},
|
|
13
|
+
) {
|
|
14
|
+
this.throwOnError = config.throwOnError ?? true;
|
|
15
|
+
this.onError = config.onError;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public abstract storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void>;
|
|
19
|
+
|
|
20
|
+
public abstract queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise<string[]>;
|
|
21
|
+
|
|
22
|
+
public abstract deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
|
|
23
|
+
|
|
24
|
+
public abstract truncateCacheTags(): Promise<number>;
|
|
25
|
+
|
|
26
|
+
protected async wrap<T>(method: keyof CacheTagsProvider, args: unknown[], fn: () => Promise<T>, fallback: T): Promise<T> {
|
|
27
|
+
try {
|
|
28
|
+
return await fn();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const provider = this.providerName;
|
|
31
|
+
|
|
32
|
+
// Call onError callback if provided, but guard against exceptions
|
|
33
|
+
// to prevent masking the original provider error
|
|
34
|
+
try {
|
|
35
|
+
this.onError?.(error, { provider, method, args });
|
|
36
|
+
} catch (handlerError) {
|
|
37
|
+
console.error(`Error handler itself failed in ${provider}.${method}.`, { handlerError });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (this.throwOnError) {
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
console.warn(`Error occurred in ${provider}.${method}.`, { error, args });
|
|
44
|
+
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { neon } from '@neondatabase/serverless';
|
|
2
|
-
import {
|
|
2
|
+
import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
|
|
3
|
+
import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
+
type NeonCacheTagsProviderBaseConfig = {
|
|
5
6
|
/**
|
|
6
7
|
* Neon connection string. You can find it in the "Connection" tab of your Neon project dashboard.
|
|
7
8
|
* Has the format `postgresql://user:pass@host/db`
|
|
@@ -21,60 +22,115 @@ type NeonCacheTagsProviderConfig = {
|
|
|
21
22
|
readonly table: string;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
export type NeonCacheTagsProviderConfig = NeonCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
|
|
26
|
+
|
|
24
27
|
/**
|
|
25
28
|
* A `CacheTagsProvider` implementation that uses Neon as the storage backend.
|
|
26
29
|
*/
|
|
27
|
-
export class NeonCacheTagsProvider implements CacheTagsProvider {
|
|
30
|
+
export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
|
|
28
31
|
private readonly sql;
|
|
29
32
|
private readonly table;
|
|
30
33
|
|
|
31
|
-
constructor({ connectionUrl, table }: NeonCacheTagsProviderConfig) {
|
|
34
|
+
constructor({ connectionUrl, table, throwOnError, onError }: NeonCacheTagsProviderConfig) {
|
|
35
|
+
super('NeonCacheTagsProvider', { throwOnError, onError });
|
|
32
36
|
this.sql = neon(connectionUrl, { fullResults: true });
|
|
33
|
-
this.table = table;
|
|
37
|
+
this.table = NeonCacheTagsProvider.quoteIdentifier(table);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
return this.wrap(
|
|
42
|
+
'storeQueryCacheTags',
|
|
43
|
+
[queryId, cacheTags],
|
|
44
|
+
async () => {
|
|
45
|
+
if (!cacheTags?.length) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]);
|
|
50
|
+
const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(',');
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags);
|
|
53
|
+
},
|
|
54
|
+
undefined,
|
|
55
|
+
);
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
public async queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise<string[]> {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
return this.wrap(
|
|
60
|
+
'queriesReferencingCacheTags',
|
|
61
|
+
[cacheTags],
|
|
62
|
+
async () => {
|
|
63
|
+
if (!cacheTags?.length) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
const { rows } = await this.sql.query(
|
|
70
|
+
`SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`,
|
|
71
|
+
cacheTags,
|
|
72
|
+
);
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
return rows.reduce<string[]>((queryIds, row) => {
|
|
75
|
+
if (typeof row.query_id === 'string') {
|
|
76
|
+
queryIds.push(row.query_id);
|
|
77
|
+
}
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
return queryIds;
|
|
80
|
+
}, []);
|
|
81
|
+
},
|
|
82
|
+
[],
|
|
83
|
+
);
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
public async deleteCacheTags(cacheTags: CacheTag[]) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
return this.wrap(
|
|
88
|
+
'deleteCacheTags',
|
|
89
|
+
[cacheTags],
|
|
90
|
+
async () => {
|
|
91
|
+
if (!cacheTags?.length) {
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
|
|
73
95
|
|
|
74
|
-
|
|
96
|
+
return (
|
|
97
|
+
(await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
0,
|
|
101
|
+
);
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
public async truncateCacheTags() {
|
|
78
|
-
return
|
|
105
|
+
return this.wrap(
|
|
106
|
+
'truncateCacheTags',
|
|
107
|
+
[],
|
|
108
|
+
async () => {
|
|
109
|
+
return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0;
|
|
110
|
+
},
|
|
111
|
+
0,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validates and quotes a PostgreSQL identifier (table name, column name, etc.) to prevent SQL injection.
|
|
117
|
+
* @param identifier The identifier to validate and quote
|
|
118
|
+
* @returns The properly quoted identifier
|
|
119
|
+
* @throws Error if the identifier is invalid
|
|
120
|
+
*/
|
|
121
|
+
private static quoteIdentifier(identifier: string): string {
|
|
122
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)?$/.test(identifier)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Invalid table name: ${identifier}. Table names must start with a letter, underscore, or dollar sign and contain only letters, digits, underscores, and dollar signs. Schema-qualified names (e.g., "schema.table") are supported.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Quote the identifier using double quotes to prevent SQL injection
|
|
129
|
+
// Handle schema-qualified names (e.g., "schema.table")
|
|
130
|
+
// Escape any double quotes within the identifier by doubling them
|
|
131
|
+
return identifier
|
|
132
|
+
.split('.')
|
|
133
|
+
.map((part) => `"${part.replace(/"/g, '""')}"`)
|
|
134
|
+
.join('.');
|
|
79
135
|
}
|
|
80
136
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
|
-
import {
|
|
2
|
+
import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
|
|
3
|
+
import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
+
type RedisCacheTagsProviderBaseConfig = {
|
|
5
6
|
/**
|
|
6
7
|
* Redis connection string. For example, `redis://user:pass@host:port/db`.
|
|
7
8
|
*/
|
|
@@ -14,14 +15,17 @@ type RedisCacheTagsProviderConfig = {
|
|
|
14
15
|
readonly keyPrefix?: string;
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* A `CacheTagsProvider` implementation that uses Redis as the storage backend.
|
|
19
22
|
*/
|
|
20
|
-
export class RedisCacheTagsProvider implements CacheTagsProvider {
|
|
23
|
+
export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
|
|
21
24
|
private readonly redis;
|
|
22
25
|
private readonly keyPrefix;
|
|
23
26
|
|
|
24
|
-
constructor({ connectionUrl, keyPrefix }: RedisCacheTagsProviderConfig) {
|
|
27
|
+
constructor({ connectionUrl, keyPrefix, throwOnError, onError }: RedisCacheTagsProviderConfig) {
|
|
28
|
+
super('RedisCacheTagsProvider', { throwOnError, onError });
|
|
25
29
|
this.redis = new Redis(connectionUrl, {
|
|
26
30
|
maxRetriesPerRequest: 3,
|
|
27
31
|
lazyConnect: true,
|
|
@@ -30,47 +34,107 @@ export class RedisCacheTagsProvider implements CacheTagsProvider {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
return this.wrap(
|
|
38
|
+
'storeQueryCacheTags',
|
|
39
|
+
[queryId, cacheTags],
|
|
40
|
+
async () => {
|
|
41
|
+
if (!cacheTags?.length) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pipeline = this.redis.pipeline();
|
|
46
|
+
|
|
47
|
+
for (const tag of cacheTags) {
|
|
48
|
+
pipeline.sadd(`${this.keyPrefix}${tag}`, queryId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const results = await pipeline.exec();
|
|
52
|
+
const error = results?.find(([err]) => err)?.[0];
|
|
53
|
+
if (error) {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
undefined,
|
|
58
|
+
);
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
public async queriesReferencingCacheTags(cacheTags: CacheTag[]) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
return this.wrap(
|
|
63
|
+
'queriesReferencingCacheTags',
|
|
64
|
+
[cacheTags],
|
|
65
|
+
async () => {
|
|
66
|
+
if (!cacheTags?.length) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
|
|
71
|
+
|
|
72
|
+
return this.redis.sunion(...keys);
|
|
73
|
+
},
|
|
74
|
+
[],
|
|
75
|
+
);
|
|
54
76
|
}
|
|
55
77
|
|
|
56
78
|
public async deleteCacheTags(cacheTags: CacheTag[]) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
return this.wrap(
|
|
80
|
+
'deleteCacheTags',
|
|
81
|
+
[cacheTags],
|
|
82
|
+
async () => {
|
|
83
|
+
if (!cacheTags?.length) {
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
|
|
88
|
+
|
|
89
|
+
return this.redis.del(...keys);
|
|
90
|
+
},
|
|
91
|
+
0,
|
|
92
|
+
);
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
public async truncateCacheTags() {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
return this.wrap(
|
|
97
|
+
'truncateCacheTags',
|
|
98
|
+
[],
|
|
99
|
+
async () => {
|
|
100
|
+
const keys = await this.getKeys();
|
|
101
|
+
|
|
102
|
+
if (keys.length === 0) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return await this.redis.del(...keys);
|
|
107
|
+
},
|
|
108
|
+
0,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
73
111
|
|
|
74
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Retrieves all keys matching the given pattern using the Redis SCAN command.
|
|
114
|
+
* This method is more efficient than using the KEYS command, especially for large datasets.
|
|
115
|
+
*
|
|
116
|
+
* @returns An array of matching keys
|
|
117
|
+
*/
|
|
118
|
+
private async getKeys(): Promise<string[]> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const keys: string[] = [];
|
|
121
|
+
|
|
122
|
+
const stream = this.redis.scanStream({
|
|
123
|
+
match: `${this.keyPrefix}*`,
|
|
124
|
+
count: 1000,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
stream.on('data', (resultKeys: string[]) => {
|
|
128
|
+
keys.push(...resultKeys);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
stream.on('end', () => {
|
|
132
|
+
resolve(keys);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
stream.on('error', (err) => {
|
|
136
|
+
reject(err);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
75
139
|
}
|
|
76
140
|
}
|
package/src/cache-tags/types.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface CacheTagsProvider {
|
|
|
52
52
|
* run again, fresh cache tag mappings will be created.
|
|
53
53
|
*
|
|
54
54
|
* @param {CacheTag[]} cacheTags Array of cache tags to delete
|
|
55
|
-
* @returns Number of keys deleted
|
|
55
|
+
* @returns Number of keys deleted
|
|
56
56
|
*
|
|
57
57
|
*/
|
|
58
58
|
deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
|
|
@@ -64,3 +64,20 @@ export interface CacheTagsProvider {
|
|
|
64
64
|
*/
|
|
65
65
|
truncateCacheTags(): Promise<number>;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
export type CacheTagsProviderErrorHandlingConfig = {
|
|
69
|
+
/**
|
|
70
|
+
* If false, errors are suppressed and a fallback value is returned.
|
|
71
|
+
* Default: true
|
|
72
|
+
*/
|
|
73
|
+
throwOnError?: boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Optional callback invoked when an error occurs in a `CacheTagsProvider` method,
|
|
77
|
+
* useful for logging and telemetry.
|
|
78
|
+
*
|
|
79
|
+
* Called before the error is either thrown (when `throwOnError` is true or
|
|
80
|
+
* undefined) or suppressed (when `throwOnError` is false).
|
|
81
|
+
*/
|
|
82
|
+
onError?: (error: unknown, ctx: { provider: string; method: keyof CacheTagsProvider; args: unknown[] }) => void;
|
|
83
|
+
};
|
package/src/cache-tags/utils.ts
CHANGED
|
@@ -13,15 +13,21 @@ export const parseXCacheTagsResponseHeader = (string?: null | string) =>
|
|
|
13
13
|
(string?.split(' ') ?? []).map((tag) => tag as CacheTag);
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Generates a unique query ID based on the query document and
|
|
16
|
+
* Generates a unique query ID based on the query document, its variables, and optional HTTP headers.
|
|
17
17
|
*
|
|
18
18
|
* @param {DocumentNode} document Query document
|
|
19
|
-
* @param {TVariables} variables
|
|
19
|
+
* @param {TVariables} variables Optional query variables
|
|
20
|
+
* @param {HeadersInit} headers Optional HTTP headers that might affect the query result (e.g., for authentication)
|
|
20
21
|
* @returns Unique query ID
|
|
21
22
|
*/
|
|
22
|
-
export const generateQueryId = <TVariables = unknown>(
|
|
23
|
+
export const generateQueryId = <TVariables = unknown>(
|
|
24
|
+
document: DocumentNode,
|
|
25
|
+
variables?: TVariables,
|
|
26
|
+
headers?: HeadersInit,
|
|
27
|
+
): string => {
|
|
23
28
|
return createHash('sha1')
|
|
24
29
|
.update(print(document))
|
|
25
30
|
.update(JSON.stringify(variables) || '')
|
|
31
|
+
.update(JSON.stringify(headers) || '')
|
|
26
32
|
.digest('hex');
|
|
27
33
|
};
|