diff --git a/.releaserc.json b/.releaserc.json index 73c7d05..99a1d4f 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,11 +1,5 @@ { - "branches": [ - "main", - { - "name": "next", - "prerelease": true - } - ], + "branches": ["main"], "plugins": [ [ "@semantic-release/commit-analyzer", diff --git a/README.md b/README.md index 94ba098..f493c18 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,46 @@ -# smartive DatoCMS Utilities +# smartive Utilities -A collection of utilities and helpers for working with DatoCMS in Next.js projects. +A collection of general purpose utilities and helpers for web projects. ## Installation ```bash -npm install @smartive/datocms-utils +npm install @smartive/utils ``` ## Utilities -### General Utilities - -#### `classNames` +### `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'; +import { classNames } from '@smartive/utils'; const className = classNames('btn', isActive && 'btn-active', 42, undefined, 'btn-primary'); // Result: "btn btn-active 42 btn-primary" ``` -#### `getTelLink` +### `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'; +import { getTelLink } from '@smartive/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, headers); - -// Parse DatoCMS's X-Cache-Tags header -const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); -// Result: ['tag-a', 'tag-2', 'other-tag'] -``` - -#### Storage Providers - -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. - -##### Neon (Postgres) Provider - -Use Neon serverless Postgres to store cache tag mappings. - -**Setup:** - -1. Create the cache tags table: - -```sql -CREATE TABLE IF NOT EXISTS query_cache_tags ( - query_id TEXT NOT NULL, - cache_tag TEXT NOT NULL, - PRIMARY KEY (query_id, cache_tag) -); -``` - -2. Install [@neondatabase/serverless](https://github.com/neondatabase/serverless) - -```bash -npm install @neondatabase/serverless -``` - -3. Create and use the store: - -```typescript -import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon'; - -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 }); - }, -}); - -// Store cache tags for a query -await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); - -// Find queries that reference specific tags -const queries = await provider.queriesReferencingCacheTags(['item:42']); - -// Delete specific cache tags -await provider.deleteCacheTags(['item:42']); - -// Clear all cache tags -await provider.truncateCacheTags(); -``` - -##### Redis Provider - -Use Redis to store cache tag mappings with better performance for high-traffic applications. - -**Setup:** - -1. Install [ioredis](https://github.com/redis/ioredis) - -```bash -npm install ioredis -``` - -2. Create and use the provider: - -```typescript -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(); -``` - -**Redis connection string examples:** - -```bash -# Upstash Redis -REDIS_URL=rediss://default:token@endpoint.upstash.io:6379 - -# Redis Cloud -REDIS_URL=redis://username:password@redis-host:6379 - -# Local development -REDIS_URL=redis://localhost:6379 -``` - -#### `CacheTagsProvider` Interface - -Both providers implement: - -- `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) - -### Complete Example - -```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, request.headers); -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); -``` - -## TypeScript Types +## Migrating from `@smartive/datocms-utils` -The package includes TypeScript types for DatoCMS webhooks and cache tags: +This package was previously published as `@smartive/datocms-utils`. With `4.0.0` it was renamed to +`@smartive/utils` and all DatoCMS cache tag utilities were removed. -- `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 +- `classNames` and `getTelLink` are unchanged — only the import specifier needs to be updated. +- The `cache-tags` entry points (`/cache-tags`, `/cache-tags/neon`, `/cache-tags/redis`, `/cache-tags/noop`) + and the `CacheTag`, `CacheTagsProvider` and `CacheTagsInvalidateWebhook` types are gone. Stay on + `@smartive/datocms-utils@3` if you still rely on them. ## License diff --git a/package-lock.json b/package-lock.json index b997ee9..a076da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,42 +1,22 @@ { - "name": "@smartive/datocms-utils", + "name": "@smartive/utils", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@smartive/datocms-utils", + "name": "@smartive/utils", "version": "1.0.0", "license": "MIT", "devDependencies": { - "@neondatabase/serverless": "1.1.0", "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.13.3", - "@types/pg": "8.20.0", "eslint": "9.39.5", "eslint-import-resolver-typescript": "4.4.5", - "graphql": "17.0.2", - "ioredis": "5.11.1", "prettier": "3.9.6", "rimraf": "6.1.3", "typescript": "5.9.3" - }, - "peerDependencies": { - "@neondatabase/serverless": "^1.0.0", - "graphql": "^15.0.0 || ^16.0.0 || ^17.0.0", - "ioredis": "^5.4.0" - }, - "peerDependenciesMeta": { - "@neondatabase/serverless": { - "optional": true - }, - "graphql": { - "optional": true - }, - "ioredis": { - "optional": true - } } }, "node_modules/@babel/code-frame": { @@ -615,13 +595,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@ioredis/commands": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", - "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", - "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", @@ -694,16 +667,6 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, - "node_modules/@neondatabase/serverless": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", - "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=19.0.0" - } - }, "node_modules/@pkgr/core": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", @@ -811,18 +774,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -1816,16 +1767,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", - "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", - "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", @@ -1990,16 +1931,6 @@ "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", @@ -3135,16 +3066,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphql": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz", - "integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3308,29 +3229,6 @@ "node": ">= 0.4" } }, - "node_modules/ioredis": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", - "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.10.0", - "cluster-key-slot": "1.1.1", - "debug": "4.4.3", - "denque": "2.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", @@ -4315,40 +4213,6 @@ "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-protocol": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", - "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-types": { - "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", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4379,49 +4243,6 @@ "node": ">= 0.4" } }, - "node_modules/postgres-array": { - "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": ">=4" - } - }, - "node_modules/postgres-bytea": { - "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", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "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": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "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": ">=0.10.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4490,29 +4311,6 @@ "dev": true, "license": "MIT" }, - "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", @@ -4847,13 +4645,6 @@ "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", @@ -5445,16 +5236,6 @@ "node": ">=0.10.0" } }, - "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": ">=0.4" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 9b6f1ba..d6e6d2b 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,13 @@ { - "name": "@smartive/datocms-utils", + "name": "@smartive/utils", "version": "1.0.0", - "description": "A set of utilities and helpers to work with DatoCMS in a Next.js project.", + "description": "A set of general purpose utilities and helpers for web projects.", "type": "module", "source": "./src/index.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": [ @@ -41,42 +25,23 @@ "access": "public" }, "keywords": [ - "datocms" + "utils", + "helpers" ], "author": "smartive AG", "license": "MIT", "repository": { - "url": "git+https://github.com/smartive/datocms-utils.git", + "url": "git+https://github.com/smartive/utils.git", "type": "git" }, "devDependencies": { - "@neondatabase/serverless": "1.1.0", "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.13.3", - "@types/pg": "8.20.0", "eslint": "9.39.5", "eslint-import-resolver-typescript": "4.4.5", - "graphql": "17.0.2", - "ioredis": "5.11.1", "prettier": "3.9.6", "rimraf": "6.1.3", "typescript": "5.9.3" - }, - "peerDependencies": { - "@neondatabase/serverless": "^1.0.0", - "graphql": "^15.0.0 || ^16.0.0 || ^17.0.0", - "ioredis": "^5.4.0" - }, - "peerDependenciesMeta": { - "@neondatabase/serverless": { - "optional": true - }, - "graphql": { - "optional": true - }, - "ioredis": { - "optional": true - } } } diff --git a/src/cache-tags/index.ts b/src/cache-tags/index.ts deleted file mode 100644 index 920534d..0000000 --- a/src/cache-tags/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types.js'; -export * from './utils.js'; diff --git a/src/cache-tags/provider/base.ts b/src/cache-tags/provider/base.ts deleted file mode 100644 index ac91938..0000000 --- a/src/cache-tags/provider/base.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index f49e5e6..0000000 --- a/src/cache-tags/provider/neon.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 6c8990e..0000000 --- a/src/cache-tags/provider/noop.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 78e2e79..0000000 --- a/src/cache-tags/provider/redis.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index b874c7b..0000000 --- a/src/cache-tags/types.ts +++ /dev/null @@ -1,83 +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[]; - }; - }; -}; - -/** - * 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/cache-tags/utils.ts b/src/cache-tags/utils.ts deleted file mode 100644 index f12bd5f..0000000 --- a/src/cache-tags/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { print, type DocumentNode } from 'graphql'; -import { createHash } from 'node:crypto'; -import { type CacheTag } from './types.js'; - -/** - * 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, its variables, and optional HTTP headers. - * - * @param {DocumentNode} document Query document - * @param {TVariables} variables Optional query variables - * @param {HeadersInit} headers Optional HTTP headers that might affect the query result (e.g., for authentication) - * @returns Unique query ID - */ -export const generateQueryId = ( - document: DocumentNode, - variables?: TVariables, - headers?: HeadersInit, -): string => { - return createHash('sha1') - .update(print(document)) - .update(JSON.stringify(variables) || '') - .update(JSON.stringify(headers) || '') - .digest('hex'); -};