@smartive/datocms-utils 3.0.0-next.11 → 3.0.0-next.13

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 CHANGED
@@ -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 two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `CacheTagsProvider` interface.
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
 
@@ -163,7 +163,7 @@ const provider = new RedisCacheTagsProvider({
163
163
  });
164
164
 
165
165
  // After making a DatoCMS query
166
- const queryId = generateQueryId(query, variables);
166
+ const queryId = generateQueryId(document, variables);
167
167
  const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']);
168
168
  await provider.storeQueryCacheTags(queryId, cacheTags);
169
169
 
@@ -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.debug(`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,KAAK,CAAC,qBAAqB,QAAQ,IAAI,MAAM,GAAG,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAE3E,OAAO,QAAQ,CAAC;QAClB,CAAC;IACH,CAAC;CACF"}
@@ -1,5 +1,6 @@
1
- import { type CacheTag, type CacheTagsProvider } from '../types.js';
2
- type NeonCacheTagsProviderConfig = {
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,14 +19,15 @@ 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>;
@@ -1,44 +1,54 @@
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
12
  this.table = NeonCacheTagsProvider.quoteIdentifier(table);
11
13
  }
12
14
  async storeQueryCacheTags(queryId, cacheTags) {
13
- if (!cacheTags?.length) {
14
- return;
15
- }
16
- const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]);
17
- const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(',');
18
- await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags);
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
- if (!cacheTags?.length) {
22
- return [];
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
- return queryIds;
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
- if (!cacheTags?.length) {
35
- return 0;
36
- }
37
- const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
38
- return (await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0;
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 (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0;
49
+ return this.wrap('truncateCacheTags', [], async () => {
50
+ return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0;
51
+ }, 0);
42
52
  }
43
53
  /**
44
54
  * Validates and quotes a PostgreSQL identifier (table name, column name, etc.) to prevent SQL injection.
@@ -47,9 +57,6 @@ export class NeonCacheTagsProvider {
47
57
  * @throws Error if the identifier is invalid
48
58
  */
49
59
  static quoteIdentifier(identifier) {
50
- // Validate that the identifier contains only valid characters
51
- // PostgreSQL identifiers can contain letters, digits, underscores, and dollar signs
52
- // They can also contain dots for schema-qualified names
53
60
  if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)?$/.test(identifier)) {
54
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.`);
55
62
  }
@@ -1 +1 @@
1
- {"version":3,"file":"neon.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/neon.ts"],"names":[],"mappings":"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"}
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 { type CacheTag, type CacheTagsProvider } from '../types.js';
2
- type RedisCacheTagsProviderConfig = {
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,14 +12,15 @@ 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>;
@@ -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,35 +15,47 @@ export class RedisCacheTagsProvider {
13
15
  this.keyPrefix = keyPrefix ?? '';
14
16
  }
15
17
  async storeQueryCacheTags(queryId, cacheTags) {
16
- if (!cacheTags?.length) {
17
- return;
18
- }
19
- const pipeline = this.redis.pipeline();
20
- for (const tag of cacheTags) {
21
- pipeline.sadd(`${this.keyPrefix}${tag}`, queryId);
22
- }
23
- await pipeline.exec();
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
- if (!cacheTags?.length) {
27
- return [];
28
- }
29
- const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
30
- return this.redis.sunion(...keys);
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
- if (!cacheTags?.length) {
34
- return 0;
35
- }
36
- const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
37
- return this.redis.del(...keys);
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
- const keys = await this.getKeys();
41
- if (keys.length === 0) {
42
- return 0;
43
- }
44
- return await this.redis.del(...keys);
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);
45
59
  }
46
60
  /**
47
61
  * Retrieves all keys matching the given pattern using the Redis SCAN command.
@@ -1 +1 @@
1
- {"version":3,"file":"redis.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/redis.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"redis.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/redis.ts"],"names":[],"mappings":"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"}
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartive/datocms-utils",
3
- "version": "3.0.0-next.11",
3
+ "version": "3.0.0-next.13",
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",
@@ -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.debug(`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 { type CacheTag, type CacheTagsProvider } from '../types.js';
2
+ import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
3
+ import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
3
4
 
4
- type NeonCacheTagsProviderConfig = {
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,61 +22,94 @@ 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
37
  this.table = NeonCacheTagsProvider.quoteIdentifier(table);
34
38
  }
35
39
 
36
40
  public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) {
37
- if (!cacheTags?.length) {
38
- return;
39
- }
40
-
41
- const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]);
42
- const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(',');
43
-
44
- await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags);
41
+ return this.wrap(
42
+ 'storeQueryCacheTags',
43
+ [queryId, cacheTags],
44
+ async () => {
45
+ if (!cacheTags?.length) {
46
+ return;
47
+ }
48
+
49
+ const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]);
50
+ const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(',');
51
+
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
- if (!cacheTags?.length) {
49
- return [];
50
- }
51
-
52
- const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
53
-
54
- const { rows } = await this.sql.query(
55
- `SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`,
56
- cacheTags,
59
+ return this.wrap(
60
+ 'queriesReferencingCacheTags',
61
+ [cacheTags],
62
+ async () => {
63
+ if (!cacheTags?.length) {
64
+ return [];
65
+ }
66
+
67
+ const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
68
+
69
+ const { rows } = await this.sql.query(
70
+ `SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`,
71
+ cacheTags,
72
+ );
73
+
74
+ return rows.reduce<string[]>((queryIds, row) => {
75
+ if (typeof row.query_id === 'string') {
76
+ queryIds.push(row.query_id);
77
+ }
78
+
79
+ return queryIds;
80
+ }, []);
81
+ },
82
+ [],
57
83
  );
58
-
59
- return rows.reduce<string[]>((queryIds, row) => {
60
- if (typeof row.query_id === 'string') {
61
- queryIds.push(row.query_id);
62
- }
63
-
64
- return queryIds;
65
- }, []);
66
84
  }
67
85
 
68
86
  public async deleteCacheTags(cacheTags: CacheTag[]) {
69
- if (!cacheTags?.length) {
70
- return 0;
71
- }
72
- const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
73
-
74
- return (await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0;
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(',');
95
+
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 (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0;
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
+ );
79
113
  }
80
114
 
81
115
  /**
@@ -85,9 +119,6 @@ export class NeonCacheTagsProvider implements CacheTagsProvider {
85
119
  * @throws Error if the identifier is invalid
86
120
  */
87
121
  private static quoteIdentifier(identifier: string): string {
88
- // Validate that the identifier contains only valid characters
89
- // PostgreSQL identifiers can contain letters, digits, underscores, and dollar signs
90
- // They can also contain dots for schema-qualified names
91
122
  if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)?$/.test(identifier)) {
92
123
  throw new Error(
93
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.`,
@@ -1,7 +1,8 @@
1
1
  import { Redis } from 'ioredis';
2
- import { type CacheTag, type CacheTagsProvider } from '../types.js';
2
+ import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
3
+ import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
3
4
 
4
- type RedisCacheTagsProviderConfig = {
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,79 @@ export class RedisCacheTagsProvider implements CacheTagsProvider {
30
34
  }
31
35
 
32
36
  public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) {
33
- if (!cacheTags?.length) {
34
- return;
35
- }
36
-
37
- const pipeline = this.redis.pipeline();
38
-
39
- for (const tag of cacheTags) {
40
- pipeline.sadd(`${this.keyPrefix}${tag}`, queryId);
41
- }
42
-
43
- await pipeline.exec();
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
- if (!cacheTags?.length) {
48
- return [];
49
- }
50
-
51
- const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
52
-
53
- return this.redis.sunion(...keys);
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
- if (!cacheTags?.length) {
58
- return 0;
59
- }
60
-
61
- const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`);
62
-
63
- return this.redis.del(...keys);
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
- const keys = await this.getKeys();
68
-
69
- if (keys.length === 0) {
70
- return 0;
71
- }
72
-
73
- return await this.redis.del(...keys);
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
+ );
74
110
  }
75
111
 
76
112
  /**
@@ -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
+ };