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
27 changes: 27 additions & 0 deletions .changeset/xpenser-framework-affordances.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@cleverbrush/client': minor
'@cleverbrush/env': minor
'@cleverbrush/knex-schema': minor
'@cleverbrush/orm': minor
'@cleverbrush/orm-cli': minor
'@cleverbrush/server': minor
---

Add framework affordances discovered while reviewing Xpenser:

- `@cleverbrush/env`: add `envBoolean()` for environment-style boolean values
such as `1`, `0`, `yes`, `no`, `on`, and `off`.
- `@cleverbrush/server`: parse `application/x-www-form-urlencoded` request
bodies by default, add `ActionResult.raw()`, and allow ordered
authentication fallback with `trySchemes`.
- `@cleverbrush/client`: add `externalCacheTags()` to
`@cleverbrush/client/cache` for framework-agnostic external cache tag
invalidation, including Next.js `revalidateTag()` as one supported callback.
- `@cleverbrush/knex-schema` / `@cleverbrush/orm`: add
`InferDatabaseRow` / `InferDatabaseValue` row typing helpers and support raw
update expressions plus conditional `where` callbacks in
`onConflict().merge()`.
- `@cleverbrush/orm-cli`: add read-only `cb-orm validate` for live schema drift
checks.

Docs and package READMEs were updated; see https://docs.cleverbrush.com.
30 changes: 30 additions & 0 deletions libs/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,36 @@ automatically invalidate the query cache for the affected group — no manual
See the [server-side cache tags](/server#cache-tags) section for how to declare
tags on your endpoints.

### External Cache Tags — `@cleverbrush/client/cache`

Use `externalCacheTags()` to bridge endpoint `.clearsCacheTag()` metadata to
any cache system that accepts tag invalidation:

```ts
import { createClient } from '@cleverbrush/client';
import { externalCacheTags } from '@cleverbrush/client/cache';

const client = createClient(api, {
middlewares: [
externalCacheTags({
invalidateTag: async tag => externalCache.invalidate(tag),
}),
],
});
```

For Next.js, pass `revalidateTag` as the invalidation callback:

```ts
import { revalidateTag } from 'next/cache';

externalCacheTags({ invalidateTag: revalidateTag });
```

The middleware runs after successful `POST`, `PUT`, `PATCH`, and `DELETE`
responses. Dynamic tags invalidate both the base tag name and the computed key
by default, for example `expense` and `expense:id=42`.

## Per-Call Overrides

Override middleware options for individual calls:
Expand Down
15 changes: 13 additions & 2 deletions libs/client/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
export type { CacheOptions } from './middlewares/cache.js';
export { throttlingCache } from './middlewares/cache.js';
export type { CacheTagMiddlewareOptions } from './middlewares/cacheTags.js';
export { cacheTags } from './middlewares/cacheTags.js';
export type {
CacheTagMiddlewareOptions,
CacheTagRoot,
SerializedCacheTag
} from './middlewares/cacheTags.js';
export {
cacheTags,
computeCacheTagKey,
createCacheTagRoot,
isMutatingMethod
} from './middlewares/cacheTags.js';
export type { ExternalCacheTagsOptions } from './middlewares/externalCacheTags.js';
export { externalCacheTags } from './middlewares/externalCacheTags.js';
56 changes: 33 additions & 23 deletions libs/client/src/middlewares/cacheTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,23 @@ export interface CacheTagMiddlewareOptions {
/**
* The root object passed to each `CacheTagPropertyAccessor.getValue()` call.
*/
interface TagRoot {
export interface CacheTagRoot {
params: Record<string, unknown>;
body: unknown;
query: Record<string, unknown>;
headers: Record<string, string>;
}

/** Shape of a serialised cache tag from endpoint metadata. */
interface SerializedCacheTag {
/**
* Shape of a serialized cache tag from endpoint metadata.
*/
export interface SerializedCacheTag {
name: string;
properties: Readonly<
Record<
string,
{
getValue(root: TagRoot): {
getValue(root: CacheTagRoot): {
value?: unknown;
success: boolean;
};
Expand All @@ -82,7 +84,10 @@ interface CacheEntry {
expiresAt: number;
}

function isMutating(method: string): boolean {
/**
* Return `true` for HTTP methods that mutate server state.
*/
export function isMutatingMethod(method: string): boolean {
return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase());
}

Expand All @@ -93,7 +98,10 @@ function isMutating(method: string): boolean {
* - Tags with properties produce `name:key1=val1,key2=val2` where
* keys are sorted alphabetically for determinism.
*/
function computeKey(tag: SerializedCacheTag, root: TagRoot): string {
export function computeCacheTagKey(
tag: SerializedCacheTag,
root: CacheTagRoot
): string {
const entries = Object.entries(tag.properties);

if (entries.length === 0) {
Expand All @@ -117,6 +125,18 @@ function computeKey(tag: SerializedCacheTag, root: TagRoot): string {
return `${tag.name}:${parts.join(',')}`;
}

/**
* Build the cache-tag accessor root from endpoint metadata.
*/
export function createCacheTagRoot(meta: EndpointMeta): CacheTagRoot {
return {
params: (meta.params as Record<string, unknown>) ?? {},
body: meta.body,
query: (meta.query as Record<string, unknown>) ?? {},
headers: (meta.headers as Record<string, string>) ?? {}
};
}

// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -149,16 +169,11 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware {
const method = (init.method ?? 'GET').toUpperCase();

// -- Invalidation on mutating requests --
if (isMutating(method) && tags && tags.length > 0) {
const root: TagRoot = {
params: (meta?.params as Record<string, unknown>) ?? {},
body: meta?.body,
query: (meta?.query as Record<string, unknown>) ?? {},
headers: (meta?.headers as Record<string, string>) ?? {}
};
if (isMutatingMethod(method) && meta && tags && tags.length > 0) {
const root = createCacheTagRoot(meta);

for (const tag of tags) {
const tagKey = computeKey(tag, root);
const tagKey = computeCacheTagKey(tag, root);
// Invalidate the exact key and any prefixed variants
// (tag name prefix match handles dynamic property variants
// when the mutation didn't provide the same properties).
Expand All @@ -174,18 +189,13 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware {
}

// -- Cache lookup for GET requests --
if (method === 'GET' && tags && tags.length > 0) {
const root: TagRoot = {
params: (meta?.params as Record<string, unknown>) ?? {},
body: meta?.body,
query: (meta?.query as Record<string, unknown>) ?? {},
headers: (meta?.headers as Record<string, string>) ?? {}
};
if (method === 'GET' && meta && tags && tags.length > 0) {
const root = createCacheTagRoot(meta);

let foundEntry: CacheEntry | undefined;

for (const tag of tags) {
const cacheKey = computeKey(tag, root);
const cacheKey = computeCacheTagKey(tag, root);
const entry = cache.get(cacheKey);
if (entry && entry.expiresAt > Date.now()) {
foundEntry = entry;
Expand All @@ -203,7 +213,7 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware {
return next(url, init).then(response => {
if (condition(response)) {
for (const tag of tags) {
const cacheKey = computeKey(tag, root);
const cacheKey = computeCacheTagKey(tag, root);
const ttl =
ttlByTag[tag.name] !== undefined
? ttlByTag[tag.name]
Expand Down
108 changes: 108 additions & 0 deletions libs/client/src/middlewares/externalCacheTags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, test, vi } from 'vitest';
import type { EndpointMeta, FetchLike } from '../middleware.js';
import { externalCacheTags } from './externalCacheTags.js';

function makeMeta(overrides: Partial<EndpointMeta> = {}): EndpointMeta {
return {
group: 'expenses',
endpoint: 'update',
method: 'PATCH',
path: '/api/expenses/:id',
basePath: '/api/expenses/:id',
collectionPath: '/api/expenses',
baseUrl: '',
fullCollectionUrl: '/api/expenses',
pathParamNames: ['id'],
params: { id: 42 },
body: undefined,
query: {},
headers: {},
operationId: null,
tags: [],
cacheTags: [
{
name: 'expense',
properties: {
id: {
getValue: root => ({
success: true,
value: root.params.id
})
}
}
}
],
...overrides
};
}

describe('externalCacheTags middleware', () => {
test('invalidates base and dynamic tags after a successful mutation', async () => {
const fetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response(null, { status: 204 }));
const invalidateTag = vi.fn();
const middleware = externalCacheTags({ invalidateTag })(fetch);

const response = await middleware('/api/expenses/42', {
method: 'PATCH',
__endpointMeta: makeMeta()
} as any);

expect(response.status).toBe(204);
expect(fetch).toHaveBeenCalledTimes(1);
expect(invalidateTag).toHaveBeenCalledWith('expense');
expect(invalidateTag).toHaveBeenCalledWith('expense:id=42');
expect(invalidateTag).toHaveBeenCalledTimes(2);
});

test('does not invalidate failed mutation responses by default', async () => {
const fetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response('bad', { status: 400 }));
const invalidateTag = vi.fn();
const middleware = externalCacheTags({ invalidateTag })(fetch);

await middleware('/api/expenses/42', {
method: 'PATCH',
__endpointMeta: makeMeta()
} as any);

expect(invalidateTag).not.toHaveBeenCalled();
});

test('does not invalidate reads or requests without cache metadata', async () => {
const fetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response(null, { status: 200 }));
const invalidateTag = vi.fn();
const middleware = externalCacheTags({ invalidateTag })(fetch);

await middleware('/api/expenses/42', {
method: 'GET',
__endpointMeta: makeMeta({ method: 'GET' })
} as any);
await middleware('/api/expenses/42', { method: 'PATCH' });

expect(invalidateTag).not.toHaveBeenCalled();
});

test('can invalidate only computed dynamic keys', async () => {
const fetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response(null, { status: 200 }));
const invalidateTag = vi.fn();
const middleware = externalCacheTags({
invalidateTag,
invalidateBaseTags: false
})(fetch);

await middleware('/api/expenses/42', {
method: 'DELETE',
__endpointMeta: makeMeta()
} as any);

expect(invalidateTag).toHaveBeenCalledWith('expense:id=42');
expect(invalidateTag).toHaveBeenCalledTimes(1);
});
});
Loading
Loading