From 94901b13c40f17fd0d1df9189008fcef20588e25 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Thu, 12 Feb 2026 23:42:16 +0100 Subject: [PATCH 01/26] chore: add support for pre-releases --- .releaserc.json | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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" From 098a08bf4805947e160da8ed139fe7a303c2842c Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Thu, 12 Feb 2026 23:44:40 +0100 Subject: [PATCH 02/26] chore: run pipeline on branch --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad21096..d69bc73 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 From adbabf8855a0244c25fef21241efb8a02f724c59 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Thu, 12 Feb 2026 23:53:19 +0100 Subject: [PATCH 03/26] chore: also install conventional-changelog-conventionalcommits --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d69bc73..416ba5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,5 +30,6 @@ jobs: semantic_version: 25 extra_plugins: | @semantic-release/changelog@6 + conventional-changelog-conventionalcommits@9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 719861b8dacbc309f9a6c46d4504c8a5a53bec79 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 01:06:31 +0100 Subject: [PATCH 04/26] feat!: refactor cache implementation to support multiple backends and switch to ESM modules Please note: cache tag functionality has been rewritten and is now available as a separate export: @smartive/datocms-utils/cache. The new implementation supports multiple backends, including Redis and Postgres, and is designed to be more flexible and extensible. Please refer to the updated documentation for details on how to use the new cache functionality. --- package-lock.json | 191 ++++++++++++++++++++++++++++++++++-- package.json | 33 +++++-- src/cache-tags-redis.ts | 96 ------------------ src/cache-tags.ts | 88 ----------------- src/cache/index.ts | 4 + src/cache/store/postgres.ts | 48 +++++++++ src/cache/store/redis.ts | 61 ++++++++++++ src/cache/types.ts | 63 ++++++++++++ src/{ => cache}/utils.ts | 2 +- src/index.ts | 6 +- src/types.ts | 24 ----- tsconfig.json | 4 +- 12 files changed, 390 insertions(+), 230 deletions(-) delete mode 100644 src/cache-tags-redis.ts delete mode 100644 src/cache-tags.ts create mode 100644 src/cache/index.ts create mode 100644 src/cache/store/postgres.ts create mode 100644 src/cache/store/redis.ts create mode 100644 src/cache/types.ts rename src/{ => cache}/utils.ts (95%) delete mode 100644 src/types.ts diff --git a/package-lock.json b/package-lock.json index 88d049e..109f720 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": { "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.10.12", + "@vercel/postgres": "0.10.0", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", + "graphql": "16.12.0", "ioredis": "5.9.2", "prettier": "3.8.1", + "rimraf": "6.1.2", "typescript": "5.9.3" }, "peerDependencies": { + "@vercel/postgres": "^0.10.0", + "graphql": "^15.0.0 || ^16.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { + "@vercel/postgres": { + "optional": true + }, + "graphql": { + "optional": true + }, "ioredis": { "optional": true } @@ -587,6 +594,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", @@ -654,6 +671,7 @@ "version": "0.9.5", "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.9.5.tgz", "integrity": "sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==", + "dev": true, "license": "MIT", "dependencies": { "@types/pg": "8.11.6" @@ -795,6 +813,7 @@ "version": "24.10.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -804,6 +823,7 @@ "version": "8.11.6", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1355,6 +1375,8 @@ "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", + "dev": true, "license": "Apache-2.0", "dependencies": { "@neondatabase/serverless": "^0.9.3", @@ -1687,6 +1709,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2941,6 +2964,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", @@ -2954,6 +2995,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", @@ -3002,9 +3085,10 @@ "dev": true }, "node_modules/graphql": { - "version": "16.11.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", - "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "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" @@ -3620,6 +3704,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", @@ -3773,6 +3873,16 @@ "loose-envify": "cli.js" } }, + "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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3829,6 +3939,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", @@ -3863,6 +3983,7 @@ "version": "4.8.2", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", + "dev": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -4001,6 +4122,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, "license": "MIT" }, "node_modules/optionator": { @@ -4071,6 +4193,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", @@ -4110,10 +4239,28 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "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/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" @@ -4123,6 +4270,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, "license": "ISC", "engines": { "node": ">=4" @@ -4132,12 +4280,14 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "dev": true, "license": "MIT" }, "node_modules/pg-types": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, "license": "MIT", "dependencies": { "pg-int8": "1.0.1", @@ -4185,6 +4335,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4194,6 +4345,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, "license": "MIT", "dependencies": { "obuf": "~1.1.2" @@ -4206,6 +4358,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4215,6 +4368,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4224,6 +4378,7 @@ "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==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -4430,6 +4585,26 @@ "node": ">=0.10.0" } }, + "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/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5104,6 +5279,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": { @@ -5300,6 +5476,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index d161bc0..2adc4e4 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,23 @@ "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": { + "types": "./dist/cache/index.d.ts", + "import": "./dist/cache/index.js" + } + }, "files": [ "dist/**/*", "src/**/*" ], "scripts": { + "clean": "rimraf dist", + "prebuild": "npm run clean", "build": "tsc", "lint": "eslint src", "prettier": "prettier --check src" @@ -31,20 +41,27 @@ "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.10.12", + "@vercel/postgres": "0.10.0", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", + "graphql": "16.12.0", + "ioredis": "5.9.2", "prettier": "3.8.1", - "typescript": "5.9.3", - "ioredis": "5.9.2" - }, - "dependencies": { - "@vercel/postgres": "^0.10.0", - "graphql": "^16.9.0" + "rimraf": "6.1.2", + "typescript": "5.9.3" }, "peerDependencies": { + "@vercel/postgres": "^0.10.0", + "graphql": "^15.0.0 || ^16.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { + "@vercel/postgres": { + "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/index.ts b/src/cache/index.ts new file mode 100644 index 0000000..af88fc1 --- /dev/null +++ b/src/cache/index.ts @@ -0,0 +1,4 @@ +export * from './store/postgres.js'; +export * from './store/redis.js'; +export * from './types.js'; +export * from './utils.js'; diff --git a/src/cache/store/postgres.ts b/src/cache/store/postgres.ts new file mode 100644 index 0000000..0025fbc --- /dev/null +++ b/src/cache/store/postgres.ts @@ -0,0 +1,48 @@ +import { sql } from '@vercel/postgres'; +import { type CacheTag, type CacheTagsStore } from '../types.js'; + +export const createPostgresCacheTagsStore = ({ table }: { table: string }): CacheTagsStore => { + const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { + 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 ${table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); + }; + + const queriesReferencingCacheTags = async (cacheTags: CacheTag[]): 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 ${table} WHERE cache_tag IN (${placeholders})`, + cacheTags, + ); + + return rows.map((row) => row.query_id); + }; + + const deleteCacheTags = async (cacheTags: CacheTag[]) => { + if (cacheTags.length === 0) { + return 0; + } + const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); + + return (await sql.query(`DELETE FROM ${table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0; + }; + + const truncateCacheTags = async () => (await sql.query(`DELETE FROM ${table}`)).rowCount ?? 0; + + return { + storeQueryCacheTags, + queriesReferencingCacheTags, + deleteCacheTags, + truncateCacheTags, + }; +}; diff --git a/src/cache/store/redis.ts b/src/cache/store/redis.ts new file mode 100644 index 0000000..2eb898b --- /dev/null +++ b/src/cache/store/redis.ts @@ -0,0 +1,61 @@ +import { Redis } from 'ioredis'; +import { type CacheTag, type CacheTagsStore } from '../types.js'; + +export const createRedisCacheTagsStore = ({ url, keyPrefix = '' }: { url: string; keyPrefix?: string }): CacheTagsStore => { + const redis = new Redis(url, { + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + + const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { + if (!cacheTags?.length) { + return; + } + + const pipeline = redis.pipeline(); + + for (const tag of cacheTags) { + pipeline.sadd(`${keyPrefix}${tag}`, queryId); + } + + await pipeline.exec(); + }; + + const queriesReferencingCacheTags = async (cacheTags: CacheTag[]) => { + if (!cacheTags?.length) { + return []; + } + + const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); + + return redis.sunion(...keys); + }; + + const deleteCacheTags = async (cacheTags: CacheTag[]) => { + if (!cacheTags?.length) { + return 0; + } + + const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); + + return redis.del(...keys); + }; + + const truncateCacheTags = async () => { + const pattern = `${keyPrefix}*`; + const keys = await redis.keys(pattern); + + if (keys.length === 0) { + return 0; + } + + return await redis.del(...keys); + }; + + return { + storeQueryCacheTags, + queriesReferencingCacheTags, + deleteCacheTags, + truncateCacheTags, + }; +}; diff --git a/src/cache/types.ts b/src/cache/types.ts new file mode 100644 index 0000000..6764a6f --- /dev/null +++ b/src/cache/types.ts @@ -0,0 +1,63 @@ +/** + * 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[]; + }; + }; +}; + +export type CacheTagsStore = { + /** + * 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, or null if there was an error + * + */ + deleteCacheTags(cacheTags: CacheTag[]): Promise; + + /** + * Wipes out all cache tags. + * + * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! + */ + truncateCacheTags(): Promise; +}; diff --git a/src/utils.ts b/src/cache/utils.ts similarity index 95% rename from src/utils.ts rename to src/cache/utils.ts index ea2a9fc..c334f79 100644 --- a/src/utils.ts +++ b/src/cache/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/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/**/*"] From 1297d859eb3ccd92c67b51a93240ae2f71be9c25 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 01:15:52 +0100 Subject: [PATCH 05/26] fix: use separate exports per provider --- package.json | 8 ++++++++ src/cache/index.ts | 2 -- src/cache/{store => provider}/postgres.ts | 2 +- src/cache/{store => provider}/redis.ts | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) rename src/cache/{store => provider}/postgres.ts (93%) rename src/cache/{store => provider}/redis.ts (91%) diff --git a/package.json b/package.json index 2adc4e4..cb8c168 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,14 @@ "./cache": { "types": "./dist/cache/index.d.ts", "import": "./dist/cache/index.js" + }, + "./cache/redis": { + "types": "./dist/cache/provider/redis.d.ts", + "import": "./dist/cache/provider/redis.js" + }, + "./cache/postgres": { + "types": "./dist/cache/provider/postgres.d.ts", + "import": "./dist/cache/provider/postgres.js" } }, "files": [ diff --git a/src/cache/index.ts b/src/cache/index.ts index af88fc1..920534d 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,4 +1,2 @@ -export * from './store/postgres.js'; -export * from './store/redis.js'; export * from './types.js'; export * from './utils.js'; diff --git a/src/cache/store/postgres.ts b/src/cache/provider/postgres.ts similarity index 93% rename from src/cache/store/postgres.ts rename to src/cache/provider/postgres.ts index 0025fbc..5882b34 100644 --- a/src/cache/store/postgres.ts +++ b/src/cache/provider/postgres.ts @@ -1,7 +1,7 @@ import { sql } from '@vercel/postgres'; import { type CacheTag, type CacheTagsStore } from '../types.js'; -export const createPostgresCacheTagsStore = ({ table }: { table: string }): CacheTagsStore => { +export const createCacheTagsStore = ({ table }: { table: string }): CacheTagsStore => { const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { if (!cacheTags?.length) { return; diff --git a/src/cache/store/redis.ts b/src/cache/provider/redis.ts similarity index 91% rename from src/cache/store/redis.ts rename to src/cache/provider/redis.ts index 2eb898b..ce4f807 100644 --- a/src/cache/store/redis.ts +++ b/src/cache/provider/redis.ts @@ -1,7 +1,7 @@ import { Redis } from 'ioredis'; import { type CacheTag, type CacheTagsStore } from '../types.js'; -export const createRedisCacheTagsStore = ({ url, keyPrefix = '' }: { url: string; keyPrefix?: string }): CacheTagsStore => { +export const createCacheTagsStore = ({ url, keyPrefix = '' }: { url: string; keyPrefix?: string }): CacheTagsStore => { const redis = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: true, From e1e97366cb7b930de55c14e11ca851b5947bbf48 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 02:09:26 +0100 Subject: [PATCH 06/26] feat: replace @vercel/postgres by @neondatabase/serverless --- package-lock.json | 99 ++++++++++------------------------ package.json | 12 ++--- src/cache/provider/postgres.ts | 48 ----------------- 3 files changed, 34 insertions(+), 125 deletions(-) delete mode 100644 src/cache/provider/postgres.ts diff --git a/package-lock.json b/package-lock.json index 109f720..7d24ccb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "@neondatabase/serverless": "1.0.2", "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.10.12", - "@vercel/postgres": "0.10.0", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", "graphql": "16.12.0", @@ -22,12 +22,12 @@ "typescript": "5.9.3" }, "peerDependencies": { - "@vercel/postgres": "^0.10.0", + "@neondatabase/serverless": "^1.0.0", "graphql": "^15.0.0 || ^16.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { - "@vercel/postgres": { + "@neondatabase/serverless": { "optional": true }, "graphql": { @@ -668,15 +668,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/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": { - "@types/pg": "8.11.6" + "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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1371,22 +1392,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", - "dev": true, - "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", @@ -1705,20 +1710,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bufferutil": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", - "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", - "dev": true, - "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", @@ -3979,18 +3970,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-gyp-build": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", - "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", - "dev": true, - "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", @@ -5472,28 +5451,6 @@ "node": ">=0.10.0" } }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "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_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 cb8c168..6756e1e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "types": "./dist/cache/provider/redis.d.ts", "import": "./dist/cache/provider/redis.js" }, - "./cache/postgres": { - "types": "./dist/cache/provider/postgres.d.ts", - "import": "./dist/cache/provider/postgres.js" + "./cache/neon": { + "types": "./dist/cache/provider/neon.d.ts", + "import": "./dist/cache/provider/neon.js" } }, "files": [ @@ -46,10 +46,10 @@ "type": "git" }, "devDependencies": { + "@neondatabase/serverless": "1.0.2", "@smartive/eslint-config": "7.0.1", "@smartive/prettier-config": "3.1.2", "@types/node": "24.10.12", - "@vercel/postgres": "0.10.0", "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", "graphql": "16.12.0", @@ -59,12 +59,12 @@ "typescript": "5.9.3" }, "peerDependencies": { - "@vercel/postgres": "^0.10.0", + "@neondatabase/serverless": "^1.0.0", "graphql": "^15.0.0 || ^16.0.0", "ioredis": "^5.4.0" }, "peerDependenciesMeta": { - "@vercel/postgres": { + "@neondatabase/serverless": { "optional": true }, "graphql": { diff --git a/src/cache/provider/postgres.ts b/src/cache/provider/postgres.ts deleted file mode 100644 index 5882b34..0000000 --- a/src/cache/provider/postgres.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { sql } from '@vercel/postgres'; -import { type CacheTag, type CacheTagsStore } from '../types.js'; - -export const createCacheTagsStore = ({ table }: { table: string }): CacheTagsStore => { - const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { - 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 ${table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); - }; - - const queriesReferencingCacheTags = async (cacheTags: CacheTag[]): 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 ${table} WHERE cache_tag IN (${placeholders})`, - cacheTags, - ); - - return rows.map((row) => row.query_id); - }; - - const deleteCacheTags = async (cacheTags: CacheTag[]) => { - if (cacheTags.length === 0) { - return 0; - } - const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); - - return (await sql.query(`DELETE FROM ${table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0; - }; - - const truncateCacheTags = async () => (await sql.query(`DELETE FROM ${table}`)).rowCount ?? 0; - - return { - storeQueryCacheTags, - queriesReferencingCacheTags, - deleteCacheTags, - truncateCacheTags, - }; -}; From a734685d2916307a0cdbec614d911c5c88b709e2 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 02:14:18 +0100 Subject: [PATCH 07/26] fix: add missing neon database implementation --- src/cache/provider/neon.ts | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/cache/provider/neon.ts diff --git a/src/cache/provider/neon.ts b/src/cache/provider/neon.ts new file mode 100644 index 0000000..02378a5 --- /dev/null +++ b/src/cache/provider/neon.ts @@ -0,0 +1,62 @@ +import { neon } from '@neondatabase/serverless'; +import { type CacheTag, type CacheTagsStore } from '../types.js'; + +export const createCacheTagsStore = ({ + connectionString, + table, +}: { + connectionString: string; + table: string; +}): CacheTagsStore => { + const sql = neon(connectionString, { fullResults: true }); + + const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { + 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 ${table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); + }; + + const queriesReferencingCacheTags = async (cacheTags: CacheTag[]): Promise => { + if (!cacheTags?.length) { + return []; + } + + const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); + + const { rows } = await sql.query( + `SELECT DISTINCT query_id FROM ${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; + }, []); + }; + + const deleteCacheTags = async (cacheTags: CacheTag[]) => { + if (cacheTags.length === 0) { + return 0; + } + const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); + + return (await sql.query(`DELETE FROM ${table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0; + }; + + const truncateCacheTags = async () => (await sql.query(`DELETE FROM ${table}`)).rowCount ?? 0; + + return { + storeQueryCacheTags, + queriesReferencingCacheTags, + deleteCacheTags, + truncateCacheTags, + }; +}; From fae7ee76732dce9c40fbd85618b6e6268959f170 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 02:26:40 +0100 Subject: [PATCH 08/26] docs: add more type information --- src/cache/provider/neon.ts | 34 +++++++++++++++++++++++++++------- src/cache/provider/redis.ts | 21 ++++++++++++++++++++- src/cache/types.ts | 3 +++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/cache/provider/neon.ts b/src/cache/provider/neon.ts index 02378a5..98f6a57 100644 --- a/src/cache/provider/neon.ts +++ b/src/cache/provider/neon.ts @@ -1,13 +1,33 @@ import { neon } from '@neondatabase/serverless'; import { type CacheTag, type CacheTagsStore } from '../types.js'; -export const createCacheTagsStore = ({ - connectionString, - table, -}: { - connectionString: string; - table: string; -}): CacheTagsStore => { +type NeonCacheTagsStoreConfig = { + /** + * 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 connectionString: 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; +}; + +/** + * Creates a `CacheTagsStore` implementation using Neon as the storage backend. Neon is a serverless Postgres database service. + * + * @param {NeonCacheTagsStoreConfig} config Configuration object containing the Neon connection string and table name. + * @returns An object implementing the `CacheTagsStore` interface, allowing you to store and manage cache tags in a Neon database. + */ +export const createCacheTagsStore = ({ connectionString, table }: NeonCacheTagsStoreConfig): CacheTagsStore => { const sql = neon(connectionString, { fullResults: true }); const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { diff --git a/src/cache/provider/redis.ts b/src/cache/provider/redis.ts index ce4f807..e4d97ba 100644 --- a/src/cache/provider/redis.ts +++ b/src/cache/provider/redis.ts @@ -1,7 +1,26 @@ import { Redis } from 'ioredis'; import { type CacheTag, type CacheTagsStore } from '../types.js'; -export const createCacheTagsStore = ({ url, keyPrefix = '' }: { url: string; keyPrefix?: string }): CacheTagsStore => { +type RedisCacheTagsStoreConfig = { + /** + * Redis connection string. For example, `redis://user:pass@host:port/db`. + */ + readonly url: 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; +}; + +/** + * Creates a `CacheTagsStore` implementation using Redis as the storage backend. + * + * @param {RedisCacheTagsStoreConfig} config Configuration object containing the Redis connection string and optional key prefix. + * @returns An object implementing the `CacheTagsStore` interface, allowing you to store and manage cache tags in a Redis database. + */ +export const createCacheTagsStore = ({ url, keyPrefix = '' }: RedisCacheTagsStoreConfig): CacheTagsStore => { const redis = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: true, diff --git a/src/cache/types.ts b/src/cache/types.ts index 6764a6f..fb33a8a 100644 --- a/src/cache/types.ts +++ b/src/cache/types.ts @@ -23,6 +23,9 @@ export type CacheTagsInvalidateWebhook = { }; }; +/** + * Configuration object for creating a `CacheTagsStore` implementation. + */ export type CacheTagsStore = { /** * Stores the cache tags of a query. From 39b9d7bcd627c50955c803ef9b22b99b31cb462a Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 02:35:33 +0100 Subject: [PATCH 09/26] docs: adjust README --- README.md | 190 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 134 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 373a7f5..9ead1ee 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, filtering out undefined and boolean values. + +```typescript +import { classNames } from '@smartive/datocms-utils'; + +const className = classNames('btn', isActive && 'btn-active', undefined, 'btn-primary'); +// Result: "btn btn-active 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'; -### Utilities for DatoCMS Cache Tags +// Generate a unique ID for a GraphQL query +const queryId = generateQueryId(document, variables); -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). +// 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 two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `CacheTagsStore` interface. -- `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,116 @@ 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 { createCacheTagsStore } from '@smartive/datocms-utils/cache/neon'; -Add your Redis connection URL to your `.env.local` file: +const store = createCacheTagsStore({ + connectionString: process.env.DATABASE_URL!, + table: 'query_cache_tags', +}); -```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 store.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 store.queriesReferencingCacheTags(['item:42']); -# For local development -REDIS_URL=redis://localhost:6379 +// Delete specific cache tags +await store.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 store.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 store: ```typescript -// Recommended: Use namespaces for clarity -import { generateQueryId, redis } from '@smartive/datocms-utils'; +import { createCacheTagsStore } from '@smartive/datocms-utils/cache/redis'; + +const store = createCacheTagsStore({ + url: process.env.REDIS_URL!, + keyPrefix: 'prod:', // Optional: namespace for multi-environment setups +}); + +// Same API as Neon provider +await store.storeQueryCacheTags(queryId, ['item:42', 'product']); +const queries = await store.queriesReferencingCacheTags(['item:42']); +await store.deleteCacheTags(['item:42']); +await store.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 +#### `CacheTagsStore` 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'; +import { createCacheTagsStore } from '@smartive/datocms-utils/cache/redis'; + +const store = createCacheTagsStore({ + url: process.env.REDIS_URL!, + keyPrefix: 'myapp:', +}); + +// After making a DatoCMS query +const queryId = generateQueryId(query, variables); +const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']); +await store.storeQueryCacheTags(queryId, cacheTags); + +// When handling DatoCMS webhook for cache invalidation +const affectedQueries = await store.queriesReferencingCacheTags(webhook.entity.attributes.tags); +// Revalidate affected queries... +await store.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 +- `CacheTagsStore`: 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) From ba83ca6db518c7394bfdc4332258d65cb83f0861 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 14:34:00 +0100 Subject: [PATCH 10/26] chore: rename connection strings to `connectionUrl` --- src/cache/provider/neon.ts | 6 +++--- src/cache/provider/redis.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cache/provider/neon.ts b/src/cache/provider/neon.ts index 98f6a57..90a6790 100644 --- a/src/cache/provider/neon.ts +++ b/src/cache/provider/neon.ts @@ -6,7 +6,7 @@ type NeonCacheTagsStoreConfig = { * 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 connectionString: string; + readonly connectionUrl: string; /** * Name of the table where cache tags will be stored. The table must have the following schema: * @@ -27,8 +27,8 @@ type NeonCacheTagsStoreConfig = { * @param {NeonCacheTagsStoreConfig} config Configuration object containing the Neon connection string and table name. * @returns An object implementing the `CacheTagsStore` interface, allowing you to store and manage cache tags in a Neon database. */ -export const createCacheTagsStore = ({ connectionString, table }: NeonCacheTagsStoreConfig): CacheTagsStore => { - const sql = neon(connectionString, { fullResults: true }); +export const createCacheTagsStore = ({ connectionUrl, table }: NeonCacheTagsStoreConfig): CacheTagsStore => { + const sql = neon(connectionUrl, { fullResults: true }); const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { if (!cacheTags?.length) { diff --git a/src/cache/provider/redis.ts b/src/cache/provider/redis.ts index e4d97ba..ae69675 100644 --- a/src/cache/provider/redis.ts +++ b/src/cache/provider/redis.ts @@ -5,7 +5,7 @@ type RedisCacheTagsStoreConfig = { /** * Redis connection string. For example, `redis://user:pass@host:port/db`. */ - readonly url: string; + 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. @@ -20,8 +20,8 @@ type RedisCacheTagsStoreConfig = { * @param {RedisCacheTagsStoreConfig} config Configuration object containing the Redis connection string and optional key prefix. * @returns An object implementing the `CacheTagsStore` interface, allowing you to store and manage cache tags in a Redis database. */ -export const createCacheTagsStore = ({ url, keyPrefix = '' }: RedisCacheTagsStoreConfig): CacheTagsStore => { - const redis = new Redis(url, { +export const createCacheTagsStore = ({ connectionUrl, keyPrefix = '' }: RedisCacheTagsStoreConfig): CacheTagsStore => { + const redis = new Redis(connectionUrl, { maxRetriesPerRequest: 3, lazyConnect: true, }); From d9c67a11b727da34fbe6a44244aabbecff70d1bb Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 14:48:48 +0100 Subject: [PATCH 11/26] feat: enhance classNames function to support more use cases --- src/classnames.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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(' '); From 5f5e8957972f9e62820865e52f9bcd06122401f3 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Fri, 13 Feb 2026 16:16:25 +0100 Subject: [PATCH 12/26] feat: add Noop cache provider for testing purposes --- package.json | 4 ++++ src/cache/provider/noop.ts | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/cache/provider/noop.ts diff --git a/package.json b/package.json index 6756e1e..223386a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "./cache/neon": { "types": "./dist/cache/provider/neon.d.ts", "import": "./dist/cache/provider/neon.js" + }, + "./cache/noop": { + "types": "./dist/cache/provider/noop.d.ts", + "import": "./dist/cache/provider/noop.js" } }, "files": [ diff --git a/src/cache/provider/noop.ts b/src/cache/provider/noop.ts new file mode 100644 index 0000000..171a503 --- /dev/null +++ b/src/cache/provider/noop.ts @@ -0,0 +1,41 @@ +import { type CacheTag, type CacheTagsStore } from '../types.js'; + +/** + * Creates a `CacheTagsStore` 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._ + * + * @returns An object implementing the `CacheTagsStore` interface. + */ +export const createCacheTagsStore = (): CacheTagsStore => { + const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { + console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); + + return Promise.resolve(); + }; + + const queriesReferencingCacheTags = async (cacheTags: CacheTag[]) => { + console.debug('-- queriesReferencingCacheTags called', { cacheTags }); + + return Promise.resolve([]); + }; + + const deleteCacheTags = async (cacheTags: CacheTag[]) => { + console.debug('-- deleteCacheTags called', { cacheTags }); + + return Promise.resolve(0); + }; + + const truncateCacheTags = async () => { + console.debug('-- truncateCacheTags called'); + + return Promise.resolve(0); + }; + + return { + storeQueryCacheTags, + queriesReferencingCacheTags, + deleteCacheTags, + truncateCacheTags, + }; +}; From b84d169f64a726315d65a6ac4b3b3b3c06d0c058 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 15:11:45 +0100 Subject: [PATCH 13/26] feat: switch to classes --- README.md | 38 ++++++++++----------- src/cache/provider/neon.ts | 50 +++++++++++++-------------- src/cache/provider/noop.ts | 33 +++++++----------- src/cache/provider/redis.ts | 68 +++++++++++++++++-------------------- src/cache/types.ts | 4 +-- 5 files changed, 89 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 9ead1ee..94a8ca3 100644 --- a/README.md +++ b/README.md @@ -80,24 +80,24 @@ npm install @neondatabase/serverless 3. Create and use the store: ```typescript -import { createCacheTagsStore } from '@smartive/datocms-utils/cache/neon'; +import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache/neon'; -const store = createCacheTagsStore({ +const provider = new NeonCacheTagsProvider({ connectionString: process.env.DATABASE_URL!, table: 'query_cache_tags', }); // Store cache tags for a query -await store.storeQueryCacheTags(queryId, ['item:42', 'product']); +await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); // Find queries that reference specific tags -const queries = await store.queriesReferencingCacheTags(['item:42']); +const queries = await provider.queriesReferencingCacheTags(['item:42']); // Delete specific cache tags -await store.deleteCacheTags(['item:42']); +await provider.deleteCacheTags(['item:42']); // Clear all cache tags -await store.truncateCacheTags(); +await provider.truncateCacheTags(); ``` ##### Redis Provider @@ -112,21 +112,21 @@ Use Redis to store cache tag mappings with better performance for high-traffic a npm install ioredis ``` -2. Create and use the store: +2. Create and use the provider: ```typescript -import { createCacheTagsStore } from '@smartive/datocms-utils/cache/redis'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache/redis'; -const store = createCacheTagsStore({ +const provider = new RedisCacheTagsProvider({ url: process.env.REDIS_URL!, keyPrefix: 'prod:', // Optional: namespace for multi-environment setups }); // Same API as Neon provider -await store.storeQueryCacheTags(queryId, ['item:42', 'product']); +await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); const queries = await store.queriesReferencingCacheTags(['item:42']); -await store.deleteCacheTags(['item:42']); -await store.truncateCacheTags(); +await provider.deleteCacheTags(['item:42']); +await provider.truncateCacheTags(); ``` **Redis connection string examples:** @@ -142,7 +142,7 @@ REDIS_URL=redis://username:password@redis-host:6379 REDIS_URL=redis://localhost:6379 ``` -#### `CacheTagsStore` Interface +#### `CacheTagsProvider` Interface Both providers implement: @@ -155,9 +155,9 @@ Both providers implement: ```typescript import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache'; -import { createCacheTagsStore } from '@smartive/datocms-utils/cache/redis'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache/redis'; -const store = createCacheTagsStore({ +const provider = new RedisCacheTagsProvider({ url: process.env.REDIS_URL!, keyPrefix: 'myapp:', }); @@ -165,12 +165,12 @@ const store = createCacheTagsStore({ // After making a DatoCMS query const queryId = generateQueryId(query, variables); const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']); -await store.storeQueryCacheTags(queryId, cacheTags); +await provider.storeQueryCacheTags(queryId, cacheTags); // When handling DatoCMS webhook for cache invalidation -const affectedQueries = await store.queriesReferencingCacheTags(webhook.entity.attributes.tags); +const affectedQueries = await provider.queriesReferencingCacheTags(webhook.entity.attributes.tags); // Revalidate affected queries... -await store.deleteCacheTags(webhook.entity.attributes.tags); +await provider.deleteCacheTags(webhook.entity.attributes.tags); ``` ## TypeScript Types @@ -179,7 +179,7 @@ The package includes TypeScript types for DatoCMS webhooks and cache tags: - `CacheTag`: A branded type for cache tags, ensuring type safety - `CacheTagsInvalidateWebhook`: Type definition for DatoCMS cache tag invalidation webhook payloads -- `CacheTagsStore`: Interface for cache tag storage implementations +- `CacheTagsProvider`: Interface for cache tag storage implementations ## License diff --git a/src/cache/provider/neon.ts b/src/cache/provider/neon.ts index 90a6790..72a53b2 100644 --- a/src/cache/provider/neon.ts +++ b/src/cache/provider/neon.ts @@ -1,5 +1,5 @@ import { neon } from '@neondatabase/serverless'; -import { type CacheTag, type CacheTagsStore } from '../types.js'; +import { type CacheTag, type CacheTagsProvider } from '../types.js'; type NeonCacheTagsStoreConfig = { /** @@ -22,15 +22,18 @@ type NeonCacheTagsStoreConfig = { }; /** - * Creates a `CacheTagsStore` implementation using Neon as the storage backend. Neon is a serverless Postgres database service. - * - * @param {NeonCacheTagsStoreConfig} config Configuration object containing the Neon connection string and table name. - * @returns An object implementing the `CacheTagsStore` interface, allowing you to store and manage cache tags in a Neon database. + * A `CacheTagsProvider` implementation that uses Neon as the storage backend. */ -export const createCacheTagsStore = ({ connectionUrl, table }: NeonCacheTagsStoreConfig): CacheTagsStore => { - const sql = neon(connectionUrl, { fullResults: true }); +export class NeonCacheTagsProvider implements CacheTagsProvider { + private readonly sql; + private readonly table; - const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { + constructor({ connectionUrl, table }: NeonCacheTagsStoreConfig) { + this.sql = neon(connectionUrl, { fullResults: true }); + this.table = table; + } + + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { if (!cacheTags?.length) { return; } @@ -38,18 +41,18 @@ export const createCacheTagsStore = ({ connectionUrl, table }: NeonCacheTagsStor 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 ${table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); - }; + await this.sql.query(`INSERT INTO ${this.table} VALUES ${placeholders} ON CONFLICT DO NOTHING`, tags); + } - const queriesReferencingCacheTags = async (cacheTags: CacheTag[]): Promise => { + public async queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise { if (!cacheTags?.length) { return []; } const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); - const { rows } = await sql.query( - `SELECT DISTINCT query_id FROM ${table} WHERE cache_tag IN (${placeholders})`, + const { rows } = await this.sql.query( + `SELECT DISTINCT query_id FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags, ); @@ -60,23 +63,18 @@ export const createCacheTagsStore = ({ connectionUrl, table }: NeonCacheTagsStor return queryIds; }, []); - }; + } - const deleteCacheTags = async (cacheTags: CacheTag[]) => { + public async deleteCacheTags(cacheTags: CacheTag[]) { if (cacheTags.length === 0) { return 0; } const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); - return (await sql.query(`DELETE FROM ${table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0; - }; - - const truncateCacheTags = async () => (await sql.query(`DELETE FROM ${table}`)).rowCount ?? 0; + return (await this.sql.query(`DELETE FROM ${this.table} WHERE cache_tag IN (${placeholders})`, cacheTags)).rowCount ?? 0; + } - return { - storeQueryCacheTags, - queriesReferencingCacheTags, - deleteCacheTags, - truncateCacheTags, - }; -}; + public async truncateCacheTags() { + return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0; + } +} diff --git a/src/cache/provider/noop.ts b/src/cache/provider/noop.ts index 171a503..6c8990e 100644 --- a/src/cache/provider/noop.ts +++ b/src/cache/provider/noop.ts @@ -1,41 +1,32 @@ -import { type CacheTag, type CacheTagsStore } from '../types.js'; +import { type CacheTag, type CacheTagsProvider } from '../types.js'; /** - * Creates a `CacheTagsStore` implementation that does not perform any actual storage operations. + * 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._ - * - * @returns An object implementing the `CacheTagsStore` interface. */ -export const createCacheTagsStore = (): CacheTagsStore => { - const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { +export class NoopCacheTagsProvider implements CacheTagsProvider { + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); return Promise.resolve(); - }; + } - const queriesReferencingCacheTags = async (cacheTags: CacheTag[]) => { + public async queriesReferencingCacheTags(cacheTags: CacheTag[]): Promise { console.debug('-- queriesReferencingCacheTags called', { cacheTags }); return Promise.resolve([]); - }; + } - const deleteCacheTags = async (cacheTags: CacheTag[]) => { + public async deleteCacheTags(cacheTags: CacheTag[]) { console.debug('-- deleteCacheTags called', { cacheTags }); return Promise.resolve(0); - }; + } - const truncateCacheTags = async () => { + public async truncateCacheTags() { console.debug('-- truncateCacheTags called'); return Promise.resolve(0); - }; - - return { - storeQueryCacheTags, - queriesReferencingCacheTags, - deleteCacheTags, - truncateCacheTags, - }; -}; + } +} diff --git a/src/cache/provider/redis.ts b/src/cache/provider/redis.ts index ae69675..117e0ff 100644 --- a/src/cache/provider/redis.ts +++ b/src/cache/provider/redis.ts @@ -1,5 +1,5 @@ import { Redis } from 'ioredis'; -import { type CacheTag, type CacheTagsStore } from '../types.js'; +import { type CacheTag, type CacheTagsProvider } from '../types.js'; type RedisCacheTagsStoreConfig = { /** @@ -15,66 +15,62 @@ type RedisCacheTagsStoreConfig = { }; /** - * Creates a `CacheTagsStore` implementation using Redis as the storage backend. - * - * @param {RedisCacheTagsStoreConfig} config Configuration object containing the Redis connection string and optional key prefix. - * @returns An object implementing the `CacheTagsStore` interface, allowing you to store and manage cache tags in a Redis database. + * A `CacheTagsProvider` implementation that uses Redis as the storage backend. */ -export const createCacheTagsStore = ({ connectionUrl, keyPrefix = '' }: RedisCacheTagsStoreConfig): CacheTagsStore => { - const redis = new Redis(connectionUrl, { - maxRetriesPerRequest: 3, - lazyConnect: true, - }); - - const storeQueryCacheTags = async (queryId: string, cacheTags: CacheTag[]) => { +export class RedisCacheTagsProvider implements CacheTagsProvider { + private readonly redis; + private readonly keyPrefix; + + constructor({ connectionUrl, keyPrefix }: RedisCacheTagsStoreConfig) { + this.redis = new Redis(connectionUrl, { + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + this.keyPrefix = keyPrefix ?? ''; + } + + public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { if (!cacheTags?.length) { return; } - const pipeline = redis.pipeline(); + const pipeline = this.redis.pipeline(); for (const tag of cacheTags) { - pipeline.sadd(`${keyPrefix}${tag}`, queryId); + pipeline.sadd(`${this.keyPrefix}${tag}`, queryId); } await pipeline.exec(); - }; + } - const queriesReferencingCacheTags = async (cacheTags: CacheTag[]) => { + public async queriesReferencingCacheTags(cacheTags: CacheTag[]) { if (!cacheTags?.length) { return []; } - const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); + const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); - return redis.sunion(...keys); - }; + return this.redis.sunion(...keys); + } - const deleteCacheTags = async (cacheTags: CacheTag[]) => { + public async deleteCacheTags(cacheTags: CacheTag[]) { if (!cacheTags?.length) { return 0; } - const keys = cacheTags.map((tag) => `${keyPrefix}${tag}`); + const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); - return redis.del(...keys); - }; + return this.redis.del(...keys); + } - const truncateCacheTags = async () => { - const pattern = `${keyPrefix}*`; - const keys = await redis.keys(pattern); + public async truncateCacheTags() { + const pattern = `${this.keyPrefix}*`; + const keys = await this.redis.keys(pattern); if (keys.length === 0) { return 0; } - return await redis.del(...keys); - }; - - return { - storeQueryCacheTags, - queriesReferencingCacheTags, - deleteCacheTags, - truncateCacheTags, - }; -}; + return await this.redis.del(...keys); + } +} diff --git a/src/cache/types.ts b/src/cache/types.ts index fb33a8a..f6321fa 100644 --- a/src/cache/types.ts +++ b/src/cache/types.ts @@ -26,7 +26,7 @@ export type CacheTagsInvalidateWebhook = { /** * Configuration object for creating a `CacheTagsStore` implementation. */ -export type CacheTagsStore = { +export interface CacheTagsProvider { /** * Stores the cache tags of a query. * @@ -63,4 +63,4 @@ export type CacheTagsStore = { * ⚠️ **Warning**: This will delete all cache tag data. Use with caution! */ truncateCacheTags(): Promise; -}; +} From ceb90def97caf0b7f922a24a7212137111665a16 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 15:27:28 +0100 Subject: [PATCH 14/26] feat: Rename everything and simplify naming --- README.md | 18 ++++++++-------- package.json | 24 ++++++++++----------- src/{cache => cache-tags}/index.ts | 0 src/{cache => cache-tags}/provider/neon.ts | 10 ++++----- src/{cache => cache-tags}/provider/noop.ts | 6 +++--- src/{cache => cache-tags}/provider/redis.ts | 10 ++++----- src/{cache => cache-tags}/types.ts | 4 ++-- src/{cache => cache-tags}/utils.ts | 0 8 files changed, 36 insertions(+), 36 deletions(-) rename src/{cache => cache-tags}/index.ts (100%) rename src/{cache => cache-tags}/provider/neon.ts (85%) rename src/{cache => cache-tags}/provider/noop.ts (77%) rename src/{cache => cache-tags}/provider/redis.ts (83%) rename src/{cache => cache-tags}/types.ts (93%) rename src/{cache => cache-tags}/utils.ts (100%) diff --git a/README.md b/README.md index 94a8ca3..f19d292 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); #### Storage Providers -The package provides two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `CacheTagsStore` interface. +The package provides two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `DatoCacheTagsProvider` interface. ##### Neon (Postgres) Provider @@ -80,9 +80,9 @@ npm install @neondatabase/serverless 3. Create and use the store: ```typescript -import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache/neon'; +import { NeonDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon'; -const provider = new NeonCacheTagsProvider({ +const provider = new NeonDatoCacheTagsProvider({ connectionString: process.env.DATABASE_URL!, table: 'query_cache_tags', }); @@ -115,9 +115,9 @@ npm install ioredis 2. Create and use the provider: ```typescript -import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache/redis'; +import { RedisDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; -const provider = new RedisCacheTagsProvider({ +const provider = new RedisDatoCacheTagsProvider({ url: process.env.REDIS_URL!, keyPrefix: 'prod:', // Optional: namespace for multi-environment setups }); @@ -142,7 +142,7 @@ REDIS_URL=redis://username:password@redis-host:6379 REDIS_URL=redis://localhost:6379 ``` -#### `CacheTagsProvider` Interface +#### `DatoCacheTagsProvider` Interface Both providers implement: @@ -155,9 +155,9 @@ Both providers implement: ```typescript import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache'; -import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache/redis'; +import { RedisDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; -const provider = new RedisCacheTagsProvider({ +const provider = new RedisDatoCacheTagsProvider({ url: process.env.REDIS_URL!, keyPrefix: 'myapp:', }); @@ -179,7 +179,7 @@ The package includes TypeScript types for DatoCMS webhooks and cache tags: - `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 +- `DatoCacheTagsProvider`: Interface for cache tag storage implementations ## License diff --git a/package.json b/package.json index 223386a..9e10f47 100644 --- a/package.json +++ b/package.json @@ -9,21 +9,21 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./cache": { - "types": "./dist/cache/index.d.ts", - "import": "./dist/cache/index.js" + "./cache-tags": { + "types": "./dist/cache-tags/index.d.ts", + "import": "./dist/cache-tags/index.js" }, - "./cache/redis": { - "types": "./dist/cache/provider/redis.d.ts", - "import": "./dist/cache/provider/redis.js" + "./cache-tags/redis": { + "types": "./dist/cache-tags/provider/redis.d.ts", + "import": "./dist/cache-tags/provider/redis.js" }, - "./cache/neon": { - "types": "./dist/cache/provider/neon.d.ts", - "import": "./dist/cache/provider/neon.js" + "./cache-tags/neon": { + "types": "./dist/cache-tags/provider/neon.d.ts", + "import": "./dist/cache-tags/provider/neon.js" }, - "./cache/noop": { - "types": "./dist/cache/provider/noop.d.ts", - "import": "./dist/cache/provider/noop.js" + "./cache-tags/noop": { + "types": "./dist/cache-tags/provider/noop.d.ts", + "import": "./dist/cache-tags/provider/noop.js" } }, "files": [ diff --git a/src/cache/index.ts b/src/cache-tags/index.ts similarity index 100% rename from src/cache/index.ts rename to src/cache-tags/index.ts diff --git a/src/cache/provider/neon.ts b/src/cache-tags/provider/neon.ts similarity index 85% rename from src/cache/provider/neon.ts rename to src/cache-tags/provider/neon.ts index 72a53b2..3606343 100644 --- a/src/cache/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -1,7 +1,7 @@ import { neon } from '@neondatabase/serverless'; -import { type CacheTag, type CacheTagsProvider } from '../types.js'; +import { type CacheTag, type DatoCacheTagsProvider } from '../types.js'; -type NeonCacheTagsStoreConfig = { +type NeonDatoCacheTagsProviderConfig = { /** * Neon connection string. You can find it in the "Connection" tab of your Neon project dashboard. * Has the format `postgresql://user:pass@host/db` @@ -22,13 +22,13 @@ type NeonCacheTagsStoreConfig = { }; /** - * A `CacheTagsProvider` implementation that uses Neon as the storage backend. + * A `DatoCacheTagsProvider` implementation that uses Neon as the storage backend. */ -export class NeonCacheTagsProvider implements CacheTagsProvider { +export class NeonDatoCacheTagsProvider implements DatoCacheTagsProvider { private readonly sql; private readonly table; - constructor({ connectionUrl, table }: NeonCacheTagsStoreConfig) { + constructor({ connectionUrl, table }: NeonDatoCacheTagsProviderConfig) { this.sql = neon(connectionUrl, { fullResults: true }); this.table = table; } diff --git a/src/cache/provider/noop.ts b/src/cache-tags/provider/noop.ts similarity index 77% rename from src/cache/provider/noop.ts rename to src/cache-tags/provider/noop.ts index 6c8990e..220a3f3 100644 --- a/src/cache/provider/noop.ts +++ b/src/cache-tags/provider/noop.ts @@ -1,11 +1,11 @@ -import { type CacheTag, type CacheTagsProvider } from '../types.js'; +import { type CacheTag, type DatoCacheTagsProvider } from '../types.js'; /** - * A `CacheTagsProvider` implementation that does not perform any actual storage operations. + * A `DatoCacheTagsProvider` 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 { +export class NoopDatoCacheTagsProvider implements DatoCacheTagsProvider { public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); diff --git a/src/cache/provider/redis.ts b/src/cache-tags/provider/redis.ts similarity index 83% rename from src/cache/provider/redis.ts rename to src/cache-tags/provider/redis.ts index 117e0ff..d4f8ad6 100644 --- a/src/cache/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -1,7 +1,7 @@ import { Redis } from 'ioredis'; -import { type CacheTag, type CacheTagsProvider } from '../types.js'; +import { type CacheTag, type DatoCacheTagsProvider } from '../types.js'; -type RedisCacheTagsStoreConfig = { +type RedisDatoCacheTagsProviderConfig = { /** * Redis connection string. For example, `redis://user:pass@host:port/db`. */ @@ -15,13 +15,13 @@ type RedisCacheTagsStoreConfig = { }; /** - * A `CacheTagsProvider` implementation that uses Redis as the storage backend. + * A `DatoCacheTagsProvider` implementation that uses Redis as the storage backend. */ -export class RedisCacheTagsProvider implements CacheTagsProvider { +export class RedisDatoCacheTagsProvider implements DatoCacheTagsProvider { private readonly redis; private readonly keyPrefix; - constructor({ connectionUrl, keyPrefix }: RedisCacheTagsStoreConfig) { + constructor({ connectionUrl, keyPrefix }: RedisDatoCacheTagsProviderConfig) { this.redis = new Redis(connectionUrl, { maxRetriesPerRequest: 3, lazyConnect: true, diff --git a/src/cache/types.ts b/src/cache-tags/types.ts similarity index 93% rename from src/cache/types.ts rename to src/cache-tags/types.ts index f6321fa..120da94 100644 --- a/src/cache/types.ts +++ b/src/cache-tags/types.ts @@ -24,9 +24,9 @@ export type CacheTagsInvalidateWebhook = { }; /** - * Configuration object for creating a `CacheTagsStore` implementation. + * Configuration object for creating a `DatoCacheTagsProvider` implementation. */ -export interface CacheTagsProvider { +export interface DatoCacheTagsProvider { /** * Stores the cache tags of a query. * diff --git a/src/cache/utils.ts b/src/cache-tags/utils.ts similarity index 100% rename from src/cache/utils.ts rename to src/cache-tags/utils.ts From 60f3b970c1ac16ba8547f6aa0eb8dfee5d7df913 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 15:34:32 +0100 Subject: [PATCH 15/26] fix: remove Dato prefix from CacheTags objects --- README.md | 18 +++++++++--------- src/cache-tags/provider/neon.ts | 10 +++++----- src/cache-tags/provider/noop.ts | 6 +++--- src/cache-tags/provider/redis.ts | 10 +++++----- src/cache-tags/types.ts | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f19d292..ce456c6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); #### Storage Providers -The package provides two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `DatoCacheTagsProvider` interface. +The package provides two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `CacheTagsProvider` interface. ##### Neon (Postgres) Provider @@ -80,9 +80,9 @@ npm install @neondatabase/serverless 3. Create and use the store: ```typescript -import { NeonDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon'; +import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon'; -const provider = new NeonDatoCacheTagsProvider({ +const provider = new NeonCacheTagsProvider({ connectionString: process.env.DATABASE_URL!, table: 'query_cache_tags', }); @@ -115,9 +115,9 @@ npm install ioredis 2. Create and use the provider: ```typescript -import { RedisDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; -const provider = new RedisDatoCacheTagsProvider({ +const provider = new RedisCacheTagsProvider({ url: process.env.REDIS_URL!, keyPrefix: 'prod:', // Optional: namespace for multi-environment setups }); @@ -142,7 +142,7 @@ REDIS_URL=redis://username:password@redis-host:6379 REDIS_URL=redis://localhost:6379 ``` -#### `DatoCacheTagsProvider` Interface +#### `CacheTagsProvider` Interface Both providers implement: @@ -155,9 +155,9 @@ Both providers implement: ```typescript import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache'; -import { RedisDatoCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; +import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; -const provider = new RedisDatoCacheTagsProvider({ +const provider = new RedisCacheTagsProvider({ url: process.env.REDIS_URL!, keyPrefix: 'myapp:', }); @@ -179,7 +179,7 @@ The package includes TypeScript types for DatoCMS webhooks and cache tags: - `CacheTag`: A branded type for cache tags, ensuring type safety - `CacheTagsInvalidateWebhook`: Type definition for DatoCMS cache tag invalidation webhook payloads -- `DatoCacheTagsProvider`: Interface for cache tag storage implementations +- `CacheTagsProvider`: Interface for cache tag storage implementations ## License diff --git a/src/cache-tags/provider/neon.ts b/src/cache-tags/provider/neon.ts index 3606343..f745872 100644 --- a/src/cache-tags/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -1,7 +1,7 @@ import { neon } from '@neondatabase/serverless'; -import { type CacheTag, type DatoCacheTagsProvider } from '../types.js'; +import { type CacheTag, type CacheTagsProvider } from '../types.js'; -type NeonDatoCacheTagsProviderConfig = { +type NeonCacheTagsProviderConfig = { /** * Neon connection string. You can find it in the "Connection" tab of your Neon project dashboard. * Has the format `postgresql://user:pass@host/db` @@ -22,13 +22,13 @@ type NeonDatoCacheTagsProviderConfig = { }; /** - * A `DatoCacheTagsProvider` implementation that uses Neon as the storage backend. + * A `CacheTagsProvider` implementation that uses Neon as the storage backend. */ -export class NeonDatoCacheTagsProvider implements DatoCacheTagsProvider { +export class NeonCacheTagsProvider implements CacheTagsProvider { private readonly sql; private readonly table; - constructor({ connectionUrl, table }: NeonDatoCacheTagsProviderConfig) { + constructor({ connectionUrl, table }: NeonCacheTagsProviderConfig) { this.sql = neon(connectionUrl, { fullResults: true }); this.table = table; } diff --git a/src/cache-tags/provider/noop.ts b/src/cache-tags/provider/noop.ts index 220a3f3..6c8990e 100644 --- a/src/cache-tags/provider/noop.ts +++ b/src/cache-tags/provider/noop.ts @@ -1,11 +1,11 @@ -import { type CacheTag, type DatoCacheTagsProvider } from '../types.js'; +import { type CacheTag, type CacheTagsProvider } from '../types.js'; /** - * A `DatoCacheTagsProvider` implementation that does not perform any actual storage operations. + * 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 NoopDatoCacheTagsProvider implements DatoCacheTagsProvider { +export class NoopCacheTagsProvider implements CacheTagsProvider { public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { console.debug('-- storeQueryCacheTags called', { queryId, cacheTags }); diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index d4f8ad6..138826b 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -1,7 +1,7 @@ import { Redis } from 'ioredis'; -import { type CacheTag, type DatoCacheTagsProvider } from '../types.js'; +import { type CacheTag, type CacheTagsProvider } from '../types.js'; -type RedisDatoCacheTagsProviderConfig = { +type RedisCacheTagsProviderConfig = { /** * Redis connection string. For example, `redis://user:pass@host:port/db`. */ @@ -15,13 +15,13 @@ type RedisDatoCacheTagsProviderConfig = { }; /** - * A `DatoCacheTagsProvider` implementation that uses Redis as the storage backend. + * A `CacheTagsProvider` implementation that uses Redis as the storage backend. */ -export class RedisDatoCacheTagsProvider implements DatoCacheTagsProvider { +export class RedisCacheTagsProvider implements CacheTagsProvider { private readonly redis; private readonly keyPrefix; - constructor({ connectionUrl, keyPrefix }: RedisDatoCacheTagsProviderConfig) { + constructor({ connectionUrl, keyPrefix }: RedisCacheTagsProviderConfig) { this.redis = new Redis(connectionUrl, { maxRetriesPerRequest: 3, lazyConnect: true, diff --git a/src/cache-tags/types.ts b/src/cache-tags/types.ts index 120da94..d2d47c8 100644 --- a/src/cache-tags/types.ts +++ b/src/cache-tags/types.ts @@ -24,9 +24,9 @@ export type CacheTagsInvalidateWebhook = { }; /** - * Configuration object for creating a `DatoCacheTagsProvider` implementation. + * Configuration object for creating a `CacheTagsProvider` implementation. */ -export interface DatoCacheTagsProvider { +export interface CacheTagsProvider { /** * Stores the cache tags of a query. * From b1db2864d2e8fca2032812c3afaae53a0c449aee Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 21:00:17 +0100 Subject: [PATCH 16/26] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce456c6..16ea1bc 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocm import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; const provider = new RedisCacheTagsProvider({ - url: process.env.REDIS_URL!, + connectionUrl: process.env.REDIS_URL!, keyPrefix: 'myapp:', }); From 071d9f1655a8cc91a40ebf27aaf710e9119ac6cb Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 21:07:04 +0100 Subject: [PATCH 17/26] chore: adjust README Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 16ea1bc..58c5071 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Utilities for managing [DatoCMS cache tags](https://www.datocms.com/docs/content #### Core Utilities ```typescript -import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache'; +import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags'; // Generate a unique ID for a GraphQL query const queryId = generateQueryId(document, variables); @@ -83,7 +83,7 @@ npm install @neondatabase/serverless import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon'; const provider = new NeonCacheTagsProvider({ - connectionString: process.env.DATABASE_URL!, + connectionUrl: process.env.DATABASE_URL!, table: 'query_cache_tags', }); @@ -124,7 +124,7 @@ const provider = new RedisCacheTagsProvider({ // Same API as Neon provider await provider.storeQueryCacheTags(queryId, ['item:42', 'product']); -const queries = await store.queriesReferencingCacheTags(['item:42']); +const queries = await provider.queriesReferencingCacheTags(['item:42']); await provider.deleteCacheTags(['item:42']); await provider.truncateCacheTags(); ``` @@ -154,7 +154,7 @@ Both providers implement: ### Complete Example ```typescript -import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache'; +import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags'; import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; const provider = new RedisCacheTagsProvider({ From 06dca139cbd9fad6e54115579afadbf0bad3b84d Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 21:18:25 +0100 Subject: [PATCH 18/26] fix: switch to SCAN instead of KEYS for Redis --- src/cache-tags/provider/redis.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index 138826b..b2bdb15 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -64,8 +64,7 @@ export class RedisCacheTagsProvider implements CacheTagsProvider { } public async truncateCacheTags() { - const pattern = `${this.keyPrefix}*`; - const keys = await this.redis.keys(pattern); + const keys = await this.getKeys(); if (keys.length === 0) { return 0; @@ -73,4 +72,33 @@ export class RedisCacheTagsProvider implements CacheTagsProvider { return await this.redis.del(...keys); } + + /** + * 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); + }); + }); + } } From b752d546fb6abbd77a1ff2297182117127d2def9 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Sun, 15 Feb 2026 21:33:13 +0100 Subject: [PATCH 19/26] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 8 ++++---- src/cache-tags/provider/neon.ts | 2 +- src/cache-tags/types.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 58c5071..e77b3db 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ npm install @smartive/datocms-utils #### `classNames` -Cleans and joins an array of class names, filtering out undefined and boolean values. +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', undefined, 'btn-primary'); -// Result: "btn btn-active btn-primary" +const className = classNames('btn', isActive && 'btn-active', 42, undefined, 'btn-primary'); +// Result: "btn btn-active 42 btn-primary" ``` #### `getTelLink` @@ -118,7 +118,7 @@ npm install ioredis import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis'; const provider = new RedisCacheTagsProvider({ - url: process.env.REDIS_URL!, + connectionUrl: process.env.REDIS_URL!, keyPrefix: 'prod:', // Optional: namespace for multi-environment setups }); diff --git a/src/cache-tags/provider/neon.ts b/src/cache-tags/provider/neon.ts index f745872..f9170d7 100644 --- a/src/cache-tags/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -66,7 +66,7 @@ export class NeonCacheTagsProvider implements CacheTagsProvider { } public async deleteCacheTags(cacheTags: CacheTag[]) { - if (cacheTags.length === 0) { + if (!cacheTags?.length) { return 0; } const placeholders = cacheTags.map((_, i) => `$${i + 1}`).join(','); diff --git a/src/cache-tags/types.ts b/src/cache-tags/types.ts index d2d47c8..8e74898 100644 --- a/src/cache-tags/types.ts +++ b/src/cache-tags/types.ts @@ -52,7 +52,7 @@ export interface CacheTagsProvider { * 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 + * @returns Number of keys deleted * */ deleteCacheTags(cacheTags: CacheTag[]): Promise; From 7cfb647f24850f1e10a21b908fb5a083edf9a77e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:15:19 +0100 Subject: [PATCH 20/26] fix: SQL injection vulnerability in Neon cache provider table names (#214) * Initial plan * Fix SQL injection vulnerability in Neon provider table name Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> * Improve identifier validation and escape double quotes Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> * Improve error message to clarify schema-qualified names Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> * schoener --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> Co-authored-by: Moreno Feltscher --- package-lock.json | 11 +++++++++++ src/cache-tags/provider/neon.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c2e0317..5f18502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -898,6 +899,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1398,6 +1400,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1696,6 +1699,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2224,6 +2228,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2284,6 +2289,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4376,6 +4382,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5054,6 +5061,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5203,6 +5211,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5268,6 +5277,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5477,6 +5487,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/cache-tags/provider/neon.ts b/src/cache-tags/provider/neon.ts index f9170d7..1ac63a3 100644 --- a/src/cache-tags/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -30,7 +30,7 @@ export class NeonCacheTagsProvider implements CacheTagsProvider { constructor({ connectionUrl, table }: NeonCacheTagsProviderConfig) { this.sql = neon(connectionUrl, { fullResults: true }); - this.table = table; + this.table = NeonCacheTagsProvider.quoteIdentifier(table); } public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { @@ -77,4 +77,29 @@ export class NeonCacheTagsProvider implements CacheTagsProvider { public async truncateCacheTags() { return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 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 { + // Validate that the identifier contains only valid characters + // PostgreSQL identifiers can contain letters, digits, underscores, and dollar signs + // They can also contain dots for schema-qualified names + 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('.'); + } } From c362c397d3218fbd01639c2a7e97f746d9fb4eb0 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Mon, 16 Feb 2026 09:36:48 +0100 Subject: [PATCH 21/26] cleanup: Docs and exports --- src/cache-tags/provider/neon.ts | 5 +---- src/cache-tags/provider/redis.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cache-tags/provider/neon.ts b/src/cache-tags/provider/neon.ts index 1ac63a3..f02ca3e 100644 --- a/src/cache-tags/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -1,7 +1,7 @@ import { neon } from '@neondatabase/serverless'; import { type CacheTag, type CacheTagsProvider } from '../types.js'; -type NeonCacheTagsProviderConfig = { +export type NeonCacheTagsProviderConfig = { /** * Neon connection string. You can find it in the "Connection" tab of your Neon project dashboard. * Has the format `postgresql://user:pass@host/db` @@ -85,9 +85,6 @@ export class NeonCacheTagsProvider implements CacheTagsProvider { * @throws Error if the identifier is invalid */ private static quoteIdentifier(identifier: string): string { - // Validate that the identifier contains only valid characters - // PostgreSQL identifiers can contain letters, digits, underscores, and dollar signs - // They can also contain dots for schema-qualified names 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.`, diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index b2bdb15..dd5e2f6 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -1,7 +1,7 @@ import { Redis } from 'ioredis'; import { type CacheTag, type CacheTagsProvider } from '../types.js'; -type RedisCacheTagsProviderConfig = { +export type RedisCacheTagsProviderConfig = { /** * Redis connection string. For example, `redis://user:pass@host:port/db`. */ From 524270eee1ac45bdbd63d3eda1a6973bb54af41e Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Mon, 16 Feb 2026 09:38:42 +0100 Subject: [PATCH 22/26] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 4 ++-- src/cache-tags/provider/redis.ts | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e77b3db..af3bf09 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); #### Storage Providers -The package provides two storage backends for cache tags: **Neon (Postgres)** and **Redis**. Both implement the same `CacheTagsProvider` interface. +The package provides three 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 @@ -163,7 +163,7 @@ const provider = new RedisCacheTagsProvider({ }); // After making a DatoCMS query -const queryId = generateQueryId(query, variables); +const queryId = generateQueryId(document, variables); const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']); await provider.storeQueryCacheTags(queryId, cacheTags); diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index dd5e2f6..7cefa8f 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -40,7 +40,13 @@ export class RedisCacheTagsProvider implements CacheTagsProvider { pipeline.sadd(`${this.keyPrefix}${tag}`, queryId); } - await pipeline.exec(); + const results = await pipeline.exec(); + + for (const [error] of results) { + if (error) { + throw error; + } + } } public async queriesReferencingCacheTags(cacheTags: CacheTag[]) { From edaf3080535e3e0379b31b76106e3d82649d7e20 Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Mon, 16 Feb 2026 09:39:37 +0100 Subject: [PATCH 23/26] cleanup: adjust docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af3bf09..cf1607d 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag'); #### Storage Providers -The package provides three 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. +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 From da599ea6fbf7a5a7275f36aef9ac904b7763d79d Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Mon, 16 Feb 2026 09:49:22 +0100 Subject: [PATCH 24/26] fix: error handling in Redis --- src/cache-tags/provider/redis.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index 7cefa8f..a112401 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -41,11 +41,9 @@ export class RedisCacheTagsProvider implements CacheTagsProvider { } const results = await pipeline.exec(); - - for (const [error] of results) { - if (error) { - throw error; - } + const error = results?.find(([err]) => err)?.[0]; + if (error) { + throw error; } } From 3e1c210c5c6283f1c0b510f006db9fd89c638fad Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Mon, 16 Feb 2026 13:43:09 +0100 Subject: [PATCH 25/26] feat: make error handling of cache tags providers configurable (#217) * feat: make error handling of cache tags providers configurable * fix: adjust default * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * erroooooor * errorrrrrs * fix: guard onError callback to prevent masking provider errors (#218) * Initial plan * fix: wrap onError callback in try-catch to prevent masking original errors Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> Co-authored-by: Moreno Feltscher --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: mfeltscher <1352744+mfeltscher@users.noreply.github.com> --- src/cache-tags/provider/base.ts | 48 +++++++++++++ src/cache-tags/provider/neon.ts | 106 +++++++++++++++++++---------- src/cache-tags/provider/redis.ts | 112 ++++++++++++++++++++----------- src/cache-tags/types.ts | 17 +++++ 4 files changed, 207 insertions(+), 76 deletions(-) create mode 100644 src/cache-tags/provider/base.ts diff --git a/src/cache-tags/provider/base.ts b/src/cache-tags/provider/base.ts new file mode 100644 index 0000000..7a7c191 --- /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.debug(`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 index f02ca3e..f49e5e6 100644 --- a/src/cache-tags/provider/neon.ts +++ b/src/cache-tags/provider/neon.ts @@ -1,7 +1,8 @@ import { neon } from '@neondatabase/serverless'; -import { type CacheTag, type CacheTagsProvider } from '../types.js'; +import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js'; +import { AbstractErrorHandlingCacheTagsProvider } from './base.js'; -export type NeonCacheTagsProviderConfig = { +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` @@ -21,61 +22,94 @@ export type NeonCacheTagsProviderConfig = { readonly table: string; }; +export type NeonCacheTagsProviderConfig = NeonCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig; + /** * A `CacheTagsProvider` implementation that uses Neon as the storage backend. */ -export class NeonCacheTagsProvider implements CacheTagsProvider { +export class NeonCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider { private readonly sql; private readonly table; - constructor({ connectionUrl, table }: NeonCacheTagsProviderConfig) { + 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[]) { - 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); + 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 { - 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 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; + }, []); + }, + [], ); - - return rows.reduce((queryIds, row) => { - if (typeof row.query_id === 'string') { - queryIds.push(row.query_id); - } - - return queryIds; - }, []); } public async deleteCacheTags(cacheTags: CacheTag[]) { - 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; + 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 (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0; + return this.wrap( + 'truncateCacheTags', + [], + async () => { + return (await this.sql.query(`DELETE FROM ${this.table}`)).rowCount ?? 0; + }, + 0, + ); } /** diff --git a/src/cache-tags/provider/redis.ts b/src/cache-tags/provider/redis.ts index a112401..78e2e79 100644 --- a/src/cache-tags/provider/redis.ts +++ b/src/cache-tags/provider/redis.ts @@ -1,7 +1,8 @@ import { Redis } from 'ioredis'; -import { type CacheTag, type CacheTagsProvider } from '../types.js'; +import type { CacheTag, CacheTagsProvider, CacheTagsProviderErrorHandlingConfig } from '../types.js'; +import { AbstractErrorHandlingCacheTagsProvider } from './base.js'; -export type RedisCacheTagsProviderConfig = { +type RedisCacheTagsProviderBaseConfig = { /** * Redis connection string. For example, `redis://user:pass@host:port/db`. */ @@ -14,14 +15,17 @@ export type RedisCacheTagsProviderConfig = { readonly keyPrefix?: string; }; +export type RedisCacheTagsProviderConfig = RedisCacheTagsProviderBaseConfig & CacheTagsProviderErrorHandlingConfig; + /** * A `CacheTagsProvider` implementation that uses Redis as the storage backend. */ -export class RedisCacheTagsProvider implements CacheTagsProvider { +export class RedisCacheTagsProvider extends AbstractErrorHandlingCacheTagsProvider implements CacheTagsProvider { private readonly redis; private readonly keyPrefix; - constructor({ connectionUrl, keyPrefix }: RedisCacheTagsProviderConfig) { + constructor({ connectionUrl, keyPrefix, throwOnError, onError }: RedisCacheTagsProviderConfig) { + super('RedisCacheTagsProvider', { throwOnError, onError }); this.redis = new Redis(connectionUrl, { maxRetriesPerRequest: 3, lazyConnect: true, @@ -30,51 +34,79 @@ export class RedisCacheTagsProvider implements CacheTagsProvider { } public async storeQueryCacheTags(queryId: string, cacheTags: CacheTag[]) { - 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; - } + 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[]) { - if (!cacheTags?.length) { - return []; - } - - const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); - - return this.redis.sunion(...keys); + 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[]) { - if (!cacheTags?.length) { - return 0; - } - - const keys = cacheTags.map((tag) => `${this.keyPrefix}${tag}`); - - return this.redis.del(...keys); + 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() { - const keys = await this.getKeys(); - - if (keys.length === 0) { - return 0; - } - - return await this.redis.del(...keys); + return this.wrap( + 'truncateCacheTags', + [], + async () => { + const keys = await this.getKeys(); + + if (keys.length === 0) { + return 0; + } + + return await this.redis.del(...keys); + }, + 0, + ); } /** diff --git a/src/cache-tags/types.ts b/src/cache-tags/types.ts index 8e74898..b874c7b 100644 --- a/src/cache-tags/types.ts +++ b/src/cache-tags/types.ts @@ -64,3 +64,20 @@ export interface CacheTagsProvider { */ 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; +}; From 5dc9692eae429b2c3046a5d7ed7702adf9c180ab Mon Sep 17 00:00:00 2001 From: Moreno Feltscher Date: Mon, 16 Feb 2026 13:49:19 +0100 Subject: [PATCH 26/26] fix: error level and documentation --- README.md | 5 +++++ src/cache-tags/provider/base.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf1607d..bf1bbcd 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ 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 @@ -120,6 +124,7 @@ 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 diff --git a/src/cache-tags/provider/base.ts b/src/cache-tags/provider/base.ts index 7a7c191..ac91938 100644 --- a/src/cache-tags/provider/base.ts +++ b/src/cache-tags/provider/base.ts @@ -40,7 +40,7 @@ export abstract class AbstractErrorHandlingCacheTagsProvider implements CacheTag if (this.throwOnError) { throw error; } - console.debug(`Error occurred in ${provider}.${method}.`, { error, args }); + console.warn(`Error occurred in ${provider}.${method}.`, { error, args }); return fallback; }