Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ jobs:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run build
- run: npm run prettier
- run: npm run lint
- run: npm run typecheck
- run: npm run test
- run: npm run publint
- run: npm run pack:check
- name: semantic release
uses: cycjimmy/semantic-release-action@v6
uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6
with:
semantic_version: 25
extra_plugins: |
@semantic-release/changelog@6
conventional-changelog-conventionalcommits@9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 changes: 44 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,48 @@ jobs:
- run: npm ci
- run: npm run prettier
- run: npm run lint
- run: npm run typecheck
- run: npm run test
- run: npm run publint
- run: npm run pack:check

node20-smoke:
name: Node 20 smoke import
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Smoke-import entry points
run: |
node --input-type=module -e "
await import('./dist/index.js');
await import('./dist/cache-tags/index.js');
await import('./dist/cache-tags/header.js');
await import('./dist/cache-tags/provider/base.js');
await import('./dist/cache-tags/provider/noop.js');
await import('./dist/cache-tags/provider/redis.js');
await import('./dist/cache-tags/provider/neon.js');
console.log('ok');
"

graphql-compat:
name: GraphQL ${{ matrix.graphql }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
graphql: ['15.10.1', '16.14.2', '17.0.2']

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
- run: npm ci
- run: npm install --no-save graphql@${{ matrix.graphql }}
- run: npm run test
1 change: 0 additions & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"preset": "conventionalcommits"
}
],
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github"
]
Expand Down
72 changes: 61 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ npm install @smartive/datocms-utils

Cleans and joins an array of class names (strings and numbers), filtering out undefined and boolean values.

Numbers are kept intentionally, so patterns like `count && 'badge'` with `count === 0` emit a literal `"0"` class. Prefer `count > 0 && 'badge'` when you want to omit the class for zero.

```typescript
import { classNames } from '@smartive/datocms-utils';

Expand All @@ -25,7 +27,7 @@ const className = classNames('btn', isActive && 'btn-active', 42, undefined, 'bt

#### `getTelLink`

Converts a phone number into a `tel:` link by removing non-digit characters (except `+` for international numbers).
Converts a phone number into a `tel:` link by removing non-digit characters, while preserving a leading `+` for international numbers.

```typescript
import { getTelLink } from '@smartive/datocms-utils';
Expand All @@ -40,6 +42,18 @@ Utilities for managing [DatoCMS cache tags](https://www.datocms.com/docs/content

#### Core Utilities

Install [graphql](https://github.com/graphql/graphql-js) when using `generateQueryId` (`@smartive/datocms-utils/cache-tags` depends on it at runtime):

```bash
npm install graphql
```

If you only need to parse the `X-Cache-Tags` header, use the graphql-free subpath:

```typescript
import { parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags/header';
```

```typescript
import { generateQueryId, parseXCacheTagsResponseHeader } from '@smartive/datocms-utils/cache-tags';

Expand All @@ -51,10 +65,20 @@ const tags = parseXCacheTagsResponseHeader('tag-a tag-2 other-tag');
// Result: ['tag-a', 'tag-2', 'other-tag']
```

> **Edge runtime:** `generateQueryId` uses Node.js `crypto.createHash` and cannot run in the Next.js Edge runtime or middleware. Call it from a Node.js runtime route/handler instead.

> **Headers:** Only pass headers that actually affect the GraphQL response (for example locale, DatoCMS environment, or draft-mode credentials). Passing the full `request.headers` object hashes cookies, `user-agent`, tracing headers, and other per-request values, which produces a unique query ID on every request and explodes the tag store.

#### Storage Providers

The package provides multiple storage backends for cache tags: **Neon (Postgres)**, **Redis**, and **Noop**. All implement the same `CacheTagsProvider` interface, with the Noop provider being especially useful for testing and development.

Custom providers can reuse the shared error-handling base class:

```typescript
import { AbstractErrorHandlingCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/base';
```

##### Neon (Postgres) Provider

Use Neon serverless Postgres to store cache tag mappings.
Expand Down Expand Up @@ -85,8 +109,9 @@ import { NeonCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/neon';
const provider = new NeonCacheTagsProvider({
connectionUrl: process.env.DATABASE_URL!,
table: 'query_cache_tags',
throwOnError: false, // Optional: Disable error throwing, defaults to `true`
onError(error, ctx) { // Optional: Custom error callback
throwOnError: false, // Optional: Disable error throwing, defaults to `true`
onError(error, ctx) {
// Optional: Custom error callback
console.error('CacheTagsProvider error', { error, context: ctx });
},
});
Expand All @@ -97,7 +122,7 @@ await provider.storeQueryCacheTags(queryId, ['item:42', 'product']);
// Find queries that reference specific tags
const queries = await provider.queriesReferencingCacheTags(['item:42']);

// Delete specific cache tags
// Delete specific cache tags (also removes all registrations for affected queries)
await provider.deleteCacheTags(['item:42']);

// Clear all cache tags
Expand All @@ -108,6 +133,8 @@ await provider.truncateCacheTags();

Use Redis to store cache tag mappings with better performance for high-traffic applications.

> **Redis Cluster:** The provider uses multi-key commands (`SUNION`, multi-key `DEL`/`UNLINK`). These fail with `CROSSSLOT` under Redis Cluster when keys hash to different slots. Upstash and single-node Redis are supported.

**Setup:**

1. Install [ioredis](https://github.com/redis/ioredis)
Expand All @@ -123,7 +150,10 @@ import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis

const provider = new RedisCacheTagsProvider({
connectionUrl: process.env.REDIS_URL!,
keyPrefix: 'prod:', // Optional: namespace for multi-environment setups
// Strongly recommended: namespaces keys so truncateCacheTags() only deletes this provider's data
keyPrefix: 'prod:',
// Optional: expire keys as a safety net against unbounded growth
ttlSeconds: 60 * 60 * 24 * 30,
throwOnError: process.env.NODE_ENV === 'development', // Optional: Disable error throwing in production - defaults to `true`
});

Expand All @@ -132,6 +162,22 @@ await provider.storeQueryCacheTags(queryId, ['item:42', 'product']);
const queries = await provider.queriesReferencingCacheTags(['item:42']);
await provider.deleteCacheTags(['item:42']);
await provider.truncateCacheTags();

// Close the connection when the provider created it
await provider.dispose();
```

You can also inject an existing ioredis client (useful in Next.js to share one connection). In that case `dispose()` does not close the client:

```typescript
import Redis from 'ioredis';
import { RedisCacheTagsProvider } from '@smartive/datocms-utils/cache-tags/redis';

const redis = new Redis(process.env.REDIS_URL!);
const provider = new RedisCacheTagsProvider({
client: redis,
keyPrefix: 'prod:',
});
```

**Redis connection string examples:**
Expand All @@ -149,12 +195,13 @@ REDIS_URL=redis://localhost:6379

#### `CacheTagsProvider` Interface

Both providers implement:
Providers implement:

- `storeQueryCacheTags(queryId: string, cacheTags: CacheTag[])`: Store cache tags for a query
- `queriesReferencingCacheTags(cacheTags: CacheTag[])`: Get query IDs that reference any of the specified tags
- `deleteCacheTags(cacheTags: CacheTag[])`: Delete specific cache tags
- `truncateCacheTags()`: Wipe all cache tags (use with caution)
- `deleteCacheTags(cacheTags: CacheTag[])`: Delete specific cache tags and all query registrations that reference them; returns the number of `(query, tag)` registrations removed
- `truncateCacheTags()`: Wipe all cache tags (use with caution; Redis requires a non-empty `keyPrefix`)
- `dispose?()`: Optional cleanup for owned resources (Redis closes its connection when it created one)

### Complete Example

Expand All @@ -167,9 +214,12 @@ const provider = new RedisCacheTagsProvider({
keyPrefix: 'myapp:',
});

// After making a DatoCMS query
const queryId = generateQueryId(document, variables, request.headers);
const cacheTags = parseXCacheTagsResponseHeader(response.headers['x-cache-tags']);
// After making a DatoCMS query — only hash headers that affect the response
const queryId = generateQueryId(document, variables, {
authorization: request.headers.get('authorization') ?? '',
'x-environment': request.headers.get('x-environment') ?? '',
});
const cacheTags = parseXCacheTagsResponseHeader(response.headers.get('x-cache-tags'));
await provider.storeQueryCacheTags(queryId, cacheTags);

// When handling DatoCMS webhook for cache invalidation
Expand Down
10 changes: 9 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { config } from '@smartive/eslint-config';
import tseslint from 'typescript-eslint';

export default config('typescript');
export default tseslint.config(...config('typescript'), {
files: ['test/**/*.ts'],
extends: [tseslint.configs.disableTypeChecked],
rules: {
// Tests exercise the compiled package; dist/ is produced by `npm run build` before `npm test`.
'import/no-unresolved': ['error', { ignore: ['^\\.\\./dist/'] }],
},
});
Loading