From f0478d42cd561087c3b87d0970400ed4ac4d0313 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 11 Feb 2026 17:49:07 +0100 Subject: [PATCH 1/6] feat: add redis cache tags utils --- README.md | 64 +++++++++++++++++++++ package-lock.json | 112 ++++++++++++++++++++++++++++++++---- package.json | 6 +- src/cache-tags-redis.ts | 122 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 5 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 src/cache-tags-redis.ts diff --git a/README.md b/README.md index 69aedf7..9653a00 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,70 @@ CREATE TABLE IF NOT EXISTS query_cache_tags ( ); ``` +### Utilities for DatoCMS Cache Tags (Redis) + +The following utilities provide Redis-based alternatives to the Postgres cache tags implementation above. They work with [DatoCMS cache tags](https://www.datocms.com/docs/content-delivery-api/cache-tags) and any Redis instance. + +- `storeQueryCacheTagsRedis`: Stores the cache tags of a query in Redis. +- `queriesReferencingCacheTagsRedis`: Retrieves the queries that reference cache tags. +- `deleteQueriesRedis`: Deletes the cache tags of a query from Redis. +- `truncateCacheTagsRedis`: Wipes out all cache tags from Redis. + +The Redis connection is automatically initialized on first use using the `REDIS_URL` environment variable. + +#### Environment Variables + +Add your Redis connection URL to your `.env.local` file: + +```bash +# Required: Redis connection URL +# For Upstash Redis +REDIS_URL=rediss://default:your-token@your-endpoint.upstash.io:6379 + +# For Redis Cloud or other providers +REDIS_URL=redis://username:password@your-redis-host:6379 + +# For local development +REDIS_URL=redis://localhost:6379 + +# Optional: Key prefix for separating production/preview environments +# Useful when using the same Redis instance for multiple environments +REDIS_KEY_PREFIX=prod # For production +REDIS_KEY_PREFIX=preview # For preview/staging +# Leave empty for development (no prefix) +``` + +**Note**: Similar to how the Postgres version uses different table names, use `REDIS_KEY_PREFIX` to separate data between environments when using the same Redis instance. + +#### Usage Example + +```typescript +import { generateQueryId, storeQueryCacheTagsRedis, queriesReferencingCacheTagsRedis, deleteQueriesRedis } from '@smartive/datocms-utils'; + +// Generate a unique query ID +const queryId = generateQueryId(query, variables); + +// Store cache tags for a query +await storeQueryCacheTagsRedis(queryId, ['item:42', 'product', 'category:5']); + +// Find all queries that reference specific tags +const affectedQueries = await queriesReferencingCacheTagsRedis(['item:42']); + +// Delete queries and their cache tag mappings +await deleteQueriesRedis(affectedQueries); +``` + +#### Redis Data Structure + +The Redis implementation uses a dual-index pattern with Sets for efficient lookups: + +- **Forward index**: `{prefix}cache-tag:{tag}` → Set of query IDs (fast "which queries use this tag" lookups) +- **Reverse index**: `{prefix}query:{queryId}` → Set of tags (fast cleanup when deleting queries) + +Where `{prefix}` is the optional `REDIS_KEY_PREFIX` environment variable (e.g., `prod:`, `preview:`). + +This provides O(1) lookup performance in both directions. + ### Other Utilities - `classNames`: Cleans and joins an array of inputs with possible undefined or boolean values. Useful for tailwind classnames. diff --git a/package-lock.json b/package-lock.json index c7c0741..fc70a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,12 @@ "@types/node": "24.10.12", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", + "ioredis": "^5.4.1", "prettier": "3.8.1", "typescript": "5.9.3" + }, + "peerDependencies": { + "ioredis": "^5.4.1" } }, "../../.nvm/versions/node/v22.2.0/lib/node_modules/list": { @@ -80,7 +84,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -572,6 +575,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "dev": true, + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -842,7 +852,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1357,7 +1366,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1656,7 +1664,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -1677,7 +1684,6 @@ "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -1782,6 +1788,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1942,6 +1958,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2179,7 +2205,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2240,7 +2265,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3136,6 +3160,31 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -3686,6 +3735,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4174,7 +4237,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4245,6 +4307,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4589,6 +4674,13 @@ "node": ">=12.0.0" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -4803,7 +4895,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4953,7 +5044,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5018,7 +5108,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5249,7 +5338,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index d7b28ae..f49b915 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,14 @@ "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", "prettier": "3.8.1", - "typescript": "5.9.3" + "typescript": "5.9.3", + "ioredis": "^5.4.1" }, "dependencies": { "@vercel/postgres": "^0.10.0", "graphql": "^16.9.0" + }, + "peerDependencies": { + "ioredis": "^5.4.1" } } diff --git a/src/cache-tags-redis.ts b/src/cache-tags-redis.ts new file mode 100644 index 0000000..04f79e2 --- /dev/null +++ b/src/cache-tags-redis.ts @@ -0,0 +1,122 @@ +import { Redis } from 'ioredis'; +import { CacheTag } from './types'; + +const redis = new Redis(process.env.REDIS_URL!, { + maxRetriesPerRequest: 3, + lazyConnect: true, +}); + +const keyPrefix = process.env.REDIS_KEY_PREFIX ? `${process.env.REDIS_KEY_PREFIX}:` : ''; + +/** + * Stores the cache tags of a query in Redis using a dual-index pattern. + * + * This function creates two types of Redis Sets for efficient lookups: + * - Forward index: `{prefix}cache-tag:{tag}` → Set of query IDs (enables fast "which queries use this tag" lookups) + * - Reverse index: `{prefix}query:{queryId}` → Set of tags (enables fast cleanup when deleting queries) + * + * @param {string} queryId Unique query ID + * @param {CacheTag[]} cacheTags Array of cache tags + * + */ +export const storeQueryCacheTagsRedis = async (queryId: string, cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return; + } + + const pipeline = redis.pipeline(); + + for (const tag of cacheTags) { + pipeline.sadd(`${keyPrefix}cache-tag:${tag}`, queryId); + } + + pipeline.sadd(`${keyPrefix}query:${queryId}`, ...cacheTags); + + await pipeline.exec(); +}; + +/** + * Retrieves the query IDs that reference any of the specified cache tags. + * + * Uses Redis SUNION to efficiently find all queries associated with the given tags. + * + * @param {CacheTag[]} cacheTags Array of cache tags to check + * @returns Array of unique query IDs + * + */ +export const queriesReferencingCacheTagsRedis = async (cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return []; + } + + const keys = cacheTags.map((tag) => `${keyPrefix}cache-tag:${tag}`); + + return redis.sunion(...keys); +}; + +/** + * Deletes the specified queries and their associated cache tag mappings from Redis. + * + * This function: + * 1. Retrieves all tags associated with each query from the reverse index + * 2. Removes the query ID from all cache tag sets (forward index) + * 3. Deletes the reverse index entries for the queries + * + * @param {string[]} queryIds Array of query IDs to delete + * + */ +export const deleteQueriesRedis = async (queryIds: string[]): Promise => { + if (!queryIds?.length) { + return; + } + + const pipeline = redis.pipeline(); + + for (const queryId of queryIds) { + pipeline.smembers(`${keyPrefix}query:${queryId}`); + } + + const results = await pipeline.exec(); + + if (!results) { + return; + } + + // Build a new pipeline to delete all references + const deletePipeline = redis.pipeline(); + + queryIds.forEach((queryId, index) => { + const result = results[index]; + if (result?.[1]) { + const tags = result[1] as string[]; + + for (const tag of tags) { + deletePipeline.srem(`${keyPrefix}cache-tag:${tag}`, queryId); + } + } + + deletePipeline.del(`${keyPrefix}query:${queryId}`); + }); + + await deletePipeline.exec(); +}; + +/** + * Wipes out all cache tags from Redis. + * + * This function deletes all keys matching the patterns: + * - `{prefix}cache-tag:*` (forward index) + * - `{prefix}query:*` (reverse index) + * + * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! + */ +export const truncateCacheTagsRedis = async (): Promise => { + const cacheTagKeys = await redis.keys(`${keyPrefix}cache-tag:*`); + const queryKeys = await redis.keys(`${keyPrefix}query:*`); + + const allKeys = [...cacheTagKeys, ...queryKeys]; + + if (allKeys.length > 0) { + await redis.del(...allKeys); + } +}; diff --git a/src/index.ts b/src/index.ts index 3861992..05585ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './cache-tags'; +export * from './cache-tags-redis'; export * from './classnames'; export * from './links'; export * from './types'; From fefa64f90fb708bfe0207f4b405e8fcf080fc2dd Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 12 Feb 2026 13:16:20 +0100 Subject: [PATCH 2/6] feat: update Redis cache tag utilities and improve deletion logic --- README.md | 20 +++++---- src/cache-tags-redis.ts | 92 +++++++++++++++-------------------------- 2 files changed, 45 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 9653a00..1ecb5d4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The following utilities provide Redis-based alternatives to the Postgres cache t - `storeQueryCacheTagsRedis`: Stores the cache tags of a query in Redis. - `queriesReferencingCacheTagsRedis`: Retrieves the queries that reference cache tags. -- `deleteQueriesRedis`: Deletes the cache tags of a query from Redis. +- `deleteCacheTagsRedis`: Deletes cache tags from Redis. - `truncateCacheTagsRedis`: Wipes out all cache tags from Redis. The Redis connection is automatically initialized on first use using the `REDIS_URL` environment variable. @@ -72,7 +72,12 @@ REDIS_KEY_PREFIX=preview # For preview/staging #### Usage Example ```typescript -import { generateQueryId, storeQueryCacheTagsRedis, queriesReferencingCacheTagsRedis, deleteQueriesRedis } from '@smartive/datocms-utils'; +import { + generateQueryId, + storeQueryCacheTagsRedis, + queriesReferencingCacheTagsRedis, + deleteCacheTagsRedis, +} from '@smartive/datocms-utils'; // Generate a unique query ID const queryId = generateQueryId(query, variables); @@ -83,20 +88,19 @@ await storeQueryCacheTagsRedis(queryId, ['item:42', 'product', 'category:5']); // Find all queries that reference specific tags const affectedQueries = await queriesReferencingCacheTagsRedis(['item:42']); -// Delete queries and their cache tag mappings -await deleteQueriesRedis(affectedQueries); +// Delete cache tags (keys will be recreated on next query) +await deleteCacheTagsRedis(['item:42']); ``` #### Redis Data Structure -The Redis implementation uses a dual-index pattern with Sets for efficient lookups: +The Redis implementation uses Sets to track query-to-tag relationships: -- **Forward index**: `{prefix}cache-tag:{tag}` → Set of query IDs (fast "which queries use this tag" lookups) -- **Reverse index**: `{prefix}query:{queryId}` → Set of tags (fast cleanup when deleting queries) +- **Cache tag keys**: `{prefix}{tag}` → Set of query IDs Where `{prefix}` is the optional `REDIS_KEY_PREFIX` environment variable (e.g., `prod:`, `preview:`). -This provides O(1) lookup performance in both directions. +When cache tags are invalidated, their keys are deleted entirely. Fresh mappings are created when queries run again. ### Other Utilities diff --git a/src/cache-tags-redis.ts b/src/cache-tags-redis.ts index 04f79e2..32f4cf3 100644 --- a/src/cache-tags-redis.ts +++ b/src/cache-tags-redis.ts @@ -1,19 +1,24 @@ import { Redis } from 'ioredis'; import { CacheTag } from './types'; -const redis = new Redis(process.env.REDIS_URL!, { - maxRetriesPerRequest: 3, - lazyConnect: true, -}); +let redis: Redis | null = null; + +const getRedis = (): Redis => { + redis ??= new Redis(process.env.REDIS_URL!, { + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + + return redis; +}; const keyPrefix = process.env.REDIS_KEY_PREFIX ? `${process.env.REDIS_KEY_PREFIX}:` : ''; /** - * Stores the cache tags of a query in Redis using a dual-index pattern. + * Stores the cache tags of a query in Redis. * - * This function creates two types of Redis Sets for efficient lookups: - * - Forward index: `{prefix}cache-tag:{tag}` → Set of query IDs (enables fast "which queries use this tag" lookups) - * - Reverse index: `{prefix}query:{queryId}` → Set of tags (enables fast cleanup when deleting queries) + * For each cache tag, adds the query ID to a Redis Set. Sets are unordered + * collections of unique strings, perfect for tracking which queries use which tags. * * @param {string} queryId Unique query ID * @param {CacheTag[]} cacheTags Array of cache tags @@ -24,14 +29,13 @@ export const storeQueryCacheTagsRedis = async (queryId: string, cacheTags: Cache return; } + const redis = getRedis(); const pipeline = redis.pipeline(); for (const tag of cacheTags) { - pipeline.sadd(`${keyPrefix}cache-tag:${tag}`, queryId); + pipeline.sadd(`${keyPrefix}${tag}`, queryId); } - pipeline.sadd(`${keyPrefix}query:${queryId}`, ...cacheTags); - await pipeline.exec(); }; @@ -49,74 +53,44 @@ export const queriesReferencingCacheTagsRedis = async (cacheTags: CacheTag[]): P return []; } - const keys = cacheTags.map((tag) => `${keyPrefix}cache-tag:${tag}`); + const redis = getRedis(); + const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); return redis.sunion(...keys); }; /** - * Deletes the specified queries and their associated cache tag mappings from Redis. + * Deletes the specified cache tags from Redis. * - * This function: - * 1. Retrieves all tags associated with each query from the reverse index - * 2. Removes the query ID from all cache tag sets (forward index) - * 3. Deletes the reverse index entries for the queries + * This removes the cache tag keys entirely. When queries are revalidated and + * run again, fresh cache tag mappings will be created. * - * @param {string[]} queryIds Array of query IDs to delete + * @param {CacheTag[]} cacheTags Array of cache tags to delete + * @returns Number of keys deleted, or null if there was an error * */ -export const deleteQueriesRedis = async (queryIds: string[]): Promise => { - if (!queryIds?.length) { - return; - } - - const pipeline = redis.pipeline(); - - for (const queryId of queryIds) { - pipeline.smembers(`${keyPrefix}query:${queryId}`); - } - - const results = await pipeline.exec(); - - if (!results) { - return; +export const deleteCacheTagsRedis = async (cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return 0; } - // Build a new pipeline to delete all references - const deletePipeline = redis.pipeline(); - - queryIds.forEach((queryId, index) => { - const result = results[index]; - if (result?.[1]) { - const tags = result[1] as string[]; + const redis = getRedis(); + const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); - for (const tag of tags) { - deletePipeline.srem(`${keyPrefix}cache-tag:${tag}`, queryId); - } - } - - deletePipeline.del(`${keyPrefix}query:${queryId}`); - }); - - await deletePipeline.exec(); + return redis.del(...keys); }; /** * Wipes out all cache tags from Redis. * - * This function deletes all keys matching the patterns: - * - `{prefix}cache-tag:*` (forward index) - * - `{prefix}query:*` (reverse index) - * * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! */ export const truncateCacheTagsRedis = async (): Promise => { - const cacheTagKeys = await redis.keys(`${keyPrefix}cache-tag:*`); - const queryKeys = await redis.keys(`${keyPrefix}query:*`); - - const allKeys = [...cacheTagKeys, ...queryKeys]; + const redis = getRedis(); + const pattern = keyPrefix ? `${keyPrefix}*` : '*'; + const keys = await redis.keys(pattern); - if (allKeys.length > 0) { - await redis.del(...allKeys); + if (keys.length > 0) { + await redis.del(...keys); } }; From c4c99a335ac7ed40bbca8f5113e5d5c14e96a1b6 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 12 Feb 2026 13:45:25 +0100 Subject: [PATCH 3/6] fb --- package.json | 5 +++++ src/cache-tags-redis.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f49b915..65434c5 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,10 @@ }, "peerDependencies": { "ioredis": "^5.4.1" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } } } diff --git a/src/cache-tags-redis.ts b/src/cache-tags-redis.ts index 32f4cf3..475772d 100644 --- a/src/cache-tags-redis.ts +++ b/src/cache-tags-redis.ts @@ -87,7 +87,7 @@ export const deleteCacheTagsRedis = async (cacheTags: CacheTag[]): Promise => { const redis = getRedis(); - const pattern = keyPrefix ? `${keyPrefix}*` : '*'; + const pattern = `${keyPrefix}*`; const keys = await redis.keys(pattern); if (keys.length > 0) { From 7195de18adf8cd6108d978e0f66a03ce8e8d3381 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 12 Feb 2026 14:02:01 +0100 Subject: [PATCH 4/6] feat: refactor Redis cache tag utilities for consistency and clarity --- README.md | 25 ++++++++++--------------- src/cache-tags-redis.ts | 10 +++++----- src/index.ts | 3 ++- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1ecb5d4..373a7f5 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ CREATE TABLE IF NOT EXISTS query_cache_tags ( The following utilities provide Redis-based alternatives to the Postgres cache tags implementation above. They work with [DatoCMS cache tags](https://www.datocms.com/docs/content-delivery-api/cache-tags) and any Redis instance. -- `storeQueryCacheTagsRedis`: Stores the cache tags of a query in Redis. -- `queriesReferencingCacheTagsRedis`: Retrieves the queries that reference cache tags. -- `deleteCacheTagsRedis`: Deletes cache tags from Redis. -- `truncateCacheTagsRedis`: Wipes out all cache tags from Redis. +- `redis.storeQueryCacheTags`: Stores the cache tags of a query in Redis. +- `redis.queriesReferencingCacheTags`: Retrieves the queries that reference cache tags. +- `redis.deleteCacheTags`: Deletes cache tags from Redis. +- `redis.truncateCacheTags`: Wipes out all cache tags from Redis. The Redis connection is automatically initialized on first use using the `REDIS_URL` environment variable. @@ -72,24 +72,19 @@ REDIS_KEY_PREFIX=preview # For preview/staging #### Usage Example ```typescript -import { - generateQueryId, - storeQueryCacheTagsRedis, - queriesReferencingCacheTagsRedis, - deleteCacheTagsRedis, -} from '@smartive/datocms-utils'; - -// Generate a unique query ID +// Recommended: Use namespaces for clarity +import { generateQueryId, redis } from '@smartive/datocms-utils'; + const queryId = generateQueryId(query, variables); // Store cache tags for a query -await storeQueryCacheTagsRedis(queryId, ['item:42', 'product', 'category:5']); +await redis.storeQueryCacheTags(queryId, ['item:42', 'product', 'category:5']); // Find all queries that reference specific tags -const affectedQueries = await queriesReferencingCacheTagsRedis(['item:42']); +const affectedQueries = await redis.queriesReferencingCacheTags(['item:42']); // Delete cache tags (keys will be recreated on next query) -await deleteCacheTagsRedis(['item:42']); +await redis.deleteCacheTags(['item:42']); ``` #### Redis Data Structure diff --git a/src/cache-tags-redis.ts b/src/cache-tags-redis.ts index 475772d..6a1a1e5 100644 --- a/src/cache-tags-redis.ts +++ b/src/cache-tags-redis.ts @@ -1,5 +1,5 @@ import { Redis } from 'ioredis'; -import { CacheTag } from './types'; +import { type CacheTag } from './types'; let redis: Redis | null = null; @@ -24,7 +24,7 @@ const keyPrefix = process.env.REDIS_KEY_PREFIX ? `${process.env.REDIS_KEY_PREFIX * @param {CacheTag[]} cacheTags Array of cache tags * */ -export const storeQueryCacheTagsRedis = async (queryId: string, cacheTags: CacheTag[]): Promise => { +export const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]): Promise => { if (!cacheTags?.length) { return; } @@ -48,7 +48,7 @@ export const storeQueryCacheTagsRedis = async (queryId: string, cacheTags: Cache * @returns Array of unique query IDs * */ -export const queriesReferencingCacheTagsRedis = async (cacheTags: CacheTag[]): Promise => { +export const queriesReferencingCacheTags = async (cacheTags: CacheTag[]): Promise => { if (!cacheTags?.length) { return []; } @@ -69,7 +69,7 @@ export const queriesReferencingCacheTagsRedis = async (cacheTags: CacheTag[]): P * @returns Number of keys deleted, or null if there was an error * */ -export const deleteCacheTagsRedis = async (cacheTags: CacheTag[]): Promise => { +export const deleteCacheTags = async (cacheTags: CacheTag[]): Promise => { if (!cacheTags?.length) { return 0; } @@ -85,7 +85,7 @@ export const deleteCacheTagsRedis = async (cacheTags: CacheTag[]): Promise => { +export const truncateCacheTags = async (): Promise => { const redis = getRedis(); const pattern = `${keyPrefix}*`; const keys = await redis.keys(pattern); diff --git a/src/index.ts b/src/index.ts index 05585ac..4bf14b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './cache-tags'; -export * from './cache-tags-redis'; +export * as postgres from './cache-tags'; +export * as redis from './cache-tags-redis'; export * from './classnames'; export * from './links'; export * from './types'; From b7319833f2ee2233531c9b0d9a1feaddf4f3462a Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Thu, 12 Feb 2026 14:42:04 +0100 Subject: [PATCH 5/6] bizli ufruuuuuuuma --- package-lock.json | 17 +++++++++++------ package.json | 4 ++-- src/cache-tags.ts | 45 ++++++++++++++++++++------------------------- src/utils.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 33 deletions(-) create mode 100644 src/utils.ts diff --git a/package-lock.json b/package-lock.json index fc70a24..681b9fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,17 @@ "@types/node": "24.10.12", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", - "ioredis": "^5.4.1", + "ioredis": "5.4.1", "prettier": "3.8.1", "typescript": "5.9.3" }, "peerDependencies": { - "ioredis": "^5.4.1" + "ioredis": "^5.4.0" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } } }, "../../.nvm/versions/node/v22.2.0/lib/node_modules/list": { @@ -3161,13 +3166,13 @@ } }, "node_modules/ioredis": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", - "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", "dev": true, "license": "MIT", "dependencies": { - "@ioredis/commands": "1.5.0", + "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", diff --git a/package.json b/package.json index 65434c5..13138e9 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,14 @@ "eslint-import-resolver-typescript": "4.4.4", "prettier": "3.8.1", "typescript": "5.9.3", - "ioredis": "^5.4.1" + "ioredis": "5.4.1" }, "dependencies": { "@vercel/postgres": "^0.10.0", "graphql": "^16.9.0" }, "peerDependencies": { - "ioredis": "^5.4.1" + "ioredis": "^5.4.0" }, "peerDependenciesMeta": { "ioredis": { diff --git a/src/cache-tags.ts b/src/cache-tags.ts index d3baef4..9d28835 100644 --- a/src/cache-tags.ts +++ b/src/cache-tags.ts @@ -1,31 +1,7 @@ import { sql } from '@vercel/postgres'; -import { createHash } from 'crypto'; -import { type DocumentNode, print } from 'graphql'; import { type CacheTag } from './types'; -/** - * Converts the value of DatoCMS's `X-Cache-Tags` header into an array of strings typed as `CacheTag`. - * For example, it transforms `'tag-a tag-2 other-tag'` into `['tag-a', 'tag-2', 'other-tag']`. - * - * @param string String value of the `X-Cache-Tags` header - * @returns Array of strings typed as `CacheTag` - */ -export const parseXCacheTagsResponseHeader = (string?: null | string) => - (string?.split(' ') ?? []).map((tag) => tag as CacheTag); - -/** - * Generates a unique query ID based on the query document and its variables. - * - * @param {DocumentNode} document Query document - * @param {TVariables} variables Query variables - * @returns Unique query ID - */ -export const generateQueryId = (document: DocumentNode, variables?: TVariables): string => { - return createHash('sha1') - .update(print(document)) - .update(JSON.stringify(variables) || '') - .digest('hex'); -}; +export { generateQueryId, parseXCacheTagsResponseHeader } from './utils'; /** * Stores the cache tags of a query in the database. @@ -67,6 +43,25 @@ export const queriesReferencingCacheTags = async (cacheTags: CacheTag[], tableId return rows.map((row) => row.query_id); }; +/** + * Deletes the specified cache tags from the database. + * + * This removes the cache tag keys entirely. When queries are revalidated and + * run again, fresh cache tag mappings will be created. + * + * @param {CacheTag[]} cacheTags Array of cache tags to delete + * @param {string} tableId Database table ID + * + */ +export const deleteCacheTags = async (cacheTags: CacheTag[], tableId: string) => { + if (cacheTags.length === 0) { + return; + } + const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); + + await sql.query(`DELETE FROM ${tableId} WHERE cache_tag IN (${placeholders})`, cacheTags); +}; + /** * Deletes the cache tags of a query from the database. * diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ea2a9fc --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,27 @@ +import { print, type DocumentNode } from 'graphql'; +import { createHash } from 'node:crypto'; +import { type CacheTag } from './types'; + +/** + * Converts the value of DatoCMS's `X-Cache-Tags` header into an array of strings typed as `CacheTag`. + * For example, it transforms `'tag-a tag-2 other-tag'` into `['tag-a', 'tag-2', 'other-tag']`. + * + * @param string String value of the `X-Cache-Tags` header + * @returns Array of strings typed as `CacheTag` + */ +export const parseXCacheTagsResponseHeader = (string?: null | string) => + (string?.split(' ') ?? []).map((tag) => tag as CacheTag); + +/** + * Generates a unique query ID based on the query document and its variables. + * + * @param {DocumentNode} document Query document + * @param {TVariables} variables Query variables + * @returns Unique query ID + */ +export const generateQueryId = (document: DocumentNode, variables?: TVariables): string => { + return createHash('sha1') + .update(print(document)) + .update(JSON.stringify(variables) || '') + .digest('hex'); +}; From 1b09d3e29a89dab1125b4ccf21ada9b62d91189d Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Thu, 12 Feb 2026 14:43:24 +0100 Subject: [PATCH 6/6] deprecate --- src/cache-tags.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cache-tags.ts b/src/cache-tags.ts index 9d28835..21650b1 100644 --- a/src/cache-tags.ts +++ b/src/cache-tags.ts @@ -67,6 +67,7 @@ export const deleteCacheTags = async (cacheTags: CacheTag[], tableId: string) => * * @param {string} queryId Unique query ID * @param {string} tableId Database table ID + * @deprecated Use `deleteCacheTags` instead. */ export const deleteQueries = async (queryIds: string[], tableId: string) => { if (!queryIds?.length) {