@smartive/datocms-utils 3.0.0-next.8 → 3.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.
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,7 +41,7 @@ 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
47
  const queryId = generateQueryId(document, variables);
@@ -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 `DatoCacheTagsProvider` 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
 
@@ -80,11 +80,15 @@ npm install @neondatabase/serverless
80
80
  3. Create and use the store:
81
81
 
82
82
  ```typescript
83
- import { NeonDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon';
83
+ import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon';
84
84
 
85
- const provider = new NeonDatoCacheTagsProvider({
86
- connectionString: process.env.DATABASE_URL!,
85
+ const provider = new NeonCacheTagsProvider({
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
@@ -115,16 +119,17 @@ npm install ioredis
115
119
  2. Create and use the provider:
116
120
 
117
121
  ```typescript
118
- import { RedisDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';
122
+ import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';
119
123
 
120
- const provider = new RedisDatoCacheTagsProvider({
121
- url: process.env.REDIS_URL!,
124
+ const provider = new RedisCacheTagsProvider({
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 store.queriesReferencingCacheTags(['item:42']);
132
+ const queries = await provider.queriesReferencingCacheTags(['item:42']);
128
133
  await provider.deleteCacheTags(['item:42']);
129
134
  await provider.truncateCacheTags();
130
135
  ```
@@ -142,7 +147,7 @@ REDIS_URL=redis://username:password@redis-host:6379
142
147
  REDIS_URL=redis://localhost:6379
143
148
  ```
144
149
 
145
- #### `DatoCacheTagsProvider` Interface
150
+ #### `CacheTagsProvider` Interface
146
151
 
147
152
  Both providers implement:
148
153
 
@@ -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';
158
- import { RedisDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';
162
+ import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags';
163
+ import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';
159
164
 
160
- const provider = new RedisDatoCacheTagsProvider({
161
- url: process.env.REDIS_URL!,
165
+ const provider = new RedisCacheTagsProvider({
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(query, variables);
171
+ const queryId = generateQueryId(document, variables);
167
172
  const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']);
168
173
  await provider.storeQueryCacheTags(queryId, cacheTags);
169
174
 
@@ -179,7 +184,7 @@ The package includes TypeScript types for DatoCMS webhooks and cache tags:
179
184
 
180
185
  - `CacheTag`: A branded type for cache tags, ensuring type safety
181
186
  - `CacheTagsInvalidateWebhook`: Type definition for DatoCMS cache tag invalidation webhook payloads
182
- - `DatoCacheTagsProvider`: Interface for cache tag storage implementations
187
+ - `CacheTagsProvider`: Interface for cache tag storage implementations
183
188
 
184
189
  ## License
185
190
 
@@ -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 { type CacheTag, type DatoCacheTagsProvider } from '../types.js';
2
- type NeonDatoCacheTagsProviderConfig = {
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 NeonDatoCacheTagsProviderConfig = {
18
19
  */
19
20
  readonly table: string;
20
21
  };
22
+ export type NeonCacheTagsProviderConfig = NeonCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
21
23
  /**
22
- * A `DatoCacheTagsProvider` implementation that uses Neon as the storage backend.
24
+ * A `CacheTagsProvider` implementation that uses Neon as the storage backend.
23
25
  */
24
- export declare class NeonDatoCacheTagsProvider implements DatoCacheTagsProvider {
26
+ export declare class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
25
27
  private readonly sql;
26
28
  private readonly table;
27
- constructor({ connectionUrl, table }: NeonDatoCacheTagsProviderConfig);
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
- * A `DatoCacheTagsProvider` implementation that uses Neon as the storage backend.
4
+ * A `CacheTagsProvider` implementation that uses Neon as the storage backend.
4
5
  */
5
- export class NeonDatoCacheTagsProvider {
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
- 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 === 0) {
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);
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;AAuBhD;;GAEG;AACH,MAAM,OAAO,yBAAyB;IACnB,GAAG,CAAC;IACJ,KAAK,CAAC;IAEvB,YAAY,EAAE,aAAa,EAAE,KAAK,EAAmC;QACnE,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,aAAa,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;QACtD,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;IAEM,KAAK,CAAC,mBAAmB,CAAC,OAAe,EAAE,SAAqB;QACrE,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;YACvB,OAAO;QACT,CAAC;QAED,MAAM,IAAI,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAClE,MAAM,YAAY,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAEzF,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,CAAC,KAAK,WAAW,YAAY,yBAAyB,EAAE,IAAI,CAAC,CAAC;IACxG,CAAC;IAEM,KAAK,CAAC,2BAA2B,CAAC,SAAqB;QAC5D,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;YACvB,OAAO,EAAE,CAAC;QACZ,CAAC;QAED,MAAM,YAAY,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAEpE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CACnC,iCAAiC,IAAI,CAAC,KAAK,wBAAwB,YAAY,GAAG,EAClF,SAAS,CACV,CAAC;QAEF,OAAO,IAAI,CAAC,MAAM,CAAW,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE;YAC7C,IAAI,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;gBACrC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YAC9B,CAAC;YAED,OAAO,QAAQ,CAAC;QAClB,CAAC,EAAE,EAAE,CAAC,CAAC;IACT,CAAC;IAEM,KAAK,CAAC,eAAe,CAAC,SAAqB;QAChD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,CAAC,CAAC;QACX,CAAC;QACD,MAAM,YAAY,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAEpE,OAAO,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,CAAC,KAAK,wBAAwB,YAAY,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC;IAC3H,CAAC;IAEM,KAAK,CAAC,iBAAiB;QAC5B,OAAO,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC;IAC3E,CAAC;CACF"}
1
+ {"version":3,"file":"neon.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/neon.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,0BAA0B,CAAC;AAEhD,OAAO,EAAE,sCAAsC,EAAE,MAAM,WAAW,CAAC;AAwBnE;;GAEG;AACH,MAAM,OAAO,qBAAsB,SAAQ,sCAAsC;IAC9D,GAAG,CAAC;IACJ,KAAK,CAAC;IAEvB,YAAY,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY,EAAE,OAAO,EAA+B;QACtF,KAAK,CAAC,uBAAuB,EAAE,EAAE,YAAY,EAAE,OAAO,EAAE,CAAC,CAAC;QAC1D,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,aAAa,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;QACtD,IAAI,CAAC,KAAK,GAAG,qBAAqB,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IAC5D,CAAC;IAEM,KAAK,CAAC,mBAAmB,CAAC,OAAe,EAAE,SAAqB;QACrE,OAAO,IAAI,CAAC,IAAI,CACd,qBAAqB,EACrB,CAAC,OAAO,EAAE,SAAS,CAAC,EACpB,KAAK,IAAI,EAAE;YACT,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;gBACvB,OAAO;YACT,CAAC;YAED,MAAM,IAAI,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAClE,MAAM,YAAY,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAEzF,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,CAAC,KAAK,WAAW,YAAY,yBAAyB,EAAE,IAAI,CAAC,CAAC;QACxG,CAAC,EACD,SAAS,CACV,CAAC;IACJ,CAAC;IAEM,KAAK,CAAC,2BAA2B,CAAC,SAAqB;QAC5D,OAAO,IAAI,CAAC,IAAI,CACd,6BAA6B,EAC7B,CAAC,SAAS,CAAC,EACX,KAAK,IAAI,EAAE;YACT,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;gBACvB,OAAO,EAAE,CAAC;YACZ,CAAC;YAED,MAAM,YAAY,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAEpE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CACnC,iCAAiC,IAAI,CAAC,KAAK,wBAAwB,YAAY,GAAG,EAClF,SAAS,CACV,CAAC;YAEF,OAAO,IAAI,CAAC,MAAM,CAAW,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE;gBAC7C,IAAI,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;oBACrC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;gBAC9B,CAAC;gBAED,OAAO,QAAQ,CAAC;YAClB,CAAC,EAAE,EAAE,CAAC,CAAC;QACT,CAAC,EACD,EAAE,CACH,CAAC;IACJ,CAAC;IAEM,KAAK,CAAC,eAAe,CAAC,SAAqB;QAChD,OAAO,IAAI,CAAC,IAAI,CACd,iBAAiB,EACjB,CAAC,SAAS,CAAC,EACX,KAAK,IAAI,EAAE;YACT,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;gBACvB,OAAO,CAAC,CAAC;YACX,CAAC;YACD,MAAM,YAAY,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAEpE,OAAO,CACL,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,CAAC,KAAK,wBAAwB,YAAY,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAClH,CAAC;QACJ,CAAC,EACD,CAAC,CACF,CAAC;IACJ,CAAC;IAEM,KAAK,CAAC,iBAAiB;QAC5B,OAAO,IAAI,CAAC,IAAI,CACd,mBAAmB,EACnB,EAAE,EACF,KAAK,IAAI,EAAE;YACT,OAAO,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC;QAC3E,CAAC,EACD,CAAC,CACF,CAAC;IACJ,CAAC;IAED;;;;;OAKG;IACK,MAAM,CAAC,eAAe,CAAC,UAAkB;QAC/C,IAAI,CAAC,yDAAyD,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;YAChF,MAAM,IAAI,KAAK,CACb,uBAAuB,UAAU,kMAAkM,CACpO,CAAC;QACJ,CAAC;QAED,oEAAoE;QACpE,uDAAuD;QACvD,kEAAkE;QAClE,OAAO,UAAU;aACd,KAAK,CAAC,GAAG,CAAC;aACV,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC;aAC9C,IAAI,CAAC,GAAG,CAAC,CAAC;IACf,CAAC;CACF"}
@@ -1,10 +1,10 @@
1
- import { type CacheTag, type DatoCacheTagsProvider } from '../types.js';
1
+ import { type CacheTag, type CacheTagsProvider } from '../types.js';
2
2
  /**
3
- * A `DatoCacheTagsProvider` implementation that does not perform any actual storage operations.
3
+ * A `CacheTagsProvider` implementation that does not perform any actual storage operations.
4
4
  *
5
5
  * _Note: This implementation is useful for testing purposes or when you want to disable caching without changing the code that interacts with the cache._
6
6
  */
7
- export declare class NoopDatoCacheTagsProvider implements DatoCacheTagsProvider {
7
+ export declare class NoopCacheTagsProvider implements CacheTagsProvider {
8
8
  storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise<void>;
9
9
  queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise<string[]>;
10
10
  deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
@@ -1,9 +1,9 @@
1
1
  /**
2
- * A `DatoCacheTagsProvider` implementation that does not perform any actual storage operations.
2
+ * A `CacheTagsProvider` implementation that does not perform any actual storage operations.
3
3
  *
4
4
  * _Note: This implementation is useful for testing purposes or when you want to disable caching without changing the code that interacts with the cache._
5
5
  */
6
- export class NoopDatoCacheTagsProvider {
6
+ export class NoopCacheTagsProvider {
7
7
  async storeQueryCacheTags(queryId, cacheTags) {
8
8
  console.debug('-- storeQueryCacheTags called', { queryId, cacheTags });
9
9
  return Promise.resolve();
@@ -1 +1 @@
1
- {"version":3,"file":"noop.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/noop.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,OAAO,yBAAyB;IAC7B,KAAK,CAAC,mBAAmB,CAAC,OAAe,EAAE,SAAqB;QACrE,OAAO,CAAC,KAAK,CAAC,+BAA+B,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEvE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAEM,KAAK,CAAC,2BAA2B,CAAC,SAAqB;QAC5D,OAAO,CAAC,KAAK,CAAC,uCAAuC,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;QAEtE,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC7B,CAAC;IAEM,KAAK,CAAC,eAAe,CAAC,SAAqB;QAChD,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;QAE1D,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5B,CAAC;IAEM,KAAK,CAAC,iBAAiB;QAC5B,OAAO,CAAC,KAAK,CAAC,6BAA6B,CAAC,CAAC;QAE7C,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5B,CAAC;CACF"}
1
+ {"version":3,"file":"noop.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/noop.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,OAAO,qBAAqB;IACzB,KAAK,CAAC,mBAAmB,CAAC,OAAe,EAAE,SAAqB;QACrE,OAAO,CAAC,KAAK,CAAC,+BAA+B,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEvE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAEM,KAAK,CAAC,2BAA2B,CAAC,SAAqB;QAC5D,OAAO,CAAC,KAAK,CAAC,uCAAuC,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;QAEtE,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC7B,CAAC;IAEM,KAAK,CAAC,eAAe,CAAC,SAAqB;QAChD,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;QAE1D,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5B,CAAC;IAEM,KAAK,CAAC,iBAAiB;QAC5B,OAAO,CAAC,KAAK,CAAC,6BAA6B,CAAC,CAAC;QAE7C,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5B,CAAC;CACF"}
@@ -1,5 +1,6 @@
1
- import { type CacheTag, type DatoCacheTagsProvider } from '../types.js';
2
- type RedisDatoCacheTagsProviderConfig = {
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 RedisDatoCacheTagsProviderConfig = {
11
12
  */
12
13
  readonly keyPrefix?: string;
13
14
  };
15
+ export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
14
16
  /**
15
- * A `DatoCacheTagsProvider` implementation that uses Redis as the storage backend.
17
+ * A `CacheTagsProvider` implementation that uses Redis as the storage backend.
16
18
  */
17
- export declare class RedisDatoCacheTagsProvider implements DatoCacheTagsProvider {
19
+ export declare class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
18
20
  private readonly redis;
19
21
  private readonly keyPrefix;
20
- constructor({ connectionUrl, keyPrefix }: RedisDatoCacheTagsProviderConfig);
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
- * A `DatoCacheTagsProvider` implementation that uses Redis as the storage backend.
4
+ * A `CacheTagsProvider` implementation that uses Redis as the storage backend.
4
5
  */
5
- export class RedisDatoCacheTagsProvider {
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 RedisDatoCacheTagsProvider {
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 pattern = `${this.keyPrefix}*`;
41
- const keys = await this.redis.keys(pattern);
42
- if (keys.length === 0) {
43
- return 0;
44
- }
45
- 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);
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;AAgBhC;;GAEG;AACH,MAAM,OAAO,0BAA0B;IACpB,KAAK,CAAC;IACN,SAAS,CAAC;IAE3B,YAAY,EAAE,aAAa,EAAE,SAAS,EAAoC;QACxE,IAAI,CAAC,KAAK,GAAG,IAAI,KAAK,CAAC,aAAa,EAAE;YACpC,oBAAoB,EAAE,CAAC;YACvB,WAAW,EAAE,IAAI;SAClB,CAAC,CAAC;QACH,IAAI,CAAC,SAAS,GAAG,SAAS,IAAI,EAAE,CAAC;IACnC,CAAC;IAEM,KAAK,CAAC,mBAAmB,CAAC,OAAe,EAAE,SAAqB;QACrE,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;YACvB,OAAO;QACT,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;QAEvC,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;YAC5B,QAAQ,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,SAAS,GAAG,GAAG,EAAE,EAAE,OAAO,CAAC,CAAC;QACpD,CAAC;QAED,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IACxB,CAAC;IAEM,KAAK,CAAC,2BAA2B,CAAC,SAAqB;QAC5D,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;YACvB,OAAO,EAAE,CAAC;QACZ,CAAC;QAED,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,CAAC;QAE/D,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC;IACpC,CAAC;IAEM,KAAK,CAAC,eAAe,CAAC,SAAqB;QAChD,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;YACvB,OAAO,CAAC,CAAC;QACX,CAAC;QAED,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,CAAC;QAE/D,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;IACjC,CAAC;IAEM,KAAK,CAAC,iBAAiB;QAC5B,MAAM,OAAO,GAAG,GAAG,IAAI,CAAC,SAAS,GAAG,CAAC;QACrC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAE5C,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACtB,OAAO,CAAC,CAAC;QACX,CAAC;QAED,OAAO,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;IACvC,CAAC;CACF"}
1
+ {"version":3,"file":"redis.js","sourceRoot":"","sources":["../../../src/cache-tags/provider/redis.ts"],"names":[],"mappings":"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"}
@@ -24,9 +24,9 @@ export type CacheTagsInvalidateWebhook = {
24
24
  };
25
25
  };
26
26
  /**
27
- * Configuration object for creating a `DatoCacheTagsProvider` implementation.
27
+ * Configuration object for creating a `CacheTagsProvider` implementation.
28
28
  */
29
- export interface DatoCacheTagsProvider {
29
+ export interface CacheTagsProvider {
30
30
  /**
31
31
  * Stores the cache tags of a query.
32
32
  *
@@ -50,7 +50,7 @@ export interface DatoCacheTagsProvider {
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, or null if there was an error
53
+ * @returns Number of keys deleted
54
54
  *
55
55
  */
56
56
  deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
@@ -61,3 +61,22 @@ export interface DatoCacheTagsProvider {
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.8",
3
+ "version": "3.0.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.12",
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.2",
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 { type CacheTag, type DatoCacheTagsProvider } from '../types.js';
2
+ import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
3
+ import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
3
4
 
4
- type NeonDatoCacheTagsProviderConfig = {
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 NeonDatoCacheTagsProviderConfig = {
21
22
  readonly table: string;
22
23
  };
23
24
 
25
+ export type NeonCacheTagsProviderConfig = NeonCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
26
+
24
27
  /**
25
- * A `DatoCacheTagsProvider` implementation that uses Neon as the storage backend.
28
+ * A `CacheTagsProvider` implementation that uses Neon as the storage backend.
26
29
  */
27
- export class NeonDatoCacheTagsProvider implements DatoCacheTagsProvider {
30
+ export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
28
31
  private readonly sql;
29
32
  private readonly table;
30
33
 
31
- constructor({ connectionUrl, table }: NeonDatoCacheTagsProviderConfig) {
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
- if (!cacheTags?.length) {
38
- return;
39
- }
41
+ return this.wrap(
42
+ 'storeQueryCacheTags',
43
+ [queryId, cacheTags],
44
+ async () => {
45
+ if (!cacheTags?.length) {
46
+ return;
47
+ }
40
48
 
41
- const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]);
42
- const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(',');
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
- await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags);
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
- }
59
+ return this.wrap(
60
+ 'queriesReferencingCacheTags',
61
+ [cacheTags],
62
+ async () => {
63
+ if (!cacheTags?.length) {
64
+ return [];
65
+ }
51
66
 
52
- const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
67
+ const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
53
68
 
54
- const { rows } = await this.sql.query(
55
- `SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`,
56
- cacheTags,
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
- return rows.reduce<string[]>((queryIds, row) => {
60
- if (typeof row.query_id === 'string') {
61
- queryIds.push(row.query_id);
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
- return queryIds;
65
- }, []);
79
+ return queryIds;
80
+ }, []);
81
+ },
82
+ [],
83
+ );
66
84
  }
67
85
 
68
86
  public async deleteCacheTags(cacheTags: CacheTag[]) {
69
- if (cacheTags.length === 0) {
70
- return 0;
71
- }
72
- const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(',');
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
- return (await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0;
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
+ );
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,11 +1,11 @@
1
- import { type CacheTag, type DatoCacheTagsProvider } from '../types.js';
1
+ import { type CacheTag, type CacheTagsProvider } from '../types.js';
2
2
 
3
3
  /**
4
- * A `DatoCacheTagsProvider` implementation that does not perform any actual storage operations.
4
+ * A `CacheTagsProvider` implementation that does not perform any actual storage operations.
5
5
  *
6
6
  * _Note: This implementation is useful for testing purposes or when you want to disable caching without changing the code that interacts with the cache._
7
7
  */
8
- export class NoopDatoCacheTagsProvider implements DatoCacheTagsProvider {
8
+ export class NoopCacheTagsProvider implements CacheTagsProvider {
9
9
  public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) {
10
10
  console.debug('-- storeQueryCacheTags called', { queryId, cacheTags });
11
11
 
@@ -1,7 +1,8 @@
1
1
  import { Redis } from 'ioredis';
2
- import { type CacheTag, type DatoCacheTagsProvider } from '../types.js';
2
+ import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js';
3
+ import { AbstractErrorHandlingCacheTagsProvider } from './base.js';
3
4
 
4
- type RedisDatoCacheTagsProviderConfig = {
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 RedisDatoCacheTagsProviderConfig = {
14
15
  readonly keyPrefix?: string;
15
16
  };
16
17
 
18
+ export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig;
19
+
17
20
  /**
18
- * A `DatoCacheTagsProvider` implementation that uses Redis as the storage backend.
21
+ * A `CacheTagsProvider` implementation that uses Redis as the storage backend.
19
22
  */
20
- export class RedisDatoCacheTagsProvider implements DatoCacheTagsProvider {
23
+ export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider {
21
24
  private readonly redis;
22
25
  private readonly keyPrefix;
23
26
 
24
- constructor({ connectionUrl, keyPrefix }: RedisDatoCacheTagsProviderConfig) {
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 RedisDatoCacheTagsProvider implements DatoCacheTagsProvider {
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 pattern = `${this.keyPrefix}*`;
68
- const keys = await this.redis.keys(pattern);
69
-
70
- if (keys.length === 0) {
71
- return 0;
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
- return await this.redis.del(...keys);
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
  }
@@ -24,9 +24,9 @@ export type CacheTagsInvalidateWebhook = {
24
24
  };
25
25
 
26
26
  /**
27
- * Configuration object for creating a `DatoCacheTagsProvider` implementation.
27
+ * Configuration object for creating a `CacheTagsProvider` implementation.
28
28
  */
29
- export interface DatoCacheTagsProvider {
29
+ export interface CacheTagsProvider {
30
30
  /**
31
31
  * Stores the cache tags of a query.
32
32
  *
@@ -52,7 +52,7 @@ export interface DatoCacheTagsProvider {
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, or null if there was an error
55
+ * @returns Number of keys deleted
56
56
  *
57
57
  */
58
58
  deleteCacheTags(cacheTags: CacheTag[]): Promise<number>;
@@ -64,3 +64,20 @@ export interface DatoCacheTagsProvider {
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
+ };