diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad21096..416ba5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - next permissions: contents: write # to be able to publish a GitHub release @@ -29,5 +30,6 @@ jobs: semantic_version: 25 extra_plugins: | @semantic-release/changelog@6 + conventional-changelog-conventionalcommits@9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.releaserc.json b/.releaserc.json index 135b7a2..73c7d05 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,8 +1,24 @@ { - "branches": ["main"], + "branches": [ + "main", + { + "name": "next", + "prerelease": true + } + ], "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits" + } + ], "@semantic-release/changelog", "@semantic-release/npm", "@semantic-release/github" diff --git a/README.md b/README.md index 373a7f5..bf1bbcd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # smartive DatoCMS Utilities -A set of utilities and helpers to work with DatoCMS in a Next.js project. +A collection of utilities and helpers for working with DatoCMS in Next.js projects. ## Installation @@ -8,23 +8,60 @@ A set of utilities and helpers to work with DatoCMS in a Next.js project. npm install @smartive/datocms-utils ``` -## Usage +## Utilities -Import and use the utilities you need in your project. The following utilities are available. +### General Utilities -## Utilities +#### `classNames` + +Cleans and joins an array of class names (strings and numbers), filtering out undefined and boolean values. + +```typescript +import { classNames } from '@smartive/datocms-utils'; + +const className = classNames('btn', isActive && 'btn-active', 42, undefined, 'btn-primary'); +// Result: "btn btn-active 42 btn-primary" +``` + +#### `getTelLink` + +Converts a phone number into a `tel:` link by removing non-digit characters (except `+` for international numbers). + +```typescript +import { getTelLink } from '@smartive/datocms-utils'; + +const link = getTelLink('+1 (555) 123-4567'); +// Result: "tel:+15551234567" +``` + +### DatoCMS Cache Tags + +Utilities for managing [DatoCMS cache tags](https://www.datocms.com/docs/content-delivery-api/cache-tags) with different storage backends. Cache tags enable efficient cache invalidation by tracking which queries reference which content. + +#### Core Utilities + +```typescript +import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags'; + +// Generate a unique ID for a GraphQL query +const queryId = generateQueryId(document, variables); + +// Parse DatoCMS's X-Cache-Tags header +const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); +// Result: ['tag-a', 'tag-2', 'other-tag'] +``` -### Utilities for DatoCMS Cache Tags +#### Storage Providers -The following utilities are used to work with [DatoCMS cache tags](https://www.datocms.com/docs/content-delivery-api/cache-tags) and a [Vercel Postgres database](https://vercel.com/docs/storage/vercel-postgres). +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. -- `storeQueryCacheTags`: Stores the cache tags of a query in the database. -- `queriesReferencingCacheTags`: Retrieves the queries that reference cache tags. -- `deleteQueries`: Deletes the cache tags of a query from the database. +##### Neon (Postgres) Provider -#### Setup Postgres database +Use Neon serverless Postgres to store cache tag mappings. -In order for the above utilites to work, you need to setup a the following database. You can use the following SQL script to do that: +**Setup:** + +1. Create the cache tags table: ```sql CREATE TABLE IF NOT EXISTS query_cache_tags ( @@ -34,75 +71,121 @@ 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. +2. Install [@neondatabase/serverless](https://github.com/neondatabase/serverless) -- `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. +```bash +npm install @neondatabase/serverless +``` -The Redis connection is automatically initialized on first use using the `REDIS_URL` environment variable. +3. Create and use the store: -#### Environment Variables +```typescript +import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon'; -Add your Redis connection URL to your `.env.local` file: +const provider = new NeonCacheTagsProvider({ + connectionUrl: process.env.DATABASE_URL!, + table: 'query_cache_tags', + throwOnError: false, // Optional: Disable error throwing, defaults to `true` + onError(error, ctx) { // Optional: Custom error callback + console.error('CacheTagsProvider error', { error, context: ctx }); + }, +}); -```bash -# Required: Redis connection URL -# For Upstash Redis -REDIS_URL=rediss://default:your-token@your-endpoint.upstash.io:6379 +// Store cache tags for a query +await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); -# For Redis Cloud or other providers -REDIS_URL=redis://username:password@your-redis-host:6379 +// Find queries that reference specific tags +const queries = await provider.queriesReferencingCacheTags(['item:42']); -# For local development -REDIS_URL=redis://localhost:6379 +// Delete specific cache tags +await provider.deleteCacheTags(['item:42']); -# 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) +// Clear all cache tags +await provider.truncateCacheTags(); ``` -**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. +##### Redis Provider + +Use Redis to store cache tag mappings with better performance for high-traffic applications. -#### Usage Example +**Setup:** + +1. Install [ioredis](https://github.com/redis/ioredis) + +```bash +npm install ioredis +``` + +2. Create and use the provider: ```typescript -// Recommended: Use namespaces for clarity -import { generateQueryId, redis } from '@smartive/datocms-utils'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; + +const provider = new RedisCacheTagsProvider({ + connectionUrl: process.env.REDIS_URL!, + keyPrefix: 'prod:', // Optional: namespace for multi-environment setups + throwOnError: process.env.NODE_ENV === 'development', // Optional: Disable error throwing in production - defaults to `true` +}); + +// Same API as Neon provider +await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); +const queries = await provider.queriesReferencingCacheTags(['item:42']); +await provider.deleteCacheTags(['item:42']); +await provider.truncateCacheTags(); +``` -const queryId = generateQueryId(query, variables); +**Redis connection string examples:** -// Store cache tags for a query -await redis.storeQueryCacheTags(queryId, ['item:42', 'product', 'category:5']); +```bash +# Upstash Redis +REDIS_URL=rediss://default:token@endpoint.upstash.io:6379 -// Find all queries that reference specific tags -const affectedQueries = await redis.queriesReferencingCacheTags(['item:42']); +# Redis Cloud +REDIS_URL=redis://username:password@redis-host:6379 -// Delete cache tags (keys will be recreated on next query) -await redis.deleteCacheTags(['item:42']); +# Local development +REDIS_URL=redis://localhost:6379 ``` -#### Redis Data Structure +#### `CacheTagsProvider` Interface + +Both providers implement: -The Redis implementation uses Sets to track query-to-tag relationships: +- `storeQueryCacheTags(queryId: string, cacheTags: CacheTag[])`: Store cache tags for a query +- `queriesReferencingCacheTags(cacheTags: CacheTag[])`: Get query IDs that reference any of the specified tags +- `deleteCacheTags(cacheTags: CacheTag[])`: Delete specific cache tags +- `truncateCacheTags()`: Wipe all cache tags (use with caution) -- **Cache tag keys**: `{prefix}{tag}` → Set of query IDs +### Complete Example -Where `{prefix}` is the optional `REDIS_KEY_PREFIX` environment variable (e.g., `prod:`, `preview:`). +```typescript +import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; + +const provider = new RedisCacheTagsProvider({ + connectionUrl: process.env.REDIS_URL!, + keyPrefix: 'myapp:', +}); + +// After making a DatoCMS query +const queryId = generateQueryId(document, variables); +const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']); +await provider.storeQueryCacheTags(queryId, cacheTags); + +// When handling DatoCMS webhook for cache invalidation +const affectedQueries = await provider.queriesReferencingCacheTags(webhook.entity.attributes.tags); +// Revalidate affected queries... +await provider.deleteCacheTags(webhook.entity.attributes.tags); +``` -When cache tags are invalidated, their keys are deleted entirely. Fresh mappings are created when queries run again. +## TypeScript Types -### Other Utilities +The package includes TypeScript types for DatoCMS webhooks and cache tags: -- `classNames`: Cleans and joins an array of inputs with possible undefined or boolean values. Useful for tailwind classnames. -- `getTelLink`: Formats a phone number to a tel link. +- `CacheTag`: A branded type for cache tags, ensuring type safety +- `CacheTagsInvalidateWebhook`: Type definition for DatoCMS cache tag invalidation webhook payloads +- `CacheTagsProvider`: Interface for cache tag storage implementations -### Types +## License -- `CacheTag`: A branded type for cache tags. -- `CacheTagsInvalidateWebhook`: The payload of the DatoCMS cache tags invalidate webhook. +MIT © [smartive AG](https://github.com/smartive) diff --git a/package-lock.json b/package-lock.json index c54da8a..a777e76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,24 +8,31 @@ "name": "@smartive/datocms-utils", "version": "1.0.0", "license": "MIT", - "dependencies": { - "@vercel/postgres": "^0.10.0", - "graphql": "^16.9.0" - }, "devDependencies": { + "@neondatabase/serverless": "1.0.2", "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.10.13", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", + "graphql": "16.12.0", "ioredis": "5.9.3", "prettier": "3.8.1", + "rimraf": "6.1.2", "typescript": "5.9.3" }, "peerDependencies": { + "@neondatabase/serverless": "^1.0.0", + "graphql": "^15.0.0 || ^16.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { + "@neondatabase/serverless": { + "optional": true + }, + "graphql": { + "optional": true + }, "ioredis": { "optional": true } @@ -586,6 +593,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -650,14 +667,36 @@ } }, "node_modules/@neondatabase/serverless": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.9.5.tgz", - "integrity": "sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", + "integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/pg": "8.11.6" + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" } }, + "node_modules/@neondatabase/serverless/node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -759,20 +798,22 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/@types/pg": { - "version": "8.11.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", - "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "pg-protocol": "*", - "pg-types": "^4.0.1" + "pg-types": "^2.2.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -1277,21 +1318,6 @@ "win32" ] }, - "node_modules/@vercel/postgres": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@vercel/postgres/-/postgres-0.10.0.tgz", - "integrity": "sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==", - "deprecated": "@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon's SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide", - "license": "Apache-2.0", - "dependencies": { - "@neondatabase/serverless": "^0.9.3", - "bufferutil": "^4.0.8", - "ws": "^8.17.1" - }, - "engines": { - "node": ">=18.14" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1602,19 +1628,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2930,6 +2943,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.3.tgz", + "integrity": "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.0", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2943,6 +2974,48 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "17.3.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", @@ -2990,6 +3063,7 @@ "version": "16.12.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -3607,6 +3681,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3810,6 +3900,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3840,17 +3940,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -3981,12 +4070,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4055,6 +4138,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4095,46 +4185,65 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, "license": "ISC", "engines": { "node": ">=4.0.0" } }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, "node_modules/pg-protocol": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "dev": true, "license": "MIT" }, "node_modules/pg-types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", - "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, "license": "MIT", "dependencies": { "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" }, "engines": { - "node": ">=10" + "node": ">=4" } }, "node_modules/picocolors": { @@ -4168,50 +4277,48 @@ } }, "node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=4" } }, "node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, "license": "MIT", - "dependencies": { - "obuf": "~1.1.2" - }, "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, "node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, "node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4388,6 +4495,26 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -5014,6 +5141,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -5207,25 +5335,14 @@ "node": ">=0.10.0" } }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=0.4" } }, "node_modules/yallist": { diff --git a/package.json b/package.json index 09ed166..63cffc1 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,35 @@ "description": "A set of utilities and helpers to work with DatoCMS in a Next.js project.", "type": "module", "source": "./src/index.ts", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./cache-tags": { + "types": "./dist/cache-tags/index.d.ts", + "import": "./dist/cache-tags/index.js" + }, + "./cache-tags/redis": { + "types": "./dist/cache-tags/provider/redis.d.ts", + "import": "./dist/cache-tags/provider/redis.js" + }, + "./cache-tags/neon": { + "types": "./dist/cache-tags/provider/neon.d.ts", + "import": "./dist/cache-tags/provider/neon.js" + }, + "./cache-tags/noop": { + "types": "./dist/cache-tags/provider/noop.d.ts", + "import": "./dist/cache-tags/provider/noop.js" + } + }, "files": [ "dist/**/*", "src/**/*" ], "scripts": { + "clean": "rimraf dist", + "prebuild": "npm run clean", "build": "tsc", "lint": "eslint src", "prettier": "prettier --check src" @@ -28,23 +50,30 @@ "type": "git" }, "devDependencies": { + "@neondatabase/serverless": "1.0.2", "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.10.13", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", + "graphql": "16.12.0", + "ioredis": "5.9.3", "prettier": "3.8.1", - "typescript": "5.9.3", - "ioredis": "5.9.3" - }, - "dependencies": { - "@vercel/postgres": "^0.10.0", - "graphql": "^16.9.0" + "rimraf": "6.1.2", + "typescript": "5.9.3" }, "peerDependencies": { + "@neondatabase/serverless": "^1.0.0", + "graphql": "^15.0.0 || ^16.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { + "@neondatabase/serverless": { + "optional": true + }, + "graphql": { + "optional": true + }, "ioredis": { "optional": true } diff --git a/src/cache-tags-redis.ts b/src/cache-tags-redis.ts deleted file mode 100644 index 6a1a1e5..0000000 --- a/src/cache-tags-redis.ts +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 21650b1..0000000 --- a/src/cache-tags.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { sql } from '@vercel/postgres'; -import { type CacheTag } from './types'; - -export { generateQueryId, parseXCacheTagsResponseHeader } from './utils'; - -/** - * Stores the cache tags of a query in the database. - * - * @param {string} queryId Unique query ID - * @param {CacheTag[]} cacheTags Array of cache tags - * @param {string} tableId Database table ID - */ -export const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[], tableId: string) => { - if (!cacheTags?.length) { - return; - } - - const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]); - const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(','); - - await sql.query(`INSERT INTO ${tableId} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); -}; - -/** - * Retrieves the queries that reference cache tags. - * - * @param {CacheTag[]} cacheTags Array of cache tags - * @param {string} tableId Database table ID - * @returns Array of query IDs - */ -export const queriesReferencingCacheTags = async (cacheTags: CacheTag[], tableId: string): Promise => { - if (!cacheTags?.length) { - return []; - } - - const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); - - const { rows }: { rows: { query_id: string }[] } = await sql.query( - `SELECT DISTINCT query_id FROM ${tableId} WHERE cache_tag IN (${placeholders})`, - cacheTags, - ); - - 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) { - return; - } - const placeholders = queryIds.map((_, i) => `$${i + 1}`).join(','); - - await sql.query(`DELETE FROM ${tableId} WHERE query_id IN (${placeholders})`, queryIds); -}; - -/** - * Wipes out all cache tags from the database. - * - * @param {string} tableId Database table ID - */ -export async function truncateCacheTags(tableId: string) { - await sql.query(`DELETE FROM ${tableId}`); -} diff --git a/src/cache-tags/index.ts b/src/cache-tags/index.ts new file mode 100644 index 0000000..920534d --- /dev/null +++ b/src/cache-tags/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './utils.js'; diff --git a/src/cache-tags/provider/base.ts b/src/cache-tags/provider/base.ts new file mode 100644 index 0000000..ac91938 --- /dev/null +++ b/src/cache-tags/provider/base.ts @@ -0,0 +1,48 @@ +import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js'; + +/** + * An abstract base class for `CacheTagsProvider` implementations that adds error handling and logging. + */ +export abstract class AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider { + protected readonly throwOnError: boolean; + protected readonly onError?: CacheTagsProviderErrorHandlingConfig['onError']; + + protected constructor( + protected readonly providerName: string, + config: CacheTagsProviderErrorHandlingConfig = {}, + ) { + this.throwOnError = config.throwOnError ?? true; + this.onError = config.onError; + } + + public abstract storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise; + + public abstract queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise; + + public abstract deleteCacheTags(cacheTags: CacheTag[]): Promise; + + public abstract truncateCacheTags(): Promise; + + protected async wrap(method: keyof CacheTagsProvider, args: unknown[], fn: () => Promise, fallback: T): Promise { + try { + return await fn(); + } catch (error) { + const provider = this.providerName; + + // Call onError callback if provided, but guard against exceptions + // to prevent masking the original provider error + try { + this.onError?.(error, { provider, method, args }); + } catch (handlerError) { + console.error(`Error handler itself failed in ${provider}.${method}.`, { handlerError }); + } + + if (this.throwOnError) { + throw error; + } + console.warn(`Error occurred in ${provider}.${method}.`, { error, args }); + + return fallback; + } + } +} diff --git a/src/cache-tags/provider/neon.ts b/src/cache-tags/provider/neon.ts new file mode 100644 index 0000000..f49e5e6 --- /dev/null +++ b/src/cache-tags/provider/neon.ts @@ -0,0 +1,136 @@ +import { neon } from '@neondatabase/serverless'; +import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js'; +import { AbstractErrorHandlingCacheTagsProvider } from './base.js'; + +type NeonCacheTagsProviderBaseConfig = { + /** + * Neon connection string. You can find it in the "Connection" tab of your Neon project dashboard. + * Has the format `postgresql://user:pass@host/db` + */ + readonly connectionUrl: string; + /** + * Name of the table where cache tags will be stored. The table must have the following schema: + * + * ```sql + * CREATE TABLE your_table_name ( + * query_id TEXT NOT NULL, + * cache_tag TEXT NOT NULL, + * PRIMARY KEY (query_id, cache_tag) + * ); + * ``` + */ + readonly table: string; +}; + +export type NeonCacheTagsProviderConfig = NeonCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig; + +/** + * A `CacheTagsProvider` implementation that uses Neon as the storage backend. + */ +export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider { + private readonly sql; + private readonly table; + + constructor({ connectionUrl, table, throwOnError, onError }: NeonCacheTagsProviderConfig) { + super('NeonCacheTagsProvider', { throwOnError, onError }); + this.sql = neon(connectionUrl, { fullResults: true }); + this.table = NeonCacheTagsProvider.quoteIdentifier(table); + } + + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { + return this.wrap( + 'storeQueryCacheTags', + [queryId, cacheTags], + async () => { + if (!cacheTags?.length) { + return; + } + + const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]); + const placeholders = cacheTags.map((_, i) => `($${2 * i + 1}, $${2 * i + 2})`).join(','); + + await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); + }, + undefined, + ); + } + + public async queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise { + return this.wrap( + 'queriesReferencingCacheTags', + [cacheTags], + async () => { + if (!cacheTags?.length) { + return []; + } + + const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); + + const { rows } = await this.sql.query( + `SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`, + cacheTags, + ); + + return rows.reduce((queryIds, row) => { + if (typeof row.query_id === 'string') { + queryIds.push(row.query_id); + } + + return queryIds; + }, []); + }, + [], + ); + } + + public async deleteCacheTags(cacheTags: CacheTag[]) { + return this.wrap( + 'deleteCacheTags', + [cacheTags], + async () => { + if (!cacheTags?.length) { + return 0; + } + const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); + + return ( + (await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0 + ); + }, + 0, + ); + } + + public async truncateCacheTags() { + return this.wrap( + 'truncateCacheTags', + [], + async () => { + return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0; + }, + 0, + ); + } + + /** + * Validates and quotes a PostgreSQL identifier (table name, column name, etc.) to prevent SQL injection. + * @param identifier The identifier to validate and quote + * @returns The properly quoted identifier + * @throws Error if the identifier is invalid + */ + private static quoteIdentifier(identifier: string): string { + if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)?$/.test(identifier)) { + 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.`, + ); + } + + // Quote the identifier using double quotes to prevent SQL injection + // Handle schema-qualified names (e.g., "schema.table") + // Escape any double quotes within the identifier by doubling them + return identifier + .split('.') + .map((part) => `"${part.replace(/"/g, '""')}"`) + .join('.'); + } +} diff --git a/src/cache-tags/provider/noop.ts b/src/cache-tags/provider/noop.ts new file mode 100644 index 0000000..6c8990e --- /dev/null +++ b/src/cache-tags/provider/noop.ts @@ -0,0 +1,32 @@ +import { type CacheTag, type CacheTagsProvider } from '../types.js'; + +/** + * A `CacheTagsProvider` implementation that does not perform any actual storage operations. + * + * _Note: This implementation is useful for testing purposes or when you want to disable caching without changing the code that interacts with the cache._ + */ +export class NoopCacheTagsProvider implements CacheTagsProvider { + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { + console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); + + return Promise.resolve(); + } + + public async queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise { + console.debug('-- queriesReferencingCacheTags called', { cacheTags }); + + return Promise.resolve([]); + } + + public async deleteCacheTags(cacheTags: CacheTag[]) { + console.debug('-- deleteCacheTags called', { cacheTags }); + + return Promise.resolve(0); + } + + public async truncateCacheTags() { + console.debug('-- truncateCacheTags called'); + + return Promise.resolve(0); + } +} diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts new file mode 100644 index 0000000..78e2e79 --- /dev/null +++ b/src/cache-tags/provider/redis.ts @@ -0,0 +1,140 @@ +import { Redis } from 'ioredis'; +import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js'; +import { AbstractErrorHandlingCacheTagsProvider } from './base.js'; + +type RedisCacheTagsProviderBaseConfig = { + /** + * Redis connection string. For example, `redis://user:pass@host:port/db`. + */ + readonly connectionUrl: string; + /** + * Optional prefix for Redis keys. If provided, all keys used to store cache tags will be prefixed with this value. + * This can be useful to avoid key collisions if the same Redis instance is used for multiple purposes. + * For example, if you set `keyPrefix` to `'myapp:'`, a cache tag like `'tag1'` will be stored under the key `'myapp:tag1'`. + */ + readonly keyPrefix?: string; +}; + +export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig; + +/** + * A `CacheTagsProvider` implementation that uses Redis as the storage backend. + */ +export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider { + private readonly redis; + private readonly keyPrefix; + + constructor({ connectionUrl, keyPrefix, throwOnError, onError }: RedisCacheTagsProviderConfig) { + super('RedisCacheTagsProvider', { throwOnError, onError }); + this.redis = new Redis(connectionUrl, { + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + this.keyPrefix = keyPrefix ?? ''; + } + + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { + return this.wrap( + 'storeQueryCacheTags', + [queryId, cacheTags], + async () => { + if (!cacheTags?.length) { + return; + } + + const pipeline = this.redis.pipeline(); + + for (const tag of cacheTags) { + pipeline.sadd(`${this.keyPrefix}${tag}`, queryId); + } + + const results = await pipeline.exec(); + const error = results?.find(([err]) => err)?.[0]; + if (error) { + throw error; + } + }, + undefined, + ); + } + + public async queriesReferencingCacheTags(cacheTags: CacheTag[]) { + return this.wrap( + 'queriesReferencingCacheTags', + [cacheTags], + async () => { + if (!cacheTags?.length) { + return []; + } + + const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); + + return this.redis.sunion(...keys); + }, + [], + ); + } + + public async deleteCacheTags(cacheTags: CacheTag[]) { + return this.wrap( + 'deleteCacheTags', + [cacheTags], + async () => { + if (!cacheTags?.length) { + return 0; + } + + const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); + + return this.redis.del(...keys); + }, + 0, + ); + } + + public async truncateCacheTags() { + return this.wrap( + 'truncateCacheTags', + [], + async () => { + const keys = await this.getKeys(); + + if (keys.length === 0) { + return 0; + } + + return await this.redis.del(...keys); + }, + 0, + ); + } + + /** + * Retrieves all keys matching the given pattern using the Redis SCAN command. + * This method is more efficient than using the KEYS command, especially for large datasets. + * + * @returns An array of matching keys + */ + private async getKeys(): Promise { + return new Promise((resolve, reject) => { + const keys: string[] = []; + + const stream = this.redis.scanStream({ + match: `${this.keyPrefix}*`, + count: 1000, + }); + + stream.on('data', (resultKeys: string[]) => { + keys.push(...resultKeys); + }); + + stream.on('end', () => { + resolve(keys); + }); + + stream.on('error', (err) => { + reject(err); + }); + }); + } +} diff --git a/src/cache-tags/types.ts b/src/cache-tags/types.ts new file mode 100644 index 0000000..b874c7b --- /dev/null +++ b/src/cache-tags/types.ts @@ -0,0 +1,83 @@ +/** + * A branded type for cache tags. This is created by intersecting `string` + * with `{ readonly _: unique symbol }`, making it a unique type. + * Although it is fundamentally a string, it is treated as a distinct type + * due to the unique symbol. + */ +export type CacheTag = string & { readonly _: unique symbol }; + +/** + * A type representing the structure of a webhook payload for cache tag invalidation. + * It includes the entity type, event type, and the entity details which contain + * the cache tags to be invalidated. + */ +export type CacheTagsInvalidateWebhook = { + entity_type: 'cda_cache_tags'; + event_type: 'invalidate'; + entity: { + id: 'cda_cache_tags'; + type: 'cda_cache_tags'; + attributes: { + tags: CacheTag[]; + }; + }; +}; + +/** + * Configuration object for creating a `CacheTagsProvider` implementation. + */ +export interface CacheTagsProvider { + /** + * Stores the cache tags of a query. + * + * @param {string} queryId Unique query ID + * @param {CacheTag[]} cacheTags Array of cache tags + * + */ + storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]): Promise; + + /** + * Retrieves the query IDs that reference any of the specified cache tags. + * + * @param {CacheTag[]} cacheTags Array of cache tags to check + * @returns Array of unique query IDs + * + */ + queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise; + + /** + * Deletes the specified cache tags. + * + * 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 + * + */ + deleteCacheTags(cacheTags: CacheTag[]): Promise; + + /** + * Wipes out all cache tags. + * + * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! + */ + truncateCacheTags(): Promise; +} + +export type CacheTagsProviderErrorHandlingConfig = { + /** + * If false, errors are suppressed and a fallback value is returned. + * Default: true + */ + throwOnError?: boolean; + + /** + * Optional callback invoked when an error occurs in a `CacheTagsProvider` method, + * useful for logging and telemetry. + * + * Called before the error is either thrown (when `throwOnError` is true or + * undefined) or suppressed (when `throwOnError` is false). + */ + onError?: (error: unknown, ctx: { provider: string; method: keyof CacheTagsProvider; args: unknown[] }) => void; +}; diff --git a/src/utils.ts b/src/cache-tags/utils.ts similarity index 95% rename from src/utils.ts rename to src/cache-tags/utils.ts index ea2a9fc..c334f79 100644 --- a/src/utils.ts +++ b/src/cache-tags/utils.ts @@ -1,6 +1,6 @@ import { print, type DocumentNode } from 'graphql'; import { createHash } from 'node:crypto'; -import { type CacheTag } from './types'; +import { type CacheTag } from './types.js'; /** * Converts the value of DatoCMS's `X-Cache-Tags` header into an array of strings typed as `CacheTag`. diff --git a/src/classnames.ts b/src/classnames.ts index 9ea16ec..35b79f7 100644 --- a/src/classnames.ts +++ b/src/classnames.ts @@ -4,4 +4,10 @@ * @param classNames Array of class names * @returns Clean string to be used for class name */ -export const classNames = (...classNames: (string | undefined | boolean)[]): string => classNames.filter(Boolean).join(' '); +export const classNames = (...classNames: unknown[]) => + classNames + .filter( + (value): value is string | number => + (typeof value === 'string' && value.length > 0) || (typeof value === 'number' && Number.isFinite(value)), + ) + .join(' '); diff --git a/src/index.ts b/src/index.ts index 3861992..d0ba229 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,2 @@ -export * from './cache-tags'; -export * from './classnames'; -export * from './links'; -export * from './types'; +export * from './classnames.js'; +export * from './links.js'; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 5cae59b..0000000 --- a/src/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * A branded type for cache tags. This is created by intersecting `string` - * with `{ readonly _: unique symbol }`, making it a unique type. - * Although it is fundamentally a string, it is treated as a distinct type - * due to the unique symbol. - */ -export type CacheTag = string & { readonly _: unique symbol }; - -/** - * A type representing the structure of a webhook payload for cache tag invalidation. - * It includes the entity type, event type, and the entity details which contain - * the cache tags to be invalidated. - */ -export type CacheTagsInvalidateWebhook = { - entity_type: 'cda_cache_tags'; - event_type: 'invalidate'; - entity: { - id: 'cda_cache_tags'; - type: 'cda_cache_tags'; - attributes: { - tags: CacheTag[]; - }; - }; -}; diff --git a/tsconfig.json b/tsconfig.json index bd04aad..829de75 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,11 +7,11 @@ "strict": true, "noImplicitReturns": true, "noImplicitAny": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "allowSyntheticDefaultImports": true, "allowJs": true, "resolveJsonModule": true, - "module": "esnext", + "module": "nodenext", "target": "esnext" }, "include": ["./src/**/*"]