diff --git a/README.md b/README.md index 69aedf7..373a7f5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,69 @@ 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. + +- `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. + +#### 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 +// Recommended: Use namespaces for clarity +import { generateQueryId, redis } from '@smartive/datocms-utils'; + +const queryId = generateQueryId(query, variables); + +// Store cache tags for a query +await redis.storeQueryCacheTags(queryId, ['item:42', 'product', 'category:5']); + +// Find all queries that reference specific tags +const affectedQueries = await redis.queriesReferencingCacheTags(['item:42']); + +// Delete cache tags (keys will be recreated on next query) +await redis.deleteCacheTags(['item:42']); +``` + +#### Redis Data Structure + +The Redis implementation uses Sets to track query-to-tag relationships: + +- **Cache tag keys**: `{prefix}{tag}` → Set of query IDs + +Where `{prefix}` is the optional `REDIS_KEY_PREFIX` environment variable (e.g., `prod:`, `preview:`). + +When cache tags are invalidated, their keys are deleted entirely. Fresh mappings are created when queries run again. + ### 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..681b9fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,17 @@ "@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.0" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } } }, "../../.nvm/versions/node/v22.2.0/lib/node_modules/list": { @@ -80,7 +89,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 +580,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 +857,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 +1371,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1656,7 +1669,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -1677,7 +1689,6 @@ "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -1782,6 +1793,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 +1963,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 +2210,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 +2270,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3136,6 +3165,31 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "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.1.1", + "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 +3740,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 +4242,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4245,6 +4312,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 +4679,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 +4900,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4953,7 +5049,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5018,7 +5113,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5249,7 +5343,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..13138e9 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,19 @@ "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.0" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } } } diff --git a/src/cache-tags-redis.ts b/src/cache-tags-redis.ts new file mode 100644 index 0000000..6a1a1e5 --- /dev/null +++ b/src/cache-tags-redis.ts @@ -0,0 +1,96 @@ +import { Redis } from 'ioredis'; +import { type CacheTag } from './types'; + +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. + * + * 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 + * + */ +export const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return; + } + + const redis = getRedis(); + const pipeline = redis.pipeline(); + + for (const tag of cacheTags) { + pipeline.sadd(`${keyPrefix}${tag}`, queryId); + } + + 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 queriesReferencingCacheTags = async (cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return []; + } + + const redis = getRedis(); + const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); + + return redis.sunion(...keys); +}; + +/** + * Deletes the specified cache tags from Redis. + * + * 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 + * @returns Number of keys deleted, or null if there was an error + * + */ +export const deleteCacheTags = async (cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return 0; + } + + const redis = getRedis(); + const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); + + return redis.del(...keys); +}; + +/** + * Wipes out all cache tags from Redis. + * + * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! + */ +export const truncateCacheTags = async (): Promise => { + const redis = getRedis(); + const pattern = `${keyPrefix}*`; + const keys = await redis.keys(pattern); + + if (keys.length > 0) { + await redis.del(...keys); + } +}; diff --git a/src/cache-tags.ts b/src/cache-tags.ts index d3baef4..21650b1 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,11 +43,31 @@ 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. * * @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) { diff --git a/src/index.ts b/src/index.ts index 3861992..4bf14b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export * from './cache-tags'; +export * as postgres from './cache-tags'; +export * as redis from './cache-tags-redis'; export * from './classnames'; export * from './links'; export * from './types'; 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'); +};