diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f8860a..33240eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,14 +22,19 @@ jobs: - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 24 + registry-url: https://registry.npmjs.org - run: npm ci - - run: npm run build + - run: npm run prettier + - run: npm run lint + - run: npm run typecheck + - run: npm run test + - run: npm run publint + - run: npm run pack:check - name: semantic release - uses: cycjimmy/semantic-release-action@v6 + uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6 with: semantic_version: 25 extra_plugins: | - @semantic-release/changelog@6 conventional-changelog-conventionalcommits@9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 169f529..d47b130 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,48 @@ jobs: - run: npm ci - run: npm run prettier - run: npm run lint + - run: npm run typecheck + - run: npm run test + - run: npm run publint + - run: npm run pack:check + + node20-smoke: + name: Node 20 smoke import + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 20 + - run: npm ci - run: npm run build + - name: Smoke-import entry points + run: | + node --input-type=module -e " + await import('./dist/index.js'); + await import('./dist/cache-tags/index.js'); + await import('./dist/cache-tags/header.js'); + await import('./dist/cache-tags/provider/base.js'); + await import('./dist/cache-tags/provider/noop.js'); + await import('./dist/cache-tags/provider/redis.js'); + await import('./dist/cache-tags/provider/neon.js'); + console.log('ok'); + " + + graphql-compat: + name: GraphQL ${{ matrix.graphql }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + graphql: ['15.10.1', '16.14.2', '17.0.2'] + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + - run: npm ci + - run: npm install --no-save graphql@${{ matrix.graphql }} + - run: npm run test diff --git a/.releaserc.json b/.releaserc.json index 73c7d05..2d8e9d3 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -19,7 +19,6 @@ "preset": "conventionalcommits" } ], - "@semantic-release/changelog", "@semantic-release/npm", "@semantic-release/github" ] diff --git a/README.md b/README.md index 94ba098..2245333 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ npm install @smartive/datocms-utils Cleans and joins an array of class names (strings and numbers), filtering out undefined and boolean values. +Numbers are kept intentionally, so patterns like `count && 'badge'` with `count === 0` emit a literal `"0"` class. Prefer `count > 0 && 'badge'` when you want to omit the class for zero. + ```typescript import { classNames } from '@smartive/datocms-utils'; @@ -25,7 +27,7 @@ const className = classNames('btn', isActive && 'btn-active', 42, undefined, 'bt #### `getTelLink` -Converts a phone number into a `tel:` link by removing non-digit characters (except `+` for international numbers). +Converts a phone number into a `tel:` link by removing non-digit characters, while preserving a leading `+` for international numbers. ```typescript import { getTelLink } from '@smartive/datocms-utils'; @@ -40,6 +42,18 @@ Utilities for managing [DatoCMS cache tags](https://www.datocms.com/docs/content #### Core Utilities +Install [graphql](https://github.com/graphql/graphql-js) when using `generateQueryId` (`@smartive/datocms-utils/cache-tags` depends on it at runtime): + +```bash +npm install graphql +``` + +If you only need to parse the `X-Cache-Tags` header, use the graphql-free subpath: + +```typescript +import { parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags/header'; +``` + ```typescript import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags'; @@ -51,10 +65,20 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); // Result: ['tag-a', 'tag-2', 'other-tag'] ``` +> **Edge runtime:** `generateQueryId` uses Node.js `crypto.createHash` and cannot run in the Next.js Edge runtime or middleware. Call it from a Node.js runtime route/handler instead. + +> **Headers:** Only pass headers that actually affect the GraphQL response (for example locale, DatoCMS environment, or draft-mode credentials). Passing the full `request.headers` object hashes cookies, `user-agent`, tracing headers, and other per-request values, which produces a unique query ID on every request and explodes the tag store. + #### 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. +Custom providers can reuse the shared error-handling base class: + +```typescript +import { AbstractErrorHandlingCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/base'; +``` + ##### Neon (Postgres) Provider Use Neon serverless Postgres to store cache tag mappings. @@ -85,8 +109,9 @@ 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 + throwOnError: false, // Optional: Disable error throwing, defaults to `true` + onError(error, ctx) { + // Optional: Custom error callback console.error('CacheTagsProvider error', { error, context: ctx }); }, }); @@ -97,7 +122,7 @@ await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); // Find queries that reference specific tags const queries = await provider.queriesReferencingCacheTags(['item:42']); -// Delete specific cache tags +// Delete specific cache tags (also removes all registrations for affected queries) await provider.deleteCacheTags(['item:42']); // Clear all cache tags @@ -108,6 +133,8 @@ await provider.truncateCacheTags(); Use Redis to store cache tag mappings with better performance for high-traffic applications. +> **Redis Cluster:** The provider uses multi-key commands (`SUNION`, multi-key `DEL`/`UNLINK`). These fail with `CROSSSLOT` under Redis Cluster when keys hash to different slots. Upstash and single-node Redis are supported. + **Setup:** 1. Install [ioredis](https://github.com/redis/ioredis) @@ -123,7 +150,10 @@ 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 + // Strongly recommended: namespaces keys so truncateCacheTags() only deletes this provider's data + keyPrefix: 'prod:', + // Optional: expire keys as a safety net against unbounded growth + ttlSeconds: 60 * 60 * 24 * 30, throwOnError: process.env.NODE_ENV === 'development', // Optional: Disable error throwing in production - defaults to `true` }); @@ -132,6 +162,22 @@ await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); const queries = await provider.queriesReferencingCacheTags(['item:42']); await provider.deleteCacheTags(['item:42']); await provider.truncateCacheTags(); + +// Close the connection when the provider created it +await provider.dispose(); +``` + +You can also inject an existing ioredis client (useful in Next.js to share one connection). In that case `dispose()` does not close the client: + +```typescript +import Redis from 'ioredis'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; + +const redis = new Redis(process.env.REDIS_URL!); +const provider = new RedisCacheTagsProvider({ + client: redis, + keyPrefix: 'prod:', +}); ``` **Redis connection string examples:** @@ -149,12 +195,13 @@ REDIS_URL=redis://localhost:6379 #### `CacheTagsProvider` Interface -Both providers implement: +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) +- `deleteCacheTags(cacheTags: CacheTag[])`: Delete specific cache tags and all query registrations that reference them; returns the number of `(query, tag)` registrations removed +- `truncateCacheTags()`: Wipe all cache tags (use with caution; Redis requires a non-empty `keyPrefix`) +- `dispose?()`: Optional cleanup for owned resources (Redis closes its connection when it created one) ### Complete Example @@ -167,9 +214,12 @@ const provider = new RedisCacheTagsProvider({ keyPrefix: 'myapp:', }); -// After making a DatoCMS query -const queryId = generateQueryId(document, variables, request.headers); -const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']); +// After making a DatoCMS query — only hash headers that affect the response +const queryId = generateQueryId(document, variables, { + authorization: request.headers.get('authorization') ?? '', + 'x-environment': request.headers.get('x-environment') ?? '', +}); +const cacheTags = parseXCacheTagsResponseHeader(response.headers.get('x-cache-tags')); await provider.storeQueryCacheTags(queryId, cacheTags); // When handling DatoCMS webhook for cache invalidation diff --git a/eslint.config.mjs b/eslint.config.mjs index 8aaf6da..6f0ea4a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,11 @@ import { config } from '@smartive/eslint-config'; +import tseslint from 'typescript-eslint'; -export default config('typescript'); +export default tseslint.config(...config('typescript'), { + files: ['test/**/*.ts'], + extends: [tseslint.configs.disableTypeChecked], + rules: { + // Tests exercise the compiled package; dist/ is produced by `npm run build` before `npm test`. + 'import/no-unresolved': ['error', { ignore: ['^\\.\\./dist/'] }], + }, +}); diff --git a/package-lock.json b/package-lock.json index 02f4321..72c58a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,18 +13,21 @@ "@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": "16.14.2", + "graphql": "17.0.2", "ioredis": "5.11.1", "prettier": "3.9.6", + "publint": "0.3.22", "rimraf": "6.1.3", "typescript": "5.9.3" }, + "engines": { + "node": ">=20" + }, "peerDependencies": { "@neondatabase/serverless": "^1.0.0", - "graphql": "^15.0.0 || ^16.0.0", + "graphql": "^15.0.0 || ^16.0.0 || ^17.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { @@ -694,6 +697,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@publint/pack": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.6.tgz", + "integrity": "sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyexec": "^1.2.4" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -788,18 +807,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.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -3063,13 +3070,13 @@ } }, "node_modules/graphql": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", - "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "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": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0" } }, "node_modules/has-bigints": { @@ -3891,6 +3898,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4155,6 +4172,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4222,40 +4246,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.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "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", @@ -4286,49 +4276,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", @@ -4380,6 +4327,28 @@ "react-is": "^16.13.1" } }, + "node_modules/publint": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.22.tgz", + "integrity": "sha512-6Z/scsr5CA7APdwyF35EY88CqgDj1textWuY788DVTJYPCWVv/Wn9G6KmLnrVRnStgYcahqN4wCDLZGSbQJ69w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.6", + "package-manager-detector": "^1.7.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4525,6 +4494,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -4935,6 +4917,16 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5345,16 +5337,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 4503203..60f8dc1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "A set of utilities and helpers to work with DatoCMS in a Next.js project.", "type": "module", "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "sideEffects": false, "exports": { ".": { "types": "./dist/index.d.ts", @@ -13,6 +15,14 @@ "types": "./dist/cache-tags/index.d.ts", "import": "./dist/cache-tags/index.js" }, + "./cache-tags/header": { + "types": "./dist/cache-tags/header.d.ts", + "import": "./dist/cache-tags/header.js" + }, + "./cache-tags/base": { + "types": "./dist/cache-tags/provider/base.d.ts", + "import": "./dist/cache-tags/provider/base.js" + }, "./cache-tags/redis": { "types": "./dist/cache-tags/provider/redis.d.ts", "import": "./dist/cache-tags/provider/redis.js" @@ -27,15 +37,20 @@ } }, "files": [ - "dist/**/*", - "src/**/*" + "dist" ], "scripts": { "clean": "rimraf dist", "prebuild": "npm run clean", "build": "tsc", - "lint": "eslint src", - "prettier": "prettier --check src" + "prepack": "npm run build", + "lint": "eslint src test", + "prettier": "prettier --check src test", + "typecheck": "npm run build && tsc -p tsconfig.test.json --noEmit", + "test": "npm run build && node --test --experimental-strip-types --experimental-test-module-mocks 'test/**/*.test.ts'", + "pack:check": "npm pack --dry-run", + "publint": "publint", + "check": "npm run prettier && npm run lint && npm run typecheck && npm run test && npm run publint && npm run pack:check" }, "publishConfig": { "access": "public" @@ -49,23 +64,26 @@ "url": "git+https://github.com/smartive/datocms-utils.git", "type": "git" }, + "engines": { + "node": ">=20" + }, "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": "16.14.2", + "graphql": "17.0.2", "ioredis": "5.11.1", "prettier": "3.9.6", + "publint": "0.3.22", "rimraf": "6.1.3", "typescript": "5.9.3" }, "peerDependencies": { "@neondatabase/serverless": "^1.0.0", - "graphql": "^15.0.0 || ^16.0.0", + "graphql": "^15.0.0 || ^16.0.0 || ^17.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { diff --git a/src/cache-tags/header.ts b/src/cache-tags/header.ts new file mode 100644 index 0000000..d765f9b --- /dev/null +++ b/src/cache-tags/header.ts @@ -0,0 +1,11 @@ +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(/\s+/).filter(Boolean) ?? []).map((tag) => tag as CacheTag); diff --git a/src/cache-tags/index.ts b/src/cache-tags/index.ts index 920534d..53564de 100644 --- a/src/cache-tags/index.ts +++ b/src/cache-tags/index.ts @@ -1,2 +1,3 @@ export * from './types.js'; -export * from './utils.js'; +export * from './header.js'; +export * from './query-id.js'; diff --git a/src/cache-tags/provider/neon.ts b/src/cache-tags/provider/neon.ts index f49e5e6..3fa01fc 100644 --- a/src/cache-tags/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -46,10 +46,13 @@ export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvide return; } - const tags = cacheTags.flatMap((_, i) => [queryId, cacheTags[i]]); + const tags = cacheTags.flatMap((tag) => [queryId, tag]); 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); + await this.sql.query( + `INSERT INTO ${this.table} (query_id, cache_tag) VALUES ${placeholders} ON CONFLICT DO NOTHING`, + tags, + ); }, undefined, ); @@ -64,12 +67,9 @@ export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvide 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})`, + const { rows } = await this.sql.query(`SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag = ANY($1)`, [ cacheTags, - ); + ]); return rows.reduce((queryIds, row) => { if (typeof row.query_id === 'string') { @@ -91,10 +91,14 @@ export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvide 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 + ( + await this.sql.query( + `DELETE FROM ${this.table} WHERE query_id IN (SELECT query_id FROM ${this.table} WHERE cache_tag = ANY($1))`, + [cacheTags], + ) + ).rowCount ?? 0 ); }, 0, diff --git a/src/cache-tags/provider/noop.ts b/src/cache-tags/provider/noop.ts index 6c8990e..3896090 100644 --- a/src/cache-tags/provider/noop.ts +++ b/src/cache-tags/provider/noop.ts @@ -1,31 +1,52 @@ import { type CacheTag, type CacheTagsProvider } from '../types.js'; +export type NoopCacheTagsProviderConfig = { + /** + * When true (default), logs each method call via `console.debug`. + */ + readonly log?: boolean; +}; + /** * 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 { + private readonly log: boolean; + + constructor({ log = true }: NoopCacheTagsProviderConfig = {}) { + this.log = log; + } + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { - console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); + if (this.log) { + console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); + } return Promise.resolve(); } public async queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise { - console.debug('-- queriesReferencingCacheTags called', { cacheTags }); + if (this.log) { + console.debug('-- queriesReferencingCacheTags called', { cacheTags }); + } return Promise.resolve([]); } public async deleteCacheTags(cacheTags: CacheTag[]) { - console.debug('-- deleteCacheTags called', { cacheTags }); + if (this.log) { + console.debug('-- deleteCacheTags called', { cacheTags }); + } return Promise.resolve(0); } public async truncateCacheTags() { - console.debug('-- truncateCacheTags called'); + if (this.log) { + console.debug('-- truncateCacheTags called'); + } return Promise.resolve(0); } diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index 78e2e79..0482612 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -1,36 +1,102 @@ -import { Redis } from 'ioredis'; +import { Redis, type RedisOptions } from 'ioredis'; import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js'; import { AbstractErrorHandlingCacheTagsProvider } from './base.js'; -type RedisCacheTagsProviderBaseConfig = { +type RedisCacheTagsProviderSharedConfig = { + /** + * Optional prefix for Redis keys. All keys used to store cache tags are prefixed with this value. + * This avoids key collisions if the same Redis instance is used for multiple purposes, and ensures + * `truncateCacheTags()` only deletes keys owned by this provider. + * For example, if you set `keyPrefix` to `'myapp:'`, a cache tag like `'tag1'` will be stored under the key `'myapp:tag1'`. + * + * @deprecated Omitting `keyPrefix` (or passing an empty string) is deprecated. A non-empty prefix + * is strongly recommended; `truncateCacheTags()` will throw without one. + */ + readonly keyPrefix?: string; + /** + * Optional TTL in seconds applied to tag and reverse-index keys on write. + * Useful as a safety net against unbounded growth of stale registrations. + */ + readonly ttlSeconds?: number; +}; + +type RedisCacheTagsProviderConnectionConfig = RedisCacheTagsProviderSharedConfig & { /** * 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'`. + * Optional ioredis options merged into the default client options when creating a new connection. */ - readonly keyPrefix?: string; + readonly redisOptions?: RedisOptions; + readonly client?: never; +}; + +type RedisCacheTagsProviderClientConfig = RedisCacheTagsProviderSharedConfig & { + /** + * An existing ioredis client. When provided, the provider does not create or close the connection; + * call `dispose()` is a no-op for the Redis connection in this case. + */ + readonly client: Redis; + readonly connectionUrl?: never; + readonly redisOptions?: never; }; -export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig; +export type RedisCacheTagsProviderConfig = (RedisCacheTagsProviderConnectionConfig | RedisCacheTagsProviderClientConfig) & + CacheTagsProviderErrorHandlingConfig; + +const DELETE_BATCH_SIZE = 1000; +const QUERY_KEY_MARKER = '\0query:'; /** * A `CacheTagsProvider` implementation that uses Redis as the storage backend. */ export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider { - private readonly redis; - private readonly keyPrefix; + private readonly redis: Redis; + private readonly ownsClient: boolean; + private readonly keyPrefix: string; + private readonly ttlSeconds?: number; - constructor({ connectionUrl, keyPrefix, throwOnError, onError }: RedisCacheTagsProviderConfig) { + constructor(config: RedisCacheTagsProviderConfig) { + const { keyPrefix, ttlSeconds, throwOnError, onError } = config; super('RedisCacheTagsProvider', { throwOnError, onError }); - this.redis = new Redis(connectionUrl, { - maxRetriesPerRequest: 3, - lazyConnect: true, - }); + this.keyPrefix = keyPrefix ?? ''; + this.ttlSeconds = ttlSeconds; + + if (this.keyPrefix.length === 0) { + console.warn( + 'RedisCacheTagsProvider: omitting keyPrefix (or using an empty string) is deprecated. ' + + 'Provide a non-empty keyPrefix so truncateCacheTags() only deletes keys owned by this provider.', + ); + } + + if ('client' in config && config.client) { + this.redis = config.client; + this.ownsClient = false; + } else { + this.redis = new Redis(config.connectionUrl, { + maxRetriesPerRequest: 3, + lazyConnect: true, + ...config.redisOptions, + }); + this.ownsClient = true; + } + + this.redis.on('error', (error) => { + try { + this.onError?.(error, { provider: this.providerName, method: 'dispose', args: [] }); + } catch (handlerError) { + console.error(`Error handler itself failed in ${this.providerName} error listener.`, { handlerError }); + } + }); + + if (typeof Symbol.asyncDispose === 'symbol') { + Object.defineProperty(this, Symbol.asyncDispose, { + value: () => this.dispose(), + configurable: true, + }); + } } public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { @@ -43,9 +109,19 @@ export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvid } const pipeline = this.redis.pipeline(); + const queryKey = this.queryKey(queryId); for (const tag of cacheTags) { - pipeline.sadd(`${this.keyPrefix}${tag}`, queryId); + const tagKey = this.tagKey(tag); + pipeline.sadd(tagKey, queryId); + pipeline.sadd(queryKey, tag); + if (this.ttlSeconds !== undefined) { + pipeline.expire(tagKey, this.ttlSeconds); + } + } + + if (this.ttlSeconds !== undefined) { + pipeline.expire(queryKey, this.ttlSeconds); } const results = await pipeline.exec(); @@ -67,7 +143,7 @@ export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvid return []; } - const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); + const keys = cacheTags.map((tag) => this.tagKey(tag)); return this.redis.sunion(...keys); }, @@ -84,9 +160,59 @@ export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvid return 0; } - const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); + // Read-then-write race: concurrent storeQueryCacheTags can re-add members between + // SUNION/SMEMBERS and the cleanup pipeline. A Lua script would make this atomic if needed. + const tagKeys = cacheTags.map((tag) => this.tagKey(tag)); + const queryIds = await this.redis.sunion(...tagKeys); + + if (queryIds.length === 0) { + return this.redis.del(...tagKeys); + } - return this.redis.del(...keys); + const reversePipeline = this.redis.pipeline(); + for (const queryId of queryIds) { + reversePipeline.smembers(this.queryKey(queryId)); + } + const reverseResults = await reversePipeline.exec(); + const reverseError = reverseResults?.find(([err]) => err)?.[0]; + if (reverseError) { + throw reverseError; + } + + const allTags = new Set(cacheTags); + for (const result of reverseResults ?? []) { + const members = (result?.[1] as string[] | undefined) ?? []; + for (const tag of members) { + allTags.add(tag); + } + } + + const cleanupPipeline = this.redis.pipeline(); + for (const queryId of queryIds) { + for (const tag of allTags) { + cleanupPipeline.srem(this.tagKey(tag), queryId); + } + cleanupPipeline.del(this.queryKey(queryId)); + } + for (const tag of cacheTags) { + cleanupPipeline.del(this.tagKey(tag)); + } + + const cleanupResults = await cleanupPipeline.exec(); + const cleanupError = cleanupResults?.find(([err]) => err)?.[0]; + if (cleanupError) { + throw cleanupError; + } + + // Count (query, tag) registrations removed. Legacy entries without a reverse index + // only lose the tags being deleted explicitly. + let removed = 0; + for (const result of reverseResults ?? []) { + const members = (result?.[1] as string[] | undefined) ?? []; + removed += members.length > 0 ? members.length : cacheTags.length; + } + + return removed; }, 0, ); @@ -97,18 +223,59 @@ export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvid 'truncateCacheTags', [], async () => { + if (this.keyPrefix.length === 0) { + throw new Error( + 'RedisCacheTagsProvider.truncateCacheTags() requires a non-empty keyPrefix to avoid deleting unrelated Redis keys.', + ); + } + const keys = await this.getKeys(); if (keys.length === 0) { return 0; } - return await this.redis.del(...keys); + return this.deleteKeysInBatches(keys); }, 0, ); } + /** + * Closes the Redis connection when this provider created it. + * No-op when an external `client` was injected. + */ + public async dispose(): Promise { + if (!this.ownsClient) { + return; + } + + await this.redis.quit(); + } + + private tagKey(tag: string): string { + return `${this.keyPrefix}${tag}`; + } + + private queryKey(queryId: string): string { + return `${this.keyPrefix}${QUERY_KEY_MARKER}${queryId}`; + } + + private async deleteKeysInBatches(keys: string[]): Promise { + let deleted = 0; + + for (let i = 0; i < keys.length; i += DELETE_BATCH_SIZE) { + const batch = keys.slice(i, i + DELETE_BATCH_SIZE); + try { + deleted += await this.redis.unlink(...batch); + } catch { + deleted += await this.redis.del(...batch); + } + } + + return deleted; + } + /** * 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. diff --git a/src/cache-tags/query-id.ts b/src/cache-tags/query-id.ts new file mode 100644 index 0000000..4bf6b43 --- /dev/null +++ b/src/cache-tags/query-id.ts @@ -0,0 +1,57 @@ +import { print, type DocumentNode } from 'graphql'; +import { createHash } from 'node:crypto'; + +type HeadersInit = NonNullable[0]>; + +/** + * Generates a unique query ID based on the query document, its variables, and optional HTTP headers. + * + * Uses Node.js `crypto.createHash` and therefore cannot run in the Next.js Edge runtime or middleware. + * + * @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('document:') + .update(print(document)) + .update('\0variables:') + .update(stableStringify(variables)) + .update('\0headers:') + .update(normalizeHeaders(headers)) + .digest('hex'); +}; + +const stableStringify = (value: unknown): string => { + if (value === undefined) { + return ''; + } + + return JSON.stringify(value, (_key, nestedValue: unknown) => { + if (nestedValue && typeof nestedValue === 'object' && !Array.isArray(nestedValue)) { + const record = nestedValue as Record; + + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, record[key]]), + ); + } + + return nestedValue; + }); +}; + +const normalizeHeaders = (headers?: HeadersInit): string => { + if (headers === undefined) { + return ''; + } + + return JSON.stringify([...new Headers(headers).entries()].sort(([a], [b]) => a.localeCompare(b))); +}; diff --git a/src/cache-tags/types.ts b/src/cache-tags/types.ts index b874c7b..6e60044 100644 --- a/src/cache-tags/types.ts +++ b/src/cache-tags/types.ts @@ -46,13 +46,12 @@ export interface CacheTagsProvider { queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise; /** - * Deletes the specified cache tags. + * Deletes the specified cache tags and all query registrations that reference them. * - * This removes the cache tag keys entirely. When queries are revalidated and - * run again, fresh cache tag mappings will be created. + * 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 + * @returns Number of (query, tag) registrations removed * */ deleteCacheTags(cacheTags: CacheTag[]): Promise; @@ -61,8 +60,15 @@ export interface CacheTagsProvider { * Wipes out all cache tags. * * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! + * + * @returns Number of (query, tag) registrations removed (or keys deleted, depending on the backend) */ truncateCacheTags(): Promise; + + /** + * Optional cleanup hook for providers that own external resources (e.g. Redis connections). + */ + dispose?(): Promise; } export type CacheTagsProviderErrorHandlingConfig = { diff --git a/src/cache-tags/utils.ts b/src/cache-tags/utils.ts index f12bd5f..a5a845d 100644 --- a/src/cache-tags/utils.ts +++ b/src/cache-tags/utils.ts @@ -1,33 +1,2 @@ -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'); -}; +export { parseXCacheTagsResponseHeader } from './header.js'; +export { generateQueryId } from './query-id.js'; diff --git a/src/links.ts b/src/links.ts index 343c6ce..64a0e8b 100644 --- a/src/links.ts +++ b/src/links.ts @@ -9,8 +9,10 @@ export const getTelLink = (phoneNumber: string): string => { throw new Error('Phone number must be a string.'); } - // Remove non-digit characters except for '+' which is used for international numbers - const cleanedPhoneNumber = phoneNumber.replace(/[^\d+]/g, ''); + const trimmed = phoneNumber.trim(); + const hasLeadingPlus = trimmed.startsWith('+'); + // Preserve only a leading '+'; strip all other non-digit characters. + const digits = trimmed.replace(/\D/g, ''); - return `tel:${cleanedPhoneNumber}`; + return `tel:${hasLeadingPlus ? `+${digits}` : digits}`; }; diff --git a/test/base-provider.test.ts b/test/base-provider.test.ts new file mode 100644 index 0000000..f2614f2 --- /dev/null +++ b/test/base-provider.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AbstractErrorHandlingCacheTagsProvider } from '../dist/cache-tags/provider/base.js'; +import type { CacheTag, CacheTagsProviderErrorHandlingConfig } from '../dist/cache-tags/types.js'; + +class FlakyProvider extends AbstractErrorHandlingCacheTagsProvider { + public fail = true; + + constructor(config: CacheTagsProviderErrorHandlingConfig = {}) { + super('FlakyProvider', config); + } + + public async storeQueryCacheTags(...args: [string, CacheTag[]]) { + return this.wrap( + 'storeQueryCacheTags', + args, + async () => { + if (this.fail) { + throw new Error('boom'); + } + }, + undefined, + ); + } + + public async queriesReferencingCacheTags(...args: [CacheTag[]]) { + return this.wrap( + 'queriesReferencingCacheTags', + args, + async () => { + throw new Error('boom'); + }, + [], + ); + } + + public async deleteCacheTags(...args: [CacheTag[]]) { + return this.wrap( + 'deleteCacheTags', + args, + async () => { + throw new Error('boom'); + }, + 0, + ); + } + + public async truncateCacheTags() { + return this.wrap( + 'truncateCacheTags', + [], + async () => { + throw new Error('boom'); + }, + 0, + ); + } +} + +describe('AbstractErrorHandlingCacheTagsProvider', () => { + it('rethrows by default and still invokes onError', async () => { + const events: unknown[] = []; + const provider = new FlakyProvider({ + onError: (error, ctx) => { + events.push({ error, ctx }); + }, + }); + + await assert.rejects(() => provider.storeQueryCacheTags('q', []), /boom/); + assert.equal(events.length, 1); + }); + + it('returns the fallback when throwOnError is false', async () => { + const provider = new FlakyProvider({ throwOnError: false }); + + assert.deepEqual(await provider.queriesReferencingCacheTags([]), []); + assert.equal(await provider.deleteCacheTags([]), 0); + assert.equal(await provider.truncateCacheTags(), 0); + }); + + it('does not mask the original error when onError itself throws', async () => { + const provider = new FlakyProvider({ + onError: () => { + throw new Error('handler failed'); + }, + }); + + await assert.rejects(() => provider.storeQueryCacheTags('q', []), /boom/); + }); +}); diff --git a/test/cache-tags-utils.test.ts b/test/cache-tags-utils.test.ts new file mode 100644 index 0000000..9ab9878 --- /dev/null +++ b/test/cache-tags-utils.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parse } from 'graphql'; +import { generateQueryId, parseXCacheTagsResponseHeader } from '../dist/cache-tags/utils.js'; + +const document = parse('{ item { id } }'); + +describe('parseXCacheTagsResponseHeader', () => { + it('parses space-delimited tags', () => { + assert.deepEqual(parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'), ['tag-a', 'tag-2', 'other-tag']); + }); + + it('filters empty tags from repeated whitespace', () => { + assert.deepEqual(parseXCacheTagsResponseHeader('tag-a tag-b'), ['tag-a', 'tag-b']); + assert.deepEqual(parseXCacheTagsResponseHeader('tag-a '), ['tag-a']); + assert.deepEqual(parseXCacheTagsResponseHeader(''), []); + }); + + it('returns an empty array for nullish input', () => { + assert.deepEqual(parseXCacheTagsResponseHeader(null), []); + assert.deepEqual(parseXCacheTagsResponseHeader(undefined), []); + }); +}); + +describe('generateQueryId', () => { + it('produces the same ID for equivalent variable key orders', () => { + const left = generateQueryId(document, { a: 1, nested: { b: 2, c: 3 } }); + const right = generateQueryId(document, { nested: { c: 3, b: 2 }, a: 1 }); + + assert.equal(left, right); + }); + + it('normalizes Headers, record, and tuple header inputs', () => { + const fromHeaders = generateQueryId( + document, + undefined, + new Headers([ + ['Authorization', 'Bearer token'], + ['X-Locale', 'en'], + ]), + ); + const fromRecord = generateQueryId(document, undefined, { + 'x-locale': 'en', + authorization: 'Bearer token', + }); + const fromTuples = generateQueryId(document, undefined, [ + ['X-Locale', 'en'], + ['Authorization', 'Bearer token'], + ]); + + assert.equal(fromHeaders, fromRecord); + assert.equal(fromHeaders, fromTuples); + }); + + it('changes the ID when authorization headers differ', () => { + const left = generateQueryId(document, undefined, new Headers({ Authorization: 'Bearer abc' })); + const right = generateQueryId(document, undefined, new Headers({ Authorization: 'Bearer xyz' })); + const empty = generateQueryId(document, undefined, new Headers()); + + assert.notEqual(left, right); + assert.notEqual(left, empty); + }); + + it('frames document, variables, and headers to avoid concatenation collisions', () => { + const left = generateQueryId(document, { value: 'a' }, { 'x-test': 'bc' }); + const right = generateQueryId(document, { value: 'ab' }, { 'x-test': 'c' }); + + assert.notEqual(left, right); + }); +}); diff --git a/test/classnames.test.ts b/test/classnames.test.ts new file mode 100644 index 0000000..5efd1d6 --- /dev/null +++ b/test/classnames.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { classNames } from '../dist/classnames.js'; + +describe('classNames', () => { + it('joins strings and finite numbers, filtering falsy and non-finite values', () => { + const inactive = false; + const active = true; + assert.equal(classNames('btn', inactive && 'btn-active', undefined, null, 'btn-primary'), 'btn btn-primary'); + assert.equal(classNames('btn', active && 'btn-active', 42), 'btn btn-active 42'); + assert.equal(classNames('', 'ok', Number.NaN, Number.POSITIVE_INFINITY), 'ok'); + }); + + it('keeps the number 0 (so `count && class` emits a literal 0 class)', () => { + const count = 0 as number; + assert.equal(classNames(count && 'badge', 'item'), '0 item'); + }); +}); diff --git a/test/links.test.ts b/test/links.test.ts new file mode 100644 index 0000000..95f8279 --- /dev/null +++ b/test/links.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getTelLink } from '../dist/links.js'; + +describe('getTelLink', () => { + it('keeps a leading plus and strips other non-digits', () => { + assert.equal(getTelLink('+1 (555) 123-4567'), 'tel:+15551234567'); + }); + + it('strips a plus that is not leading', () => { + assert.equal(getTelLink('(0)+41'), 'tel:041'); + }); + + it('rejects non-string input', () => { + assert.throws(() => getTelLink(42 as unknown as string), /must be a string/); + }); +}); diff --git a/test/neon-provider.test.ts b/test/neon-provider.test.ts new file mode 100644 index 0000000..55f6a86 --- /dev/null +++ b/test/neon-provider.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import type { CacheTag } from '../dist/cache-tags/types.js'; + +describe('NeonCacheTagsProvider', () => { + it('rejects invalid table names', async () => { + const { NeonCacheTagsProvider } = await import('../dist/cache-tags/provider/neon.js'); + + assert.throws( + () => + new NeonCacheTagsProvider({ + connectionUrl: 'postgresql://user:pass@host/db', + table: 'bad;drop table', + }), + /Invalid table name/, + ); + }); + + it('names columns, uses ANY($1), and deletes by affected query_id', async () => { + const queries: { sql: string; params: unknown[] }[] = []; + + mock.module('@neondatabase/serverless', { + // @types/node still types this as namedExports; Node prefers options.exports. + namedExports: { + neon: () => ({ + query: async (sql: string, params: unknown[] = []) => { + queries.push({ sql, params }); + + if (sql.startsWith('SELECT')) { + return { rows: [{ query_id: 'q1' }], rowCount: 1 }; + } + + return { rows: [], rowCount: 2 }; + }, + }), + }, + }); + + const { NeonCacheTagsProvider } = await import(`../dist/cache-tags/provider/neon.js?t=${Date.now()}`); + const provider = new NeonCacheTagsProvider({ + connectionUrl: 'postgresql://user:pass@host/db', + table: 'query_cache_tags', + }); + + const tags = ['item:42', 'product'] as CacheTag[]; + await provider.storeQueryCacheTags('q1', tags); + await provider.queriesReferencingCacheTags(tags); + await provider.deleteCacheTags(tags); + + assert.match(queries[0].sql, /INSERT INTO "query_cache_tags" \(query_id, cache_tag\) VALUES/); + assert.deepEqual(queries[0].params, ['q1', 'item:42', 'q1', 'product']); + + assert.match(queries[1].sql, /cache_tag = ANY\(\$1\)/); + assert.deepEqual(queries[1].params, [tags]); + + assert.match( + queries[2].sql, + /DELETE FROM "query_cache_tags" WHERE query_id IN \(SELECT query_id FROM "query_cache_tags" WHERE cache_tag = ANY\(\$1\)\)/, + ); + assert.deepEqual(queries[2].params, [tags]); + }); +}); diff --git a/test/noop-provider.test.ts b/test/noop-provider.test.ts new file mode 100644 index 0000000..3325e7f --- /dev/null +++ b/test/noop-provider.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { NoopCacheTagsProvider } from '../dist/cache-tags/provider/noop.js'; +import type { CacheTag } from '../dist/cache-tags/types.js'; + +describe('NoopCacheTagsProvider', () => { + it('returns empty/zero results without logging when log is false', async () => { + const originalDebug = console.debug; + const calls: unknown[][] = []; + console.debug = (...args: unknown[]) => { + calls.push(args); + }; + + try { + const provider = new NoopCacheTagsProvider({ log: false }); + const tag = 'tag-a' as CacheTag; + + await provider.storeQueryCacheTags('q1', [tag]); + assert.deepEqual(await provider.queriesReferencingCacheTags([tag]), []); + assert.equal(await provider.deleteCacheTags([tag]), 0); + assert.equal(await provider.truncateCacheTags(), 0); + assert.equal(calls.length, 0); + } finally { + console.debug = originalDebug; + } + }); +}); diff --git a/test/redis-provider.test.ts b/test/redis-provider.test.ts new file mode 100644 index 0000000..1c63f3b --- /dev/null +++ b/test/redis-provider.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { RedisCacheTagsProvider } from '../dist/cache-tags/provider/redis.js'; + +describe('RedisCacheTagsProvider', () => { + it('allows a missing keyPrefix but rejects truncateCacheTags without one', async () => { + const provider = new RedisCacheTagsProvider({ + connectionUrl: 'redis://localhost:6379', + keyPrefix: undefined, + throwOnError: true, + }); + + await assert.rejects(() => provider.truncateCacheTags(), /non-empty keyPrefix/); + await provider.dispose(); + }); + + it('allows an empty keyPrefix but rejects truncateCacheTags without one', async () => { + const provider = new RedisCacheTagsProvider({ + connectionUrl: 'redis://localhost:6379', + keyPrefix: '', + throwOnError: true, + }); + + await assert.rejects(() => provider.truncateCacheTags(), /non-empty keyPrefix/); + await provider.dispose(); + }); + + it('dispose is a no-op for an injected client', async () => { + const calls: string[] = []; + const fakeClient = { + on() { + return this; + }, + quit: async () => { + calls.push('quit'); + + return 'OK'; + }, + pipeline: () => { + throw new Error('not used'); + }, + }; + + const provider = new RedisCacheTagsProvider({ + client: fakeClient as never, + keyPrefix: 'test:', + }); + + await provider.dispose(); + assert.deepEqual(calls, []); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 829de75..144685b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,9 @@ "compilerOptions": { "declaration": true, "outDir": "./dist/", - "rootDirs": ["./src"], + "rootDir": "./src", "sourceMap": true, + "inlineSources": true, "strict": true, "noImplicitReturns": true, "noImplicitAny": true, @@ -12,7 +13,8 @@ "allowJs": true, "resolveJsonModule": true, "module": "nodenext", - "target": "esnext" + "target": "es2022", + "lib": ["es2022", "esnext.disposable"] }, "include": ["./src/**/*"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..8596ecb --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "declaration": false, + "sourceMap": false, + "inlineSources": false + }, + "include": ["./src/**/*", "./test/**/*"] +}