diff --git a/.changeset/quiet-pans-create.md b/.changeset/quiet-pans-create.md new file mode 100644 index 00000000..b5ae938d --- /dev/null +++ b/.changeset/quiet-pans-create.md @@ -0,0 +1,29 @@ +--- +'@cleverbrush/client': minor +'@cleverbrush/server': minor +--- + +feat(client): optimistic update + offline support + tag-based cache invalidation + +- Add `optimisticUpdate()` middleware — tags mutations with IDs and tracks network failures +- Add `offlineQueue()` middleware — queues mutations when offline, replays on reconnect +- Add `useOptimisticMutation()` React hook — automatic TanStack Query cache snapshot/rollback +- Add `OfflineError` class extending `NetworkError` +- Extend `PerCallOverrides` with `optimisticUpdate` and `offlineQueue` keys + +feat(server, client): tag-based cache invalidation via `.clearsCacheTag()` endpoint annotations + +- Add `.clearsCacheTag(name[, selector])` to `EndpointBuilder` — declare cache tags with optional property selectors +- Add `CacheTagDefinition`, `CacheTagPropertyAccessor`, `createCacheTagTree`, `serializeTag`, `computeCacheKey` to `@cleverbrush/server` +- Add `cacheTags()` middleware to `@cleverbrush/client/cache` — tag-keyed HTTP caching with automatic invalidation on mutations +- Add `CacheTagMiddlewareOptions` with `ttlByTag`, `defaultTtl`, `condition` +- Add `cacheTags` and `headers` fields to `EndpointMeta` for middleware introspection +- Add implicit TanStack Query invalidation in `useMutation` when endpoint declares cache tags +- Add `CacheTagSelector` type for IDE autocomplete in `.clearsCacheTag()` selector callbacks + +feat(server, client): request idempotency middleware + +- Add `idempotency()` server middleware — stores responses keyed by `X-Idempotency-Key` header, replays stored response on duplicate keys +- Add `idempotency()` client middleware — auto-generates UUID v4 as `X-Idempotency-Key` header for mutating requests, preserves key across retries +- Export `IdempotencyOptions` (client) and `ServerIdempotencyOptions` (server) +- Add `cacheResponse()` server middleware — tag-based server-side response caching with handler-level invalidation diff --git a/demos/todo-backend/Dockerfile b/demos/todo-backend/Dockerfile index 949cba78..3f11b1bd 100644 --- a/demos/todo-backend/Dockerfile +++ b/demos/todo-backend/Dockerfile @@ -5,7 +5,7 @@ FROM node:22-alpine AS builder WORKDIR /app # Copy root workspace manifest for npm workspaces resolution -COPY package.json package-lock.json* turbo.json tsconfig.build.json ./ +COPY package.json package-lock.json* turbo.json tsconfig.json tsconfig.build.json ./ # Copy all workspace package.json files for dependency resolution COPY libs/async/package.json ./libs/async/ @@ -26,6 +26,8 @@ COPY libs/server-openapi/package.json ./libs/server-openapi/ COPY libs/otel/package.json ./libs/otel/ COPY libs/client/package.json ./libs/client/ COPY libs/benchmarks/package.json ./libs/benchmarks/ +COPY libs/orm/package.json ./libs/orm/ +COPY libs/orm-cli/package.json ./libs/orm-cli/ COPY demos/todo-backend/package.json ./demos/todo-backend/ # Install all workspace dependencies diff --git a/demos/todo-backend/src/api/contract.ts b/demos/todo-backend/src/api/contract.ts index bd5cc6f8..4b814715 100644 --- a/demos/todo-backend/src/api/contract.ts +++ b/demos/todo-backend/src/api/contract.ts @@ -82,9 +82,13 @@ export const api = defineApi({ list: todosResource .get() .query(TodoListQuerySchema) + .cacheTag('todo-list', p => ({ + page: p.query.page, + limit: p.query.limit + })) .responses({ 200: array(TodoResponseSchema) }), - get: todosResource.get(ById).responses({ + get: todosResource.get(ById).cacheTag('todo', p => ({ id: p.params.id })).responses({ 200: TodoResponseSchema, 403: ErrorResponseSchema, 404: ErrorResponseSchema @@ -96,6 +100,7 @@ export const api = defineApi({ id: number().coerce() })`/${t => t.id}/with-author` ) + .cacheTag('todo-author', p => ({ id: p.params.id })) .responses({ 200: TodoWithAuthorResponseSchema, 403: ErrorResponseSchema, @@ -105,15 +110,20 @@ export const api = defineApi({ create: todosResource .post() .body(CreateTodoBodySchema) + .clearsCacheTag('todo-list') .responses({ 201: TodoResponseSchema }), - update: todosResource.patch(ById).body(UpdateTodoBodySchema).responses({ + update: todosResource.patch(ById).body(UpdateTodoBodySchema) + .clearsCacheTag('todo-list') + .clearsCacheTag('todo', p => ({ id: p.params.id })).responses({ 200: TodoResponseSchema, 403: ErrorResponseSchema, 404: ErrorResponseSchema }), - delete: todosResource.delete(ById).responses({ + delete: todosResource.delete(ById) + .clearsCacheTag('todo-list') + .clearsCacheTag('todo', p => ({ id: p.params.id })).responses({ 204: null, 403: ErrorResponseSchema, 404: ErrorResponseSchema @@ -153,6 +163,8 @@ export const api = defineApi({ complete: todosResource .post(route({ id: number().coerce() })`/${t => t.id}/complete`) .headers(CompletionRequestHeadersSchema) + .clearsCacheTag('todo-list') + .clearsCacheTag('todo', p => ({ id: p.params.id })) .responses({ 200: TodoResponseSchema, 409: ErrorResponseSchema, @@ -168,6 +180,7 @@ export const api = defineApi({ .get( route({ id: number().coerce() })`/${t => t.id}/activity` ) + .cacheTag('todo-activity', p => ({ id: p.params.id })) .responses({ 200: array(TodoActivityResponseSchema), 403: ErrorResponseSchema, @@ -179,15 +192,24 @@ export const api = defineApi({ list: usersResource .get() .query(PaginationQuerySchema) + .cacheTag('user-list', p => ({ + page: p.query.page, + limit: p.query.limit + })) .responses({ 200: array(UserResponseSchema) }), - delete: usersResource.delete(ById).responses({ + delete: usersResource.delete(ById) + .clearsCacheTag('user-list') + .clearsCacheTag('user-profile') + .responses({ 204: null, 400: ErrorResponseSchema, 404: ErrorResponseSchema }), - me: usersResource.get(route({})`/me`).returns(UserResponseSchema) + me: usersResource.get(route({})`/me`) + .cacheTag('user-profile') + .returns(UserResponseSchema) }, webhooks: { @@ -204,9 +226,14 @@ export const api = defineApi({ listAll: activityResource .get() .query(object({ limit: number().coerce().optional() })) + .cacheTag('activity-list', p => ({ + limit: p.query.limit + })) .responses({ 200: array(TodoActivityResponseSchema) }), - delete: activityResource.delete(ById).responses({ + delete: activityResource.delete(ById) + .clearsCacheTag('activity-list') + .responses({ 204: null, 404: ErrorResponseSchema }) diff --git a/demos/todo-backend/src/api/endpoints.ts b/demos/todo-backend/src/api/endpoints.ts index 550733f4..83dd52c4 100644 --- a/demos/todo-backend/src/api/endpoints.ts +++ b/demos/todo-backend/src/api/endpoints.ts @@ -1,11 +1,16 @@ -import { defineWebhook } from '@cleverbrush/server'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { POLYMORPHIC_TYPE_BRAND } from '@cleverbrush/orm'; -import { DbToken, KnexToken, LoggerToken, TrackedDbToken } from '../di/tokens.js'; +import { defineWebhook } from '@cleverbrush/server'; +import { + DbToken, + KnexToken, + LoggerToken, + TrackedDbToken +} from '../di/tokens.js'; import { api } from './contract.js'; import { type ImportTodosBody, - ImportTodosBodySchema, + type ImportTodosBodySchema, PrincipalSchema, TodoNotificationPayloadSchema, WebhookAckSchema diff --git a/demos/todo-frontend/Dockerfile b/demos/todo-frontend/Dockerfile index 1ceb83b6..9fd23c3c 100644 --- a/demos/todo-frontend/Dockerfile +++ b/demos/todo-frontend/Dockerfile @@ -24,6 +24,10 @@ COPY libs/server/package.json ./libs/server/ COPY libs/server-openapi/package.json ./libs/server-openapi/ COPY libs/client/package.json ./libs/client/ COPY libs/benchmarks/package.json ./libs/benchmarks/ +COPY libs/log/package.json ./libs/log/ +COPY libs/otel/package.json ./libs/otel/ +COPY libs/orm/package.json ./libs/orm/ +COPY libs/orm-cli/package.json ./libs/orm-cli/ COPY demos/todo-backend/package.json ./demos/todo-backend/ COPY demos/todo-frontend/package.json ./demos/todo-frontend/ diff --git a/demos/todo-frontend/src/App.tsx b/demos/todo-frontend/src/App.tsx index f2c0ee45..2f8efe3d 100644 --- a/demos/todo-frontend/src/App.tsx +++ b/demos/todo-frontend/src/App.tsx @@ -23,6 +23,8 @@ const BatchingPage = lazy(() => import('./features/batching/BatchingPage')); const ReactQueryPage = lazy(() => import('./features/react-query/ReactQueryPage')); const LivePage = lazy(() => import('./features/live/LivePage')); const ActivityFeedPage = lazy(() => import('./features/activity/ActivityPage')); +const CachePage = lazy(() => import('./features/cache/CachePage')); +const IdempotencyPage = lazy(() => import('./features/idempotency/IdempotencyPage')); const PageFallback = () => ( @@ -51,6 +53,8 @@ const router = createBrowserRouter([ { path: '/react-query', element: }> }, { path: '/live', element: }> }, { path: '/activity', element: }> }, + { path: '/cache', element: }> }, + { path: '/idempotency', element: }> }, { element: , children: [ diff --git a/demos/todo-frontend/src/api/client.ts b/demos/todo-frontend/src/api/client.ts index 59188500..a1a61889 100644 --- a/demos/todo-frontend/src/api/client.ts +++ b/demos/todo-frontend/src/api/client.ts @@ -21,8 +21,11 @@ import { createClient } from '@cleverbrush/client/react'; import { retry } from '@cleverbrush/client/retry'; import { timeout } from '@cleverbrush/client/timeout'; import { dedupe } from '@cleverbrush/client/dedupe'; -import { throttlingCache } from '@cleverbrush/client/cache'; +import { idempotency } from '@cleverbrush/client/idempotency'; +import { cacheTags } from '@cleverbrush/client/cache'; import { batching } from '@cleverbrush/client/batching'; +import { optimisticUpdate } from '@cleverbrush/client/optimistic-update'; +import { offlineQueue } from '@cleverbrush/client/offline-queue'; import { api } from '@cleverbrush/todo-backend/contract'; import { loadToken, setToken } from '../lib/http-client'; @@ -34,12 +37,18 @@ const BASE_URL = import.meta.env.VITE_API_URL ?? ''; * Groups: `auth`, `todos`, `users`, `webhooks`, `admin`, `demo`. * * Resilience middlewares are applied in order: - * 1. **retry** — retries failed requests up to 2 times with exponential backoff - * 2. **timeout** — aborts requests exceeding 10 seconds - * 3. **dedupe** — coalesces identical in-flight GET requests - * 4. **cache** — serves cached GET responses within a 2-second TTL - * 5. **batching** — coalesces concurrent requests into a single `POST /__batch` + * 1. **offlineQueue** — queues mutations when offline, replays on reconnect (outermost) + * 2. **idempotency** — adds X-Idempotency-Key to mutations for server deduplication + * 3. **retry** — retries failed requests (preserves idempotency key across retries) + * 4. **timeout** — aborts requests exceeding 10 seconds + * 5. **dedupe** — coalesces identical in-flight GET requests + * 6. **cacheTags** — tag-based caching and auto-invalidation + * 7. **batching** — coalesces concurrent requests into a single `POST /__batch` + * 8. **optimisticUpdate** — tags mutations and tracks network failures (innermost) */ + +export const offlineQueueStore = { queue: [], isOnline: true, isReplaying: false }; + export const client = createClient(api, { baseUrl: BASE_URL, getToken: () => loadToken(), @@ -51,10 +60,15 @@ export const client = createClient(api, { } }, middlewares: [ + offlineQueue({ store: offlineQueueStore }), + idempotency(), retry({ limit: 2, retryOnTimeout: true }), timeout({ timeout: 10_000 }), dedupe(), - throttlingCache({ throttle: 2000 }), - batching({ maxSize: 10, windowMs: 10 }) - ] + cacheTags({ + defaultTtl: 5000 + }), + batching({ maxSize: 10, windowMs: 10 }), + optimisticUpdate() + ] as any }); diff --git a/demos/todo-frontend/src/components/Layout.tsx b/demos/todo-frontend/src/components/Layout.tsx index 5714dd9a..edaeca93 100644 --- a/demos/todo-frontend/src/components/Layout.tsx +++ b/demos/todo-frontend/src/components/Layout.tsx @@ -25,6 +25,8 @@ const navItems: NavItem[] = [ { to: '/resilience', label: 'Resilience', emoji: '🛡️' }, { to: '/batching', label: 'Batching', emoji: '📦' }, { to: '/react-query', label: 'React Query', emoji: '⚡' }, + { to: '/cache', label: 'Cache', emoji: '🗄️' }, + { to: '/idempotency', label: 'Idempotency', emoji: '🔑' }, { to: '/live', label: 'Live', emoji: '📡' }, { to: '/activity', label: 'Activity Feed', emoji: '🔴' }, { to: '/webhooks', label: 'Webhooks', emoji: '🔔' }, diff --git a/demos/todo-frontend/src/features/cache/CachePage.tsx b/demos/todo-frontend/src/features/cache/CachePage.tsx new file mode 100644 index 00000000..e2024f17 --- /dev/null +++ b/demos/todo-frontend/src/features/cache/CachePage.tsx @@ -0,0 +1,352 @@ +import { useState, useCallback } from 'react'; +import { + Badge, + Box, + Button, + Callout, + Card, + Code, + Flex, + Heading, + Separator, + Text, + TextField +} from '@radix-ui/themes'; +import { client } from '../../api/client'; + +// ── Types ──────────────────────────────────────────────────────────────── + +type LogEntry = { + step: number; + label: string; + expected: string; + result: string; + resultColor: 'blue' | 'green' | 'amber' | 'red'; +}; + +// ── Page ───────────────────────────────────────────────────────────────── + +export default function CachePage() { + const [todoId, setTodoId] = useState(1); + const [running, setRunning] = useState(false); + const [log, setLog] = useState([]); + + const run = useCallback(async () => { + setRunning(true); + setLog([]); + const entries: LogEntry[] = []; + + function add( + step: number, + label: string, + expected: string, + result: string, + resultColor: LogEntry['resultColor'] = 'blue' + ) { + entries.push({ step, label, expected, result, resultColor }); + setLog([...entries]); + } + + const id = todoId; + let start: number; + + // Step 1 — warm the cache + start = performance.now(); + try { + await client.todos.get({ params: { id } }); + const ms = Math.round(performance.now() - start); + add( + 1, + `Fetch GET /api/todos/${id}`, + 'Network request in DevTools', + `Completed in ${ms}ms — network fetch`, + 'blue' + ); + } catch (err: any) { + add( + 1, + `Fetch GET /api/todos/${id}`, + 'Network request', + `Error: ${err.message}`, + 'red' + ); + setRunning(false); + return; + } + + await new Promise((r) => setTimeout(r, 100)); + + // Step 2 — cache hit + start = performance.now(); + await client.todos.get({ params: { id } }); + const ms2 = Math.round(performance.now() - start); + add( + 2, + `Fetch GET /api/todos/${id} again`, + 'NO request in DevTools — served from cacheTags middleware cache', + `Completed in ${ms2}ms`, + 'green' + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Step 3 — list cache hit (after being warmed by previous fetch?) + start = performance.now(); + await client.todos.list({ query: {} }); + const ms3 = Math.round(performance.now() - start); + add( + 3, + 'Fetch GET /api/todos (list)', + 'Network request in DevTools (first list fetch)', + `Completed in ${ms3}ms`, + 'blue' + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Step 4 — list cache hit (warmed) + start = performance.now(); + await client.todos.list({ query: {} }); + const ms4 = Math.round(performance.now() - start); + add( + 4, + 'Fetch GET /api/todos (list) again', + 'NO request — cache hit for "todo-list" tag', + `Completed in ${ms4}ms`, + 'green' + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Step 5 — mutate + start = performance.now(); + try { + await client.todos.update({ + params: { id }, + body: { title: `Cache-test ${Date.now()}` } + }); + const ms5 = Math.round(performance.now() - start); + add( + 5, + `Mutate PATCH /api/todos/${id}`, + 'Network request in DevTools. Invalidates "todo" and "todo-list" cache entries.', + `Completed in ${ms5}ms`, + 'amber' + ); + } catch (err: any) { + add( + 5, + `Mutate PATCH /api/todos/${id}`, + 'Network request, invalidates cache', + `Error: ${err.message}`, + 'red' + ); + } + + await new Promise((r) => setTimeout(r, 100)); + + // Step 6 — fetch after invalidate (should be network) + start = performance.now(); + await client.todos.get({ params: { id } }); + const ms6 = Math.round(performance.now() - start); + add( + 6, + `Fetch GET /api/todos/${id} after mutation`, + 'Network request in DevTools — "todo" cache was invalidated by step 5', + `Completed in ${ms6}ms`, + 'green' + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Step 7 — list after mutation (should also be invalidated) + start = performance.now(); + await client.todos.list({ query: {} }); + const ms7 = Math.round(performance.now() - start); + add( + 7, + 'Fetch GET /api/todos (list) after mutation', + 'Network request in DevTools — "todo-list" cache was invalidated by step 5', + `Completed in ${ms7}ms`, + 'green' + ); + + setRunning(false); + }, [todoId]); + + const colorMap: Record = { + blue: 'var(--blue-9)', + green: 'var(--green-9)', + amber: 'var(--amber-9)', + red: 'var(--red-9)' + }; + + return ( + + + 🗄️ Cache Tags + + + Demonstrates tag-based HTTP caching with the{' '} + cacheTags middleware. The demo runs a sequence of + fetch + mutate operations and shows what to expect in your + browser's DevTools Network tab. + + + + + Open DevTools → Network tab before running. Cache hits + show no HTTP request at all — the response + comes from the middleware's in-memory cache. + + + + + + + + + Todo ID + + + setTodoId(Number(e.target.value) || 1) + } + /> + + + + + + {log.length > 0 && ( + + + Run Log + + + + {log.map((entry) => ( + + + + Step {entry.step} + + + {entry.label} + + + + + + + Expected + + + {entry.expected} + + + + + Result + + + {entry.result} + + + + + ))} + + + )} + + + + What's Happening + + + + + Steps 1–2: The first{' '} + GET /api/todos/:id fetches from the + network and populates the{' '} + todo cache entry. The second call + within the 5s TTL hits the in-memory cache — no + network request is made. + + + Steps 3–4: Same pattern for{' '} + GET /api/todos with the{' '} + todo-list tag. + + + Step 5: A{' '} + PATCH /api/todos/:id mutation triggers + cache invalidation for both the{' '} + todo tag (matching the entity) and the{' '} + todo-list tag (the collection). The + middleware deletes the matching cache entries. + + + Steps 6–7: After invalidation, the + next fetch for both entity and list hits the + network again — the cache was cleared. + + + + + + ); +} diff --git a/demos/todo-frontend/src/features/idempotency/IdempotencyPage.tsx b/demos/todo-frontend/src/features/idempotency/IdempotencyPage.tsx new file mode 100644 index 00000000..bd5f9b6b --- /dev/null +++ b/demos/todo-frontend/src/features/idempotency/IdempotencyPage.tsx @@ -0,0 +1,232 @@ +import { useState, useCallback } from 'react'; +import { + Badge, + Box, + Button, + Card, + Code, + Flex, + Heading, + Separator, + Text, + TextField +} from '@radix-ui/themes'; +import { client } from '../../api/client'; + +// ── Types ──────────────────────────────────────────────────────────────── + +type LogEntry = { + id: number; + step: number; + label: string; + result: string; + kind: 'info' | 'success' | 'error'; +}; + +// ── Page ───────────────────────────────────────────────────────────────── + +export default function IdempotencyPage() { + const [title, setTitle] = useState('Test todo'); + const [running, setRunning] = useState(false); + const [log, setLog] = useState([]); + + const run = useCallback(async () => { + setRunning(true); + setLog([]); + const entries: LogEntry[] = []; + + function add( + step: number, + label: string, + result: string, + kind: LogEntry['kind'] = 'info' + ) { + entries.push({ + id: entries.length + 1, + step, + label, + result, + kind + }); + setLog([...entries]); + } + + // Step 1: Create a todo — client adds X-Idempotency-Key automatically + let createdId: number | undefined; + try { + const res = await client.todos.create({ + body: { title: title.trim() } + }); + createdId = res.id; + add( + 1, + 'POST /api/todos (create)', + `Created todo #${res.id} with title "${res.title}" — idempotency key auto-generated`, + 'success' + ); + } catch (err: any) { + add( + 1, + 'POST /api/todos (create)', + `Error: ${err.message}`, + 'error' + ); + setRunning(false); + return; + } + + // Step 2: Create the same todo again (different key, should succeed) + try { + const res2 = await client.todos.create({ + body: { title: title.trim() } + }); + add( + 2, + 'POST /api/todos (create) again', + `Created todo #${res2.id} — different idempotency key, different request (expected: two todos exist)`, + 'success' + ); + } catch (err: any) { + add( + 2, + 'POST /api/todos (create) again', + `Error: ${err.message}`, + 'error' + ); + } + + // Step 3: Show what's in the Network tab + add( + 3, + 'Check DevTools Network tab', + 'Both POST requests have X-Idempotency-Key header. Server stores the response — if the same key is replayed, the handler does not execute again.', + 'info' + ); + + setRunning(false); + }, [title]); + + return ( + + + 🔑 Idempotency + + + Demonstrates the idempotency client middleware — + every mutating request automatically receives an{' '} + X-Idempotency-Key header so the server can + deduplicate replays. + + + + + + + + Todo title + + setTitle(e.target.value)} + /> + + + + + + {log.length > 0 && ( + + + Run Log + + + + {log.map((entry) => ( + + + Step {entry.step} + + + + {entry.label} + + + {entry.result} + + + + ))} + + + )} + + + + How It Works + + + + + Client: The{' '} + idempotency() middleware adds a{' '} + X-Idempotency-Key header (UUID v4) to + every mutating request. When a request is retried, + the same key is reused — so retries are + deduplicated server-side. + + + Server: The{' '} + idempotency() middleware stores + responses keyed by the header value. Replayed + requests return the stored response instead of + re-executing the handler. + + + TTL: Stored responses expire + after 24 hours by default. Periodic cleanup runs + every 60 seconds. + + + + + + ); +} diff --git a/demos/todo-frontend/src/features/react-query/ReactQueryPage.tsx b/demos/todo-frontend/src/features/react-query/ReactQueryPage.tsx index 2b894555..d0cdd4e2 100644 --- a/demos/todo-frontend/src/features/react-query/ReactQueryPage.tsx +++ b/demos/todo-frontend/src/features/react-query/ReactQueryPage.tsx @@ -14,7 +14,8 @@ import { } from '@radix-ui/themes'; import { useQueryClient } from '@tanstack/react-query'; import { isApiError, isWebError } from '@cleverbrush/client'; -import { client } from '../../api/client'; +import { useOptimisticMutation } from '@cleverbrush/client/react'; +import { client, offlineQueueStore } from '../../api/client'; // ── Shared Helpers ────────────────────────────────────────────────────── @@ -154,24 +155,20 @@ function ParameterQueryDemo() { ); } -// ── Demo 3: useMutation + Cache Invalidation ────────────────────────── +// ── Demo 3: useMutation + Implicit Cache Invalidation ────────────────── function MutationDemo() { - const queryClient = useQueryClient(); const [title, setTitle] = useState(''); const mutation = client.todos.create.useMutation({ onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: client.todos.queryKey() - }); setTitle(''); } }); return ( )} - {'client.todos.create.useMutation({ onSuccess: () => invalidate })'} + cache invalidation is implicit via cacheTags middleware ); } -// ── Demo 4: Optimistic Toggle ───────────────────────────────────────── +// ── Demo 4: Optimistic Toggle (useOptimisticMutation) ───────────────── function OptimisticToggleDemo() { - const queryClient = useQueryClient(); const { data } = client.todos.list.useQuery(); - const toggleMutation = client.todos.update.useMutation({ - onMutate: async (variables: any) => { - await queryClient.cancelQueries({ - queryKey: client.todos.queryKey() - }); - const key = client.todos.list.queryKey(); - const previous = queryClient.getQueryData(key); - queryClient.setQueryData(key, (old: any[]) => - old?.map((t: any) => - t.id === variables.params.id - ? { ...t, completed: variables.body.completed } - : t - ) - ); - return { previous }; - }, - onError: (_err: unknown, _vars: unknown, context: any) => { - if (context?.previous) { - queryClient.setQueryData( - client.todos.list.queryKey(), - context.previous - ); - } - }, - onSettled: () => { - queryClient.invalidateQueries({ - queryKey: client.todos.queryKey() - }); - } + const toggleMutation = useOptimisticMutation(client.todos.update, { + queryKey: client.todos.list.queryKey(), + optimisticUpdate: (oldTodos: any, variables: any) => + (oldTodos ?? []).map((t: any) => + t.id === variables.params.id + ? { ...t, completed: variables.body.completed } + : t + ) }); const todos = (data ?? []).slice(0, 5); return ( {todos.length === 0 ? ( @@ -289,7 +265,7 @@ function OptimisticToggleDemo() { )} - onMutate → cancel + setQueryData → onError → rollback + {'useOptimisticMutation(client.todos.update, { queryKey, optimisticUpdate })'} ); @@ -517,6 +493,203 @@ function ErrorHandlingDemo() { ); } +// ── Demo 9: Cache Tag Invalidation ────────────────────────────────────_ + +function CacheTagDemo() { + const [todoId, setTodoId] = useState(1); + const [result, setResult] = useState(null); + const [fetchCount, setFetchCount] = useState(0); + const [cachedCount, setCachedCount] = useState(0); + const [updating, setUpdating] = useState(false); + + const handleFetch = useCallback(async () => { + const before = Date.now(); + await client.todos.get({ params: { id: todoId } }); + const elapsed = Date.now() - before; + // < 100ms likely came from middleware cache; network takes longer + if (elapsed < 100) { + setCachedCount((c) => c + 1); + setResult(`Cache hit! (${elapsed}ms)`); + } else { + setFetchCount((c) => c + 1); + setResult(`Network fetch (${elapsed}ms)`); + } + }, [todoId]); + + const handleMutate = useCallback(async () => { + setUpdating(true); + try { + await client.todos.update({ + params: { id: todoId }, + body: { title: `Updated ${Date.now()}` } + }); + setResult('Mutation done — cache invalidated'); + } catch { + setResult('Mutation failed (expected if todo does not exist)'); + } + setUpdating(false); + }, [todoId]); + + return ( + + + Todo ID: + setTodoId(Number(e.target.value) || 1)} + style={{ width: '80px' }} + /> + + + + + + + Network: {fetchCount} + + + Cache hits: {cachedCount} + + + + {result && ( + + {result} + + )} + + + {'endpoint.cacheTag("todo", p => ({ id: p.params.id }))'} + + + ); +} + +// ── Demo 10: Offline Queue ───────────────────────────────────────────── + +function OfflineQueueDemo() { + const [title, setTitle] = useState(''); + const [isDemoOffline, setIsDemoOffline] = useState(false); + + const queueCount = offlineQueueStore.queue.length; + const isReplaying = offlineQueueStore.isReplaying; + + const toggleOffline = () => { + if (isDemoOffline) { + setIsDemoOffline(false); + offlineQueueStore.isOnline = true; + window.dispatchEvent(new Event('online')); + } else { + setIsDemoOffline(true); + offlineQueueStore.isOnline = false; + window.dispatchEvent(new Event('offline')); + } + }; + + const createMutation = client.todos.create.useMutation({ + onSuccess: () => { + setTitle(''); + } + }); + + return ( + + + + {queueCount > 0 && ( + + {queueCount} queued + + )} + {isReplaying && ( + + Replaying… + + )} + {isDemoOffline && ( + Offline + )} + + + + setTitle(e.target.value)} + style={{ flex: 1 }} + /> + + + + {isDemoOffline && ( + + Offline mode — mutations are queued and will replay when + you go back online. + + )} + {queueCount > 0 && ( + + Queued: {queueCount} mutation(s) + + )} + + offlineQueue() middleware — automatic queue and replay + + + ); +} + // ── Page ──────────────────────────────────────────────────────────────── export default function ReactQueryPage() { @@ -538,6 +711,8 @@ export default function ReactQueryPage() { + + ); diff --git a/demos/todo-frontend/vite.config.ts b/demos/todo-frontend/vite.config.ts index 1c2c55a6..4931fa12 100644 --- a/demos/todo-frontend/vite.config.ts +++ b/demos/todo-frontend/vite.config.ts @@ -2,12 +2,28 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { resolve } from 'path'; +const clientSrc = resolve(__dirname, '../../libs/client/src'); + export default defineConfig({ plugins: [react()], resolve: { alias: { // Resolve backend contract from source during development - '@cleverbrush/todo-backend/contract': resolve(__dirname, '../todo-backend/src/contract.ts') + '@cleverbrush/todo-backend/contract': resolve(__dirname, '../todo-backend/src/contract.ts'), + // Resolve @cleverbrush/client sub-paths from TypeScript source so that + // dev-server changes take effect immediately (Vite HMR) without + // requiring a dist rebuild + server restart. + // More-specific sub-paths must come before the base package entry. + '@cleverbrush/client/react': `${clientSrc}/react.ts`, + '@cleverbrush/client/retry': `${clientSrc}/retry.ts`, + '@cleverbrush/client/timeout': `${clientSrc}/timeout.ts`, + '@cleverbrush/client/dedupe': `${clientSrc}/dedupe.ts`, + '@cleverbrush/client/idempotency': `${clientSrc}/idempotency.ts`, + '@cleverbrush/client/cache': `${clientSrc}/cache.ts`, + '@cleverbrush/client/batching': `${clientSrc}/batching.ts`, + '@cleverbrush/client/optimistic-update': `${clientSrc}/optimisticUpdate.ts`, + '@cleverbrush/client/offline-queue': `${clientSrc}/offlineQueue.ts`, + '@cleverbrush/client': `${clientSrc}/index.ts` }, // Force a single instance of these packages so instanceof checks work // across @cleverbrush/react-form (which bundles its own copy) and app code diff --git a/libs/client/README.md b/libs/client/README.md index 8eef9a08..d7364325 100644 --- a/libs/client/README.md +++ b/libs/client/README.md @@ -243,6 +243,43 @@ throttlingCache({ Caches successful GET responses for a configurable TTL. Subsequent requests within the TTL receive a cloned cached response without hitting the network. +### Cache Tags — `@cleverbrush/client/cache` + +Tag-based HTTP caching with automatic invalidation driven by server-side endpoint +annotations (`.cacheTag()` / `.clearsCacheTag()`). Replaces manual invalidation callbacks — mutations +automatically clear cache entries matching the endpoint's declared tag names. + +```ts +import { cacheTags } from '@cleverbrush/client/cache'; + +const client = createClient(api, { + middlewares: [cacheTags({ defaultTtl: 5000 })], +}); + +// Populates cache entries for 'todo-list' and 'todo:id=1' tags. +await client.todos.list({ query: { page: 1 } }); +await client.todos.get({ params: { id: 1 } }); + +// Mutation — automatically invalidates both tags. +await client.todos.update({ params: { id: 1 }, body: { title: 'Updated' } }); + +// Triggers network fetch — cache was cleared. +await client.todos.list({ query: { page: 1 } }); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `defaultTtl` | `number` | `0` | TTL in ms for tags without explicit TTL. `0` = invalidation-only. | +| `ttlByTag` | `Record` | `{}` | Per-tag TTL overrides. | +| `condition` | `(Response) => boolean` | `response.ok` | Predicate controlling which responses are cached. | + +When used with `@cleverbrush/client/react`, TanStack Query's `useMutation` hooks +automatically invalidate the query cache for the affected group — no manual +`queryClient.invalidateQueries()` needed. + +See the [server-side cache tags](/server#cache-tags) section for how to declare +tags on your endpoints. + ## Per-Call Overrides Override middleware options for individual calls: @@ -683,6 +720,47 @@ function InfiniteTodos() { } ``` +### Optimistic Mutations + +The `useOptimisticMutation` hook wraps TanStack Query's `useMutation` with automatic cache snapshot, optimistic update, and rollback on error. This replaces the manual `onMutate`/`onError`/`onSettled` pattern. + +```tsx +import { useOptimisticMutation } from '@cleverbrush/client/react'; + +function TodoItem({ todo }: { todo: Todo }) { + const toggleMutation = useOptimisticMutation(client.todos.update, { + queryKey: client.todos.list.queryKey(), + optimisticUpdate: (oldTodos, variables) => + (oldTodos ?? []).map(t => + t.id === variables.params.id + ? { ...t, completed: variables.body.completed } + : t + ), + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: client.todos.queryKey() + }); + } + }); + + return ( + + ); +} +``` + +The hook handles: +1. **Cancel** — cancels in-flight queries for the given `queryKey` +2. **Snapshot** — captures the current cache state +3. **Optimistic update** — applies your `optimisticUpdate` function +4. **Rollback** — restores the snapshot if the mutation fails +5. **Invalidate** — invalidates the cache when the mutation settles + ### Error Handling in Hooks ```tsx @@ -693,7 +771,7 @@ const { error } = client.todos.list.useQuery(); if (isApiError(error)) { console.log(error.status, error.body); } else if (isTimeoutError(error)) { - console.log('Timed out after', error.timeout, 'ms'); + console.log('Timed out after', err.timeout, 'ms'); } ``` @@ -731,10 +809,134 @@ if (isApiError(error)) { | `@cleverbrush/client/retry` | Retry middleware with exponential backoff | | `@cleverbrush/client/timeout` | AbortController-based timeout middleware | | `@cleverbrush/client/dedupe` | Request deduplication middleware | -| `@cleverbrush/client/cache` | Throttling cache middleware | +| `@cleverbrush/client/idempotency` | Idempotency key middleware (deduplicates mutations) | +| `@cleverbrush/client/cache` | Throttling cache + tag-based cache invalidation middleware | | `@cleverbrush/client/batching` | Request batching middleware | +| `@cleverbrush/client/optimistic-update` | Optimistic update middleware (mutation tracking) | +| `@cleverbrush/client/offline-queue` | Offline queue middleware (queue + replay) | | `@cleverbrush/client/react` | TanStack Query hooks + unified client | +## Optimistic Update Middleware + +```ts +import { optimisticUpdate } from '@cleverbrush/client/optimistic-update'; +``` + +Tags mutation requests (POST/PUT/PATCH/DELETE) with a unique ID and tracks network failures in an inspectable store. Designed to work with the `useOptimisticMutation` React hook. + +### Basic usage + +```ts +import { createClient } from '@cleverbrush/client'; +import { optimisticUpdate } from '@cleverbrush/client/optimistic-update'; + +const client = createClient(api, { + middlewares: [optimisticUpdate()] +}); +``` + +### Store inspection + +Pass a shared store to inspect failed mutations: + +```ts +import { optimisticUpdate, type OptimisticUpdateStore } from '@cleverbrush/client/optimistic-update'; + +const store: OptimisticUpdateStore = { failures: [] }; + +const client = createClient(api, { + middlewares: [optimisticUpdate({ store })] +}); + +// After a network error: +console.log(store.failures); +// → [{ id, url, init, error, timestamp }] +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `store` | `OptimisticUpdateStore` | `{ failures: [] }` | Shared mutable store | +| `skip` | `(url, init) => boolean` | — | Skip tagging for specific requests | + +### Per-call override + +```ts +await client.todos.create({ + body: { title: 'test' }, + optimisticUpdate: { skip: true } // skip tagging for this call +}); +``` + +## Offline Queue Middleware + +```ts +import { offlineQueue } from '@cleverbrush/client/offline-queue'; +``` + +Queues mutation requests (POST/PUT/PATCH/DELETE) when the browser reports offline (`navigator.onLine`). Automatically replays queued mutations when connectivity is restored. + +### Important: middleware placement + +`offlineQueue()` must be the **outermost** middleware (first in the array) so that retry/timeout/etc. middlewares re-apply when queued mutations are replayed: + +```ts +middlewares: [ + offlineQueue(), // outermost + retry({ limit: 3 }), // re-applies on replay + timeout({ timeout: 10000 }), + batching(), +] +``` + +### Basic usage + +```ts +import { createClient } from '@cleverbrush/client'; +import { offlineQueue } from '@cleverbrush/client/offline-queue'; + +const client = createClient(api, { + middlewares: [offlineQueue()] +}); +``` + +### Store inspection + +Pass a shared store to observe queue state: + +```ts +import { offlineQueue, type OfflineQueueStore } from '@cleverbrush/client/offline-queue'; + +const store: OfflineQueueStore = { queue: [], isOnline: true, isReplaying: false }; + +const client = createClient(api, { + middlewares: [offlineQueue({ store })] +}); + +// Check queue status: +console.log(store.isOnline); // boolean +console.log(store.queue.length); // queued mutations +console.log(store.isReplaying); // currently replaying +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `store` | `OfflineQueueStore` | `{ queue: [], isOnline: true, isReplaying: false }` | Shared mutable store | +| `skip` | `(url, init) => boolean` | — | Skip queue for specific requests | +| `maxRetries` | `number` | `3` | Max flush retries per queued item | + +### Per-call override + +```ts +await client.todos.create({ + body: { title: 'test' }, + offlineQueue: { skip: true } // bypass queue for this call +}); +``` + ## License BSD 3-Clause diff --git a/libs/client/package.json b/libs/client/package.json index 4d8ba57a..4268d2bb 100644 --- a/libs/client/package.json +++ b/libs/client/package.json @@ -63,6 +63,10 @@ "types": "./dist/dedupe.d.ts", "import": "./dist/dedupe.js" }, + "./idempotency": { + "types": "./dist/idempotency.d.ts", + "import": "./dist/idempotency.js" + }, "./cache": { "types": "./dist/cache.d.ts", "import": "./dist/cache.js" @@ -71,6 +75,14 @@ "types": "./dist/batching.d.ts", "import": "./dist/batching.js" }, + "./optimistic-update": { + "types": "./dist/optimisticUpdate.d.ts", + "import": "./dist/optimisticUpdate.js" + }, + "./offline-queue": { + "types": "./dist/offlineQueue.d.ts", + "import": "./dist/offlineQueue.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.js" diff --git a/libs/client/src/cache.ts b/libs/client/src/cache.ts index 3e72f450..605e98ed 100644 --- a/libs/client/src/cache.ts +++ b/libs/client/src/cache.ts @@ -1,2 +1,4 @@ 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'; diff --git a/libs/client/src/client.ts b/libs/client/src/client.ts index 091b6a8a..86709b1e 100644 --- a/libs/client/src/client.ts +++ b/libs/client/src/client.ts @@ -212,7 +212,12 @@ export function createClient( } // The actual fetch logic, shared by every endpoint proxy method. - async function execute(ep: any, args: any): Promise { + async function execute( + ep: any, + args: any, + groupName?: string, + endpointName?: string + ): Promise { const { url, method, @@ -230,10 +235,65 @@ export function createClient( const perCallOptions: Record = {}; if (args?.retry !== undefined) perCallOptions.retry = args.retry; if (args?.timeout !== undefined) perCallOptions.timeout = args.timeout; + if (args?.optimisticUpdate !== undefined) + perCallOptions.optimisticUpdate = args.optimisticUpdate; + if (args?.offlineQueue !== undefined) + perCallOptions.offlineQueue = args.offlineQueue; if (Object.keys(perCallOptions).length > 0) { (init as any)[PER_CALL_OPTIONS] = perCallOptions; } + // Attach endpoint metadata for middleware introspection + // (e.g. throttlingCache cache invalidation callbacks). + if (groupName && endpointName) { + const meta = getMeta(ep); + const tpl = meta.pathTemplate; + + let suffix = ''; + if (typeof tpl === 'string') { + suffix = tpl; + } else if (tpl && typeof (tpl as any).introspect === 'function') { + suffix = + (tpl as any).introspect().templateDefinition.literals[0] ?? + ''; + } + const collectionPath = meta.basePath + suffix || '/'; + + let pathParamNames: string[] = []; + if ( + tpl && + typeof tpl !== 'string' && + typeof (tpl as any).introspect === 'function' + ) { + pathParamNames = (tpl as any) + .introspect() + .templateDefinition.segments.map((s: any) => s.path); + } + + const epMeta: Record = { + group: groupName, + endpoint: endpointName, + method: meta.method, + path: ep.path as string, + basePath: meta.basePath, + baseUrl, + collectionPath, + fullCollectionUrl: baseUrl.replace(/\/$/, '') + collectionPath, + pathParamNames, + params: args?.params ?? ({} as Record), + body: args?.body, + query: args?.query ?? ({} as Record), + headers: args?.headers ?? ({} as Record), + operationId: meta.operationId ?? null, + tags: meta.tags ?? [], + cacheTags: meta.cacheTags ?? [] + }; + + if (!(init as any).__endpointMeta) { + (init as any).__endpointMeta = epMeta; + } + } + // -- beforeRequest hooks -- await runBeforeRequest(hooks, url, init); @@ -383,7 +443,8 @@ export function createClient( } // Regular HTTP endpoints return a callable with .stream() - const call = (args?: any) => execute(ep, args); + const call = (args?: any) => + execute(ep, args, groupName, endpointName); call.stream = (args?: any) => streamLines(ep, args); return call; } diff --git a/libs/client/src/errors.ts b/libs/client/src/errors.ts index 7fc21b34..9f618578 100644 --- a/libs/client/src/errors.ts +++ b/libs/client/src/errors.ts @@ -153,6 +153,46 @@ export function isTimeoutError(error: unknown): error is TimeoutError { * } * ``` */ +/** + * Error thrown when a request is attempted while the client is offline. + * + * Extends {@link NetworkError} so offline errors are caught by + * `isNetworkError()` checks. + * + * @example + * ```ts + * try { + * await client.todos.list(); + * } catch (err) { + * if (isOfflineError(err)) { + * console.log('Cannot make requests while offline'); + * } + * } + * ``` + */ +export class OfflineError extends NetworkError { + constructor() { + super('Client is offline'); + this.name = 'OfflineError'; + } +} + +/** + * Type guard for {@link OfflineError}. + * + * @example + * ```ts + * catch (err) { + * if (isOfflineError(err)) { + * console.log('Device is offline'); + * } + * } + * ``` + */ +export function isOfflineError(error: unknown): error is OfflineError { + return error instanceof OfflineError; +} + export function isNetworkError(error: unknown): error is NetworkError { return error instanceof NetworkError; } diff --git a/libs/client/src/idempotency.ts b/libs/client/src/idempotency.ts new file mode 100644 index 00000000..81337ab6 --- /dev/null +++ b/libs/client/src/idempotency.ts @@ -0,0 +1,2 @@ +export type { IdempotencyOptions } from './middlewares/idempotency.js'; +export { idempotency } from './middlewares/idempotency.js'; diff --git a/libs/client/src/index.ts b/libs/client/src/index.ts index 1bdda650..12037754 100644 --- a/libs/client/src/index.ts +++ b/libs/client/src/index.ts @@ -24,13 +24,15 @@ export { ApiError, isApiError, isNetworkError, + isOfflineError, isTimeoutError, isWebError, NetworkError, + OfflineError, TimeoutError, WebError } from './errors.js'; -export type { FetchLike, Middleware } from './middleware.js'; +export type { EndpointMeta, FetchLike, Middleware } from './middleware.js'; export { composeMiddleware, getPerCallOptions, diff --git a/libs/client/src/middleware.ts b/libs/client/src/middleware.ts index b36b14a0..2fbae2f9 100644 --- a/libs/client/src/middleware.ts +++ b/libs/client/src/middleware.ts @@ -115,3 +115,73 @@ export function getPerCallOptions( ): T | undefined { return (init as any)[PER_CALL_OPTIONS]?.[key] as T | undefined; } + +// --------------------------------------------------------------------------- +// Endpoint metadata +// --------------------------------------------------------------------------- + +/** + * Endpoint metadata carried through {@link PER_CALL_OPTIONS} on every request. + * + * Computed by the Proxy-based client at call time, this gives middleware + * access to the endpoint's structural info plus the actual call arguments + * without any URL parsing or regex. + * + * Used by {@link throttlingCache} for cache-invalidation callbacks. + */ +export interface EndpointMeta { + /** Contract group name, e.g. `"todos"`. */ + group: string; + /** Endpoint name within the group, e.g. `"update"`. */ + endpoint: string; + /** HTTP method in uppercase, e.g. `"PATCH"`. */ + method: string; + /** Path template with colon placeholders, e.g. `/api/todos/:id`. */ + path: string; + /** Resource base path, e.g. `/api/todos`. */ + basePath: string; + /** Resource collection path (basePath without param placeholders). */ + collectionPath: string; + /** Client base URL (e.g. `"http://localhost:3000"` or `""`). */ + baseUrl: string; + /** + * Full collection URL matching the HTTP cache key format. + * Computed as `baseUrl (stripped of trailing slash) + collectionPath`. + */ + fullCollectionUrl: string; + /** Names of path parameters, e.g. `["id"]`. */ + pathParamNames: string[]; + /** Actual route parameter values from the call, e.g. `{ id: 42 }`. */ + params: Readonly>; + /** Request body. */ + body: unknown; + /** Query parameters, e.g. `{ page: 1 }`. */ + query: Readonly>; + /** OpenAPI operationId, or `null`. */ + operationId: string | null; + /** OpenAPI tags, or `[]`. */ + tags: readonly string[]; + /** + * Cache tag definitions from the endpoint's `.cacheTag()` calls. + * Each tag has a `name` and a map of `properties` (key → accessor). + * Used by the `cacheTags` middleware. + */ + cacheTags: ReadonlyArray<{ + name: string; + properties: Readonly< + Record< + string, + { + getValue(root: { + params: Record; + body: unknown; + query: Record; + headers: Record; + }): { value?: unknown; success: boolean }; + } + > + >; + }>; + /** Request headers from the call, e.g. `{ 'x-request-id': 'abc' }`. */ + headers: Readonly>; +} diff --git a/libs/client/src/middlewares/cache.ts b/libs/client/src/middlewares/cache.ts index c188ee6b..5ab26bc3 100644 --- a/libs/client/src/middlewares/cache.ts +++ b/libs/client/src/middlewares/cache.ts @@ -18,7 +18,7 @@ * @module */ -import type { Middleware } from '../middleware.js'; +import type { EndpointMeta, Middleware } from '../middleware.js'; // --------------------------------------------------------------------------- // Types @@ -61,8 +61,15 @@ export interface CacheOptions { * Return `null` to skip invalidation. * * By default, mutating requests do not invalidate the cache. + * + * The `meta` parameter carries endpoint metadata (group, endpoint, + * method, path, params, body, query, etc.) provided by the client proxy. */ - invalidate?: (url: string, init: RequestInit) => string | null; + invalidate?: ( + url: string, + init: RequestInit, + meta?: EndpointMeta + ) => string | null; } // --------------------------------------------------------------------------- @@ -106,8 +113,9 @@ const DEFAULT_CONDITION = (response: Response) => response.ok; * const client = createClient(api, { * middlewares: [throttlingCache({ * throttle: 2000, - * invalidate: (url, init) => { - * if (init.method !== 'GET') return `GET@${url}`; + * invalidate: (_url, _init, meta) => { + * if (meta && meta.method !== 'GET') + * return `GET@${meta.collectionPath}`; * return null; * }, * })], @@ -128,7 +136,10 @@ export function throttlingCache(options: CacheOptions = {}): Middleware { return next => (url, init) => { // Handle cache invalidation for mutating requests. if (invalidate) { - const invalidateKey = invalidate(url, init); + const meta = (init as any).__endpointMeta as + | EndpointMeta + | undefined; + const invalidateKey = invalidate(url, init, meta); if (invalidateKey !== null && invalidateKey !== undefined) { cache.delete(invalidateKey); } diff --git a/libs/client/src/middlewares/cacheTags.test.ts b/libs/client/src/middlewares/cacheTags.test.ts new file mode 100644 index 00000000..14545185 --- /dev/null +++ b/libs/client/src/middlewares/cacheTags.test.ts @@ -0,0 +1,497 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { EndpointMeta, FetchLike } from '../middleware.js'; +import { cacheTags } from './cacheTags.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTagMeta( + tags: Array<{ + name: string; + properties: Record< + string, + { + getValue(root: any): { + value?: unknown; + success: boolean; + }; + } + >; + }>, + overrides: Partial = {} +): EndpointMeta { + return { + group: 'test', + endpoint: 'test', + method: 'GET', + path: '/api/test', + basePath: '/api/test', + collectionPath: '/api/test', + baseUrl: '', + fullCollectionUrl: '/api/test', + pathParamNames: [], + params: {}, + body: undefined, + query: {}, + headers: {}, + operationId: null, + tags: [], + cacheTags: tags, + ...overrides + } as EndpointMeta; +} + +function makeInit(meta: EndpointMeta, method = 'GET'): RequestInit { + return { + method, + headers: {}, + __endpointMeta: meta + } as any; +} + +function makeConstAccessor(value: unknown) { + return { + getValue: () => ({ success: true, value }) + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('cacheTags middleware', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // -- GET caching -------------------------------------------------------- + + test('caches GET response within TTL', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(42) } + } + ]); + const init = makeInit(meta); + + // First call — hits network + const r1 = await mw('/api/test', init); + expect(r1.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + + // Second call within TTL — should be cached + const r2 = await mw('/api/test', init); + expect(r2.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('fetches again after TTL expiry', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ v: 1 }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ v: 2 }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 1000 })(fetch); + + const meta = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(1) } + } + ]); + const init = makeInit(meta); + + // First call + await mw('/api/test', init); + expect(fetch).toHaveBeenCalledTimes(1); + + // Advance past TTL + vi.advanceTimersByTime(1001); + + // Second call — should fetch again + await mw('/api/test', init); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test('does not cache when TTL is 0 (invalidates only)', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 0 })(fetch); + + const meta = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(42) } + } + ]); + const init = makeInit(meta); + + await mw('/api/test', init); + await mw('/api/test', init); + + // Both calls should hit the network + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test('caches per unique key', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta1 = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(1) } + } + ]); + const meta2 = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(2) } + } + ]); + + await mw('/api/test', makeInit(meta1)); + await mw('/api/test', makeInit(meta2)); + + // Two different keys → two network calls + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test('does not cache error responses', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response('Not Found', { status: 404 })); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(42) } + } + ]); + const init = makeInit(meta); + + await mw('/api/test', init); + await mw('/api/test', init); + + // Error responses not cached + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test('uses per-tag TTL when configured', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ v: 1 }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ v: 2 }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ + defaultTtl: 0, + ttlByTag: { 'cached-tag': 2000 } + })(fetch); + + const meta = makeTagMeta([ + { + name: 'cached-tag', + properties: { id: makeConstAccessor(42) } + } + ]); + const init = makeInit(meta); + + // First call + await mw('/api/test', init); + expect(fetch).toHaveBeenCalledTimes(1); + + // Within 2000ms TTL + vi.advanceTimersByTime(500); + await mw('/api/test', init); + expect(fetch).toHaveBeenCalledTimes(1); + + // Past TTL + vi.advanceTimersByTime(1501); + await mw('/api/test', init); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + // -- Invalidation ------------------------------------------------------- + + test('invalidates cache on mutating requests by tag name prefix', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ v: 1 }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ v: 2 }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const getMeta = makeTagMeta([ + { + name: 'test-tag', + properties: { id: makeConstAccessor(42) } + } + ]); + + // GET — cache populated + await mw('/api/test', makeInit(getMeta, 'GET')); + expect(fetch).toHaveBeenCalledTimes(1); + + // POST — should invalidate 'test-tag' keys + await mw('/api/test', makeInit(getMeta, 'POST')); + expect(fetch).toHaveBeenCalledTimes(2); + + // GET again — should re-fetch after invalidation + await mw('/api/test', makeInit(getMeta, 'GET')); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + test('invalidates exact key and all prefixed variants', async () => { + const fetch = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + ); + + const mw = cacheTags({ defaultTtl: 10000 })(fetch); + + // Cache entries with different ids under same tag + const meta1 = makeTagMeta([ + { name: 'todo', properties: { id: makeConstAccessor(1) } } + ]); + const meta2 = makeTagMeta([ + { name: 'todo', properties: { id: makeConstAccessor(2) } } + ]); + + await mw('/api/todo/1', makeInit(meta1, 'GET')); + await mw('/api/todo/2', makeInit(meta2, 'GET')); + expect(fetch).toHaveBeenCalledTimes(2); + + // Mutate todo:2 — should invalidate 'todo' prefix = all todo keys + await mw('/api/todo/2', makeInit(meta2, 'POST')); + expect(fetch).toHaveBeenCalledTimes(3); + + // Both GETs should re-fetch + await mw('/api/todo/1', makeInit(meta1, 'GET')); + await mw('/api/todo/2', makeInit(meta2, 'GET')); + expect(fetch).toHaveBeenCalledTimes(5); + }); + + test('does not invalidate when no cache tags', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta = makeTagMeta([]); + const getInit = makeInit(meta, 'GET'); + + // GET — passes through + await mw('/api/test', getInit); + expect(fetch).toHaveBeenCalledTimes(1); + + // POST — passes through + await mw('/api/test', makeInit(meta, 'POST')); + expect(fetch).toHaveBeenCalledTimes(2); + + // GET — still goes through (no caching without tags) + await mw('/api/test', getInit); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + // -- Simple tags -------------------------------------------------------- + + test('simple tags (no properties) work as cache keys', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta = makeTagMeta([{ name: 'global', properties: {} }]); + + await mw('/api/test', makeInit(meta, 'GET')); + await mw('/api/test', makeInit(meta, 'GET')); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + // -- Multiple tags ------------------------------------------------------ + + test('multiple tags: first matching cache hit is used', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta = makeTagMeta([ + { name: 'tag-a', properties: { id: makeConstAccessor(1) } }, + { name: 'tag-b', properties: { id: makeConstAccessor(2) } } + ]); + + // First request populates both tags + await mw('/api/test', makeInit(meta, 'GET')); + expect(fetch).toHaveBeenCalledTimes(1); + + // Second request — tag-a key still valid + await mw('/api/test', makeInit(meta, 'GET')); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + // -- Headers-based tag -------------------------------------------------- + + test('uses headers in tag key computation', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + // Two requests with different tenant headers + const meta1 = makeTagMeta( + [ + { + name: 'tenant', + properties: { + tenant: { + getValue: (root: any) => ({ + success: true, + value: root.headers?.['x-tenant'] + }) + } + } + } + ], + { headers: { 'x-tenant': 'acme' } } + ); + + const meta2 = makeTagMeta( + [ + { + name: 'tenant', + properties: { + tenant: { + getValue: (root: any) => ({ + success: true, + value: root.headers?.['x-tenant'] + }) + } + } + } + ], + { headers: { 'x-tenant': 'beta' } } + ); + + await mw('/api/test', makeInit(meta1, 'GET')); + await mw('/api/test', makeInit(meta2, 'GET')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + // -- Pass-through for non-tagged requests ------------------------------- + + test('passes through requests without cache tags', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const init: RequestInit = { + method: 'GET', + headers: {} + }; + + await mw('/api/test', init); + await mw('/api/test', init); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + // -- Cloned responses are independent ----------------------------------- + + test('cached responses are clones', async () => { + const responseBody = JSON.stringify({ data: 'test' }); + const fetch = vi.fn().mockResolvedValue( + new Response(responseBody, { + headers: { 'Content-Type': 'application/json' } + }) + ); + + const mw = cacheTags({ defaultTtl: 5000 })(fetch); + + const meta = makeTagMeta([ + { name: 'test', properties: { id: makeConstAccessor(1) } } + ]); + + const r1 = await mw('/api/test', makeInit(meta, 'GET')); + const body1 = await r1.text(); + + const r2 = await mw('/api/test', makeInit(meta, 'GET')); + const body2 = await r2.text(); + + expect(body1).toBe(responseBody); + expect(body2).toBe(responseBody); + expect(r1).not.toBe(r2); + }); +}); diff --git a/libs/client/src/middlewares/cacheTags.ts b/libs/client/src/middlewares/cacheTags.ts new file mode 100644 index 00000000..39910a49 --- /dev/null +++ b/libs/client/src/middlewares/cacheTags.ts @@ -0,0 +1,226 @@ +/** + * Tag-based cache middleware for the `@cleverbrush/client`. + * + * Caches successful GET responses keyed by endpoint-defined cache tags. + * Mutating requests (POST, PUT, DELETE, PATCH) invalidate all cache + * entries whose key starts with each of the endpoint's tag names. + * + * @example + * ```ts + * import { createClient } from '@cleverbrush/client'; + * import { cacheTags } from '@cleverbrush/client/cache'; + * + * const client = createClient(api, { + * middlewares: [cacheTags({ defaultTtl: 0, ttlByTag: { 'todo-list': 5000 } })], + * }); + * ``` + * + * @module + */ + +import type { EndpointMeta, Middleware } from '../middleware.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Configuration for the {@link cacheTags} middleware. + */ +export interface CacheTagMiddlewareOptions { + /** + * Per-tag TTL map: `{ [tagName]: ttlMs }`. + * Tags not listed here fall back to `defaultTtl`. + */ + ttlByTag?: Record; + + /** + * Default TTL in milliseconds for tags without an explicit TTL. + * Defaults to `0` (no caching — invalidation-only mode). + */ + defaultTtl?: number; + + /** + * Predicate that decides whether a request should be cached. + * Defaults to caching only successful responses (`response.ok`). + */ + condition?: (response: Response) => boolean; +} + +/** + * The root object passed to each `CacheTagPropertyAccessor.getValue()` call. + */ +interface TagRoot { + params: Record; + body: unknown; + query: Record; + headers: Record; +} + +/** Shape of a serialised cache tag from endpoint metadata. */ +interface SerializedCacheTag { + name: string; + properties: Readonly< + Record< + string, + { + getValue(root: TagRoot): { + value?: unknown; + success: boolean; + }; + } + > + >; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +interface CacheEntry { + response: Response; + expiresAt: number; +} + +function isMutating(method: string): boolean { + return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase()); +} + +/** + * Computes a deterministic cache key from a tag and request data. + * + * - Tags with no properties produce just the tag name. + * - Tags with properties produce `name:key1=val1,key2=val2` where + * keys are sorted alphabetically for determinism. + */ +function computeKey(tag: SerializedCacheTag, root: TagRoot): string { + const entries = Object.entries(tag.properties); + + if (entries.length === 0) { + return tag.name; + } + + const parts: string[] = []; + for (const [key, accessor] of entries.sort(([a], [b]) => + a.localeCompare(b) + )) { + const result = accessor.getValue(root); + if (result.success && result.value !== undefined) { + parts.push(`${key}=${String(result.value)}`); + } + } + + if (parts.length === 0) { + return tag.name; + } + + return `${tag.name}:${parts.join(',')}`; +} + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- + +/** + * Creates a tag-based cache middleware. + * + * On GET requests, the middleware inspects `__endpointMeta.cacheTags` to + * compute cache keys. If a valid (non-expired) cache entry exists, the + * cached response is returned immediately (cloned). + * + * On mutating requests (POST, PUT, DELETE, PATCH), all cache entries whose + * key starts with any of the endpoint's tag names are invalidated. + * + * @param options - Cache configuration. + * @returns A {@link Middleware} that caches and invalidates by tag. + */ +export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware { + const { + ttlByTag = {}, + defaultTtl = 0, + condition = (response: Response) => response.ok + } = options; + + const cache = new Map(); + + return next => (url, init) => { + const meta = (init as any).__endpointMeta as EndpointMeta | undefined; + const tags: readonly SerializedCacheTag[] | undefined = meta?.cacheTags; + 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) ?? {}, + body: meta?.body, + query: (meta?.query as Record) ?? {}, + headers: (meta?.headers as Record) ?? {} + }; + + for (const tag of tags) { + const tagKey = computeKey(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). + for (const [cachedKey] of cache) { + if ( + cachedKey === tagKey || + cachedKey.startsWith(tag.name) + ) { + cache.delete(cachedKey); + } + } + } + } + + // -- Cache lookup for GET requests -- + if (method === 'GET' && tags && tags.length > 0) { + const root: TagRoot = { + params: (meta?.params as Record) ?? {}, + body: meta?.body, + query: (meta?.query as Record) ?? {}, + headers: (meta?.headers as Record) ?? {} + }; + + let foundEntry: CacheEntry | undefined; + + for (const tag of tags) { + const cacheKey = computeKey(tag, root); + const entry = cache.get(cacheKey); + if (entry && entry.expiresAt > Date.now()) { + foundEntry = entry; + break; + } + if (entry) { + cache.delete(cacheKey); + } + } + + if (foundEntry) { + return Promise.resolve(foundEntry.response.clone()); + } + + return next(url, init).then(response => { + if (condition(response)) { + for (const tag of tags) { + const cacheKey = computeKey(tag, root); + const ttl = + ttlByTag[tag.name] !== undefined + ? ttlByTag[tag.name] + : defaultTtl; + if (ttl > 0) { + cache.set(cacheKey, { + response: response.clone(), + expiresAt: Date.now() + ttl + }); + } + } + } + return response; + }); + } + + // -- Pass-through for non-cache-tagged or non-GET requests -- + return next(url, init); + }; +} diff --git a/libs/client/src/middlewares/idempotency.test.ts b/libs/client/src/middlewares/idempotency.test.ts new file mode 100644 index 00000000..672af9c6 --- /dev/null +++ b/libs/client/src/middlewares/idempotency.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { FetchLike } from '../middleware.js'; +import { idempotency } from './idempotency.js'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('idempotency middleware', () => { + test('adds X-Idempotency-Key header to POST requests', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response('ok', { status: 201 })); + + const mw = idempotency()(fetch); + await mw('/api/todos', { method: 'POST', body: '{}' }); + + const call = fetch.mock.calls[0]; + const headers = call[1].headers as Headers; + const key = headers.get('X-Idempotency-Key'); + expect(key).toBeTruthy(); + // UUID v4 format: 8-4-4-4-12 hex chars + expect(key).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + }); + + test('adds key to PUT requests', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency()(fetch); + await mw('/api/todos/1', { method: 'PUT', body: '{}' }); + + const headers = fetch.mock.calls[0][1].headers as Headers; + expect(headers.get('X-Idempotency-Key')).toBeTruthy(); + }); + + test('adds key to PATCH requests', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency()(fetch); + await mw('/api/todos/1', { method: 'PATCH', body: '{}' }); + + expect( + (fetch.mock.calls[0][1].headers as Headers).get('X-Idempotency-Key') + ).toBeTruthy(); + }); + + test('adds key to DELETE requests', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency()(fetch); + await mw('/api/todos/1', { method: 'DELETE' }); + + expect( + (fetch.mock.calls[0][1].headers as Headers).get('X-Idempotency-Key') + ).toBeTruthy(); + }); + + test('does not add key to GET requests', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency()(fetch); + await mw('/api/todos', { method: 'GET' }); + + const headers = fetch.mock.calls[0][1].headers as Headers | undefined; + expect(headers?.get('X-Idempotency-Key') ?? null).toBeNull(); + }); + + test('reuses existing key if already present', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const existingKey = 'my-custom-key-123'; + const mw = idempotency()(fetch); + await mw('/api/todos', { + method: 'POST', + headers: new Headers({ 'X-Idempotency-Key': existingKey }) + }); + + const headers = fetch.mock.calls[0][1].headers as Headers; + expect(headers.get('X-Idempotency-Key')).toBe(existingKey); + }); + + test('preserves existing headers alongside idempotency key', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency()(fetch); + await mw('/api/todos', { + method: 'POST', + headers: new Headers({ + Authorization: 'Bearer token', + 'Content-Type': 'application/json' + }) + }); + + const headers = fetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-Idempotency-Key')).toBeTruthy(); + }); + + test('uses custom header name', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency({ headerName: 'Idempotency-Key' })(fetch); + await mw('/api/todos', { method: 'POST', body: '{}' }); + + const headers = fetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Idempotency-Key')).toBeTruthy(); + }); + + test('uses custom key generator', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + let callCount = 0; + const mw = idempotency({ + keyGenerator: () => `custom-${++callCount}` + })(fetch); + + await mw('/api/todos', { method: 'POST' }); + + const headers = fetch.mock.calls[0][1].headers as Headers; + expect(headers.get('X-Idempotency-Key')).toBe('custom-1'); + }); + + test('custom condition can include GET', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('ok')); + + const mw = idempotency({ condition: () => true })(fetch); + await mw('/api/todos', { method: 'GET' }); + + const headers = fetch.mock.calls[0][1].headers as Headers; + expect(headers.get('X-Idempotency-Key')).toBeTruthy(); + }); +}); diff --git a/libs/client/src/middlewares/idempotency.ts b/libs/client/src/middlewares/idempotency.ts new file mode 100644 index 00000000..4978c542 --- /dev/null +++ b/libs/client/src/middlewares/idempotency.ts @@ -0,0 +1,104 @@ +/** + * Idempotency middleware for the `@cleverbrush/client`. + * + * Automatically adds an `X-Idempotency-Key` header to mutating requests + * so the server can deduplicate replays. The same key is preserved across + * retries, ensuring retried requests are treated as the same operation. + * + * @module + */ + +import type { Middleware } from '../middleware.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Configuration for {@link idempotency}. + */ +export interface IdempotencyOptions { + /** + * Header name to use for the idempotency key. + * Defaults to `"X-Idempotency-Key"`. + */ + headerName?: string; + + /** + * Custom key generator. Receives `(url, init)` and returns a string. + * Defaults to generating a UUID v4. + * + * The key must be stable across retries of the same logical request. + * When not provided, a UUID is generated once and reused on retry. + */ + keyGenerator?: (url: string, init: RequestInit) => string; + + /** + * Predicate that decides whether a request should receive a key. + * Defaults to `true` for POST, PUT, PATCH, DELETE. + */ + condition?: (url: string, init: RequestInit) => boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Generates a random UUID v4 without external dependencies. + */ +function uuid4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +function isMutating(method: string): boolean { + return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase()); +} + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- + +/** + * Creates an idempotency middleware for the client. + * + * Mutating requests automatically receive an `X-Idempotency-Key` header + * with a UUID v4 value. The key is generated once per request and reused + * across retries — so the server sees the same key for the same logical + * operation even when the client retries. + * + * @param options - Configuration. + * @returns A {@link Middleware} that adds idempotency keys. + * + * @example + * ```ts + * const client = createClient(api, { + * middlewares: [idempotency(), retry({ limit: 3 })], + * }); + * ``` + */ +export function idempotency(options: IdempotencyOptions = {}): Middleware { + const { + headerName = 'X-Idempotency-Key', + keyGenerator = uuid4, + condition = (_url, init) => + isMutating((init.method ?? 'GET').toUpperCase()) + } = options; + + return next => (url, init) => { + if (!condition(url, init)) { + return next(url, init); + } + + const headers = new Headers(init.headers); + if (!headers.has(headerName.toLowerCase())) { + headers.set(headerName, keyGenerator(url, init)); + } + + return next(url, { ...init, headers }); + }; +} diff --git a/libs/client/src/middlewares/offlineQueue.test.ts b/libs/client/src/middlewares/offlineQueue.test.ts new file mode 100644 index 00000000..b9157a08 --- /dev/null +++ b/libs/client/src/middlewares/offlineQueue.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { offlineQueue } from './offlineQueue.js'; + +describe('offlineQueue middleware', () => { + function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' } + }); + } + + beforeEach(() => { + if (typeof navigator !== 'undefined') { + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: true + }); + } + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('passes GET requests through immediately when online', async () => { + const mw = offlineQueue(); + const next = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + const response = await mw(next)('/api/todos', { method: 'GET' }); + + expect(response).toBeDefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('passes POST requests through when online', async () => { + const mw = offlineQueue(); + const next = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + + const response = await mw(next)('/api/todos', { + method: 'POST', + body: '{"title":"test"}' + }); + + expect(await response.json()).toEqual({ id: 1 }); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('queues POST requests when offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const mw = offlineQueue(); + const next = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + + const promise = mw(next)('/api/todos', { + method: 'POST', + body: '{"title":"test"}' + }); + + // Should NOT call next while offline + expect(next).not.toHaveBeenCalled(); + // Should return a pending promise + expect(promise).toBeInstanceOf(Promise); + }); + + test('store reflects queued requests', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const store = { queue: [], isOnline: false, isReplaying: false }; + const mw = offlineQueue({ store }); + + mw(vi.fn())('/api/todos', { method: 'POST', body: '{}' }); + mw(vi.fn())('/api/todos/1', { method: 'DELETE' }); + + expect(store.queue).toHaveLength(2); + expect(store.queue[0].url).toBe('/api/todos'); + expect(store.queue[0].init.method).toBe('POST'); + expect(store.queue[1].url).toBe('/api/todos/1'); + expect(typeof store.queue[0].id).toBe('string'); + expect(typeof store.queue[0].timestamp).toBe('number'); + }); + + test('flushes queue when going back online via flush', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const mw = offlineQueue(); + const next = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + + const promise = mw(next)('/api/todos', { + method: 'POST', + body: '{"title":"test"}' + }); + + expect(next).not.toHaveBeenCalled(); + + // Simulate going online + Object.defineProperty(navigator, 'onLine', { value: true }); + window.dispatchEvent(new Event('online')); + + // Wait a tick for the flush to process + await vi.waitFor(() => { + expect(next).toHaveBeenCalled(); + }); + + // The promise should resolve + const response = await promise; + expect(response).toBeDefined(); + }); + + test('online event triggers automatic flush', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const store = { queue: [], isOnline: false, isReplaying: false }; + const mw = offlineQueue({ store }); + const next = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + mw(next)('/api/todos', { method: 'POST', body: '{}' }); + + Object.defineProperty(navigator, 'onLine', { value: true }); + window.dispatchEvent(new Event('online')); + + await vi.waitFor(() => { + expect(store.isOnline).toBe(true); + expect(next).toHaveBeenCalled(); + }); + }); + + test('offline event updates store.isOnline', () => { + const store = { queue: [], isOnline: true, isReplaying: false }; + offlineQueue({ store }); + + window.dispatchEvent(new Event('offline')); + + expect(store.isOnline).toBe(false); + }); + + test('skip predicate bypasses queue for specific requests', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const mw = offlineQueue({ + skip: url => url.includes('skip-me') + }); + const next = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + await mw(next)('/api/skip-me', { method: 'POST' }); + + expect(next).toHaveBeenCalledTimes(1); + }); + + test('works in non-browser environments (defaults online)', async () => { + const originalWindow = globalThis.window; + const originalNavigator = globalThis.navigator; + (globalThis as any).window = undefined; + (globalThis as any).navigator = undefined; + + try { + const mw = offlineQueue(); + const next = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + const response = await mw(next)('/api/todos', { method: 'POST' }); + + expect(response).toBeDefined(); + expect(next).toHaveBeenCalledTimes(1); + } finally { + (globalThis as any).window = originalWindow; + (globalThis as any).navigator = originalNavigator; + } + }); + + test('store.isReplaying is true during flush', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const store = { queue: [], isOnline: false, isReplaying: false }; + const mw = offlineQueue({ store }); + const next = vi + .fn() + .mockImplementation( + () => + new Promise(resolve => + setTimeout( + () => resolve(jsonResponse({ ok: true })), + 50 + ) + ) + ); + + mw(next)('/api/todos', { method: 'POST', body: '{}' }); + + Object.defineProperty(navigator, 'onLine', { value: true }); + window.dispatchEvent(new Event('online')); + + expect(store.isReplaying).toBe(true); + + await vi.waitFor(() => { + expect(store.isReplaying).toBe(false); + }); + }); + + test('multiple queued requests are flushed in order', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const mw = offlineQueue(); + const next = vi + .fn() + .mockImplementation(() => + Promise.resolve(jsonResponse({ ok: true })) + ); + + const p1 = mw(next)('/api/todos', { method: 'POST', body: '{"n":1}' }); + const p2 = mw(next)('/api/todos', { method: 'POST', body: '{"n":2}' }); + const p3 = mw(next)('/api/todos', { method: 'POST', body: '{"n":3}' }); + + Object.defineProperty(navigator, 'onLine', { value: true }); + window.dispatchEvent(new Event('online')); + + const responses = await Promise.all([p1, p2, p3]); + expect(responses).toHaveLength(3); + expect(next).toHaveBeenCalledTimes(3); + }); +}); diff --git a/libs/client/src/middlewares/offlineQueue.ts b/libs/client/src/middlewares/offlineQueue.ts new file mode 100644 index 00000000..088b45f9 --- /dev/null +++ b/libs/client/src/middlewares/offlineQueue.ts @@ -0,0 +1,126 @@ +import type { FetchLike, Middleware } from '../middleware.js'; + +export interface QueuedRequest { + id: string; + url: string; + init: RequestInit; + timestamp: number; + retryCount: number; +} + +export interface OfflineQueueStore { + queue: QueuedRequest[]; + isOnline: boolean; + isReplaying: boolean; +} + +export interface OfflineQueueOptions { + store?: OfflineQueueStore; + skip?: (url: string, init: RequestInit) => boolean; + maxRetries?: number; +} + +const MAX_FLUSH_RETRIES = 3; + +interface Deferred { + resolve: (value: Response | PromiseLike) => void; + reject: (reason: unknown) => void; +} + +function generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof navigator !== 'undefined'; +} + +export function offlineQueue(options: OfflineQueueOptions = {}): Middleware { + const { + skip, + store = { queue: [], isOnline: true, isReplaying: false }, + maxRetries = MAX_FLUSH_RETRIES + } = options; + + const deferredMap = new Map(); + + let nextRef: FetchLike | null = null; + + function flushQueue(): void { + const next = nextRef; + if (!next || store.queue.length === 0) return; + + store.isReplaying = true; + const batch = store.queue.splice(0); + + Promise.all( + batch.map(async item => { + const deferred = deferredMap.get(item.id); + deferredMap.delete(item.id); + if (!deferred) return; + + try { + const init = { ...item.init }; + delete (init as any).signal; + const response = await next(item.url, init); + deferred.resolve(response); + } catch (err) { + if (item.retryCount < maxRetries) { + item.retryCount++; + store.queue.push(item); + deferredMap.set(item.id, deferred); + } else { + deferred.reject(err); + } + } + }) + ).finally(() => { + store.isReplaying = false; + }); + } + + store.isOnline = isBrowser() ? navigator.onLine : true; + + if (isBrowser()) { + window.addEventListener('online', () => { + store.isOnline = true; + flushQueue(); + }); + window.addEventListener('offline', () => { + store.isOnline = false; + }); + } + + return next => { + nextRef = next; + + return async (url, init) => { + if (skip?.(url, init)) { + return next(url, init); + } + + const method = (init.method ?? 'GET').toUpperCase(); + if (method === 'GET') { + return next(url, init); + } + + if (!store.isOnline) { + const id = generateId(); + const queued: QueuedRequest = { + id, + url, + init: { ...init }, + timestamp: Date.now(), + retryCount: 0 + }; + store.queue.push(queued); + + return new Promise((resolve, reject) => { + deferredMap.set(id, { resolve, reject }); + }); + } + + return next(url, init); + }; + }; +} diff --git a/libs/client/src/middlewares/optimisticUpdate.test.ts b/libs/client/src/middlewares/optimisticUpdate.test.ts new file mode 100644 index 00000000..90b8b60d --- /dev/null +++ b/libs/client/src/middlewares/optimisticUpdate.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, vi } from 'vitest'; +import { NetworkError } from '../errors.js'; +import { + OPTIMISTIC_MUTATION_ID, + optimisticUpdate +} from './optimisticUpdate.js'; + +describe('optimisticUpdate middleware', () => { + function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' } + }); + } + + test('passes GET requests through without tagging', async () => { + const mw = optimisticUpdate(); + const next = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + const response = await mw(next)('/api/todos', { method: 'GET' }); + + expect(response).toBeDefined(); + expect(next).toHaveBeenCalledTimes(1); + const init = next.mock.calls[0][1] as any; + expect(init[OPTIMISTIC_MUTATION_ID]).toBeUndefined(); + }); + + test('tags POST requests with a mutation ID', async () => { + const mw = optimisticUpdate(); + const next = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + + await mw(next)('/api/todos', { + method: 'POST', + body: '{"title":"test"}' + }); + + const init = next.mock.calls[0][1] as any; + expect(init[OPTIMISTIC_MUTATION_ID]).toBeDefined(); + expect(typeof init[OPTIMISTIC_MUTATION_ID]).toBe('string'); + }); + + test.each(['PUT', 'PATCH', 'DELETE'])('tags %s requests', async method => { + const mw = optimisticUpdate(); + const next = vi.fn().mockResolvedValue(jsonResponse({})); + + await mw(next)('/api/todos/1', { method }); + + const init = next.mock.calls[0][1] as any; + expect(init[OPTIMISTIC_MUTATION_ID]).toBeDefined(); + }); + + test('skip predicate bypasses tagging', async () => { + const mw = optimisticUpdate({ + skip: url => url.includes('skip-me') + }); + const next = vi.fn().mockResolvedValue(jsonResponse({})); + + await mw(next)('/api/skip-me', { method: 'POST' }); + + const init = next.mock.calls[0][1] as any; + expect(init[OPTIMISTIC_MUTATION_ID]).toBeUndefined(); + }); + + test('captures NetworkError in the store', async () => { + const store = { failures: [] }; + const mw = optimisticUpdate({ store }); + const next = vi.fn().mockRejectedValue(new NetworkError('offline')); + + await expect( + mw(next)('/api/todos', { method: 'POST' }) + ).rejects.toThrow(NetworkError); + + expect(store.failures).toHaveLength(1); + expect(store.failures[0].url).toBe('/api/todos'); + expect(store.failures[0].error).toBeInstanceOf(NetworkError); + expect(store.failures[0].id).toBeDefined(); + expect(store.failures[0].timestamp).toBeDefined(); + }); + + test('does NOT capture ApiError (HTTP errors) in the store', async () => { + const store = { failures: [] }; + const mw = optimisticUpdate({ store }); + const next = vi.fn().mockResolvedValue( + new Response('Not Found', { + status: 404, + statusText: 'Not Found' + }) + ); + + const response = await mw(next)('/api/todos/999', { method: 'DELETE' }); + + expect(response.status).toBe(404); + expect(store.failures).toHaveLength(0); + }); + + test('captures non-NetworkError rejections but does NOT add to store', async () => { + const store = { failures: [] }; + const mw = optimisticUpdate({ store }); + const next = vi.fn().mockRejectedValue(new Error('Something broke')); + + await expect( + mw(next)('/api/todos', { method: 'POST' }) + ).rejects.toThrow('Something broke'); + + expect(store.failures).toHaveLength(0); + }); + + test('uses default internal store when none provided', async () => { + const mw = optimisticUpdate(); + const next = vi.fn().mockRejectedValue(new NetworkError('offline')); + + await expect( + mw(next)('/api/todos', { method: 'POST' }) + ).rejects.toThrow(NetworkError); + + // Middleware still works without a custom store + }); + + test('multiple failures accumulate in the store', async () => { + const store = { failures: [] }; + const mw = optimisticUpdate({ store }); + const next = vi.fn().mockRejectedValue(new NetworkError('offline')); + + await expect( + mw(next)('/api/todos/1', { method: 'DELETE' }) + ).rejects.toThrow(NetworkError); + await expect( + mw(next)('/api/todos', { method: 'POST' }) + ).rejects.toThrow(NetworkError); + + expect(store.failures).toHaveLength(2); + }); +}); diff --git a/libs/client/src/middlewares/optimisticUpdate.ts b/libs/client/src/middlewares/optimisticUpdate.ts new file mode 100644 index 00000000..53951f0c --- /dev/null +++ b/libs/client/src/middlewares/optimisticUpdate.ts @@ -0,0 +1,59 @@ +import { isNetworkError } from '../errors.js'; +import type { Middleware } from '../middleware.js'; + +export const OPTIMISTIC_MUTATION_ID = Symbol('optimistic-mutation-id'); + +export interface OptimisticFailure { + id: string; + url: string; + init: RequestInit; + error: Error; + timestamp: number; +} + +export interface OptimisticUpdateStore { + failures: OptimisticFailure[]; +} + +export interface OptimisticUpdateOptions { + store?: OptimisticUpdateStore; + skip?: (url: string, init: RequestInit) => boolean; +} + +function generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function optimisticUpdate( + options: OptimisticUpdateOptions = {} +): Middleware { + const { skip, store = { failures: [] } } = options; + + return next => async (url, init) => { + const method = (init.method ?? 'GET').toUpperCase(); + if (method === 'GET') { + return next(url, init); + } + if (skip?.(url, init)) { + return next(url, init); + } + + const mutationId = generateId(); + (init as any)[OPTIMISTIC_MUTATION_ID] = mutationId; + + try { + return await next(url, init); + } catch (err) { + if (isNetworkError(err)) { + store.failures.push({ + id: mutationId, + url, + init: { ...init }, + error: err instanceof Error ? err : new Error(String(err)), + timestamp: Date.now() + }); + } + throw err; + } + }; +} diff --git a/libs/client/src/offlineQueue.ts b/libs/client/src/offlineQueue.ts new file mode 100644 index 00000000..620c59f2 --- /dev/null +++ b/libs/client/src/offlineQueue.ts @@ -0,0 +1,6 @@ +export type { + OfflineQueueOptions, + OfflineQueueStore, + QueuedRequest +} from './middlewares/offlineQueue.js'; +export { offlineQueue } from './middlewares/offlineQueue.js'; diff --git a/libs/client/src/optimisticUpdate.ts b/libs/client/src/optimisticUpdate.ts new file mode 100644 index 00000000..6f59d0af --- /dev/null +++ b/libs/client/src/optimisticUpdate.ts @@ -0,0 +1,9 @@ +export type { + OptimisticFailure, + OptimisticUpdateOptions, + OptimisticUpdateStore +} from './middlewares/optimisticUpdate.js'; +export { + OPTIMISTIC_MUTATION_ID, + optimisticUpdate +} from './middlewares/optimisticUpdate.js'; diff --git a/libs/client/src/react/createClient.ts b/libs/client/src/react/createClient.ts index ac9b6448..87993b4a 100644 --- a/libs/client/src/react/createClient.ts +++ b/libs/client/src/react/createClient.ts @@ -45,6 +45,9 @@ import type { UnifiedClient } from './types.js'; * Each group also exposes a `queryKey()` method for group-level * cache invalidation. * + * When the `cacheTags` middleware is active, `useMutation` hooks + * automatically invalidate TanStack Query entries for the affected group. + * * @param contract - An API contract created with `defineApi()`. * @param options - Client options passed to `@cleverbrush/web`'s `createClient()`. * @returns A fully typed unified client proxy. @@ -112,7 +115,12 @@ export function createClient( group, endpoint ); - call.useMutation = createUseMutation(webClient, group, endpoint); + call.useMutation = createUseMutation( + webClient, + group, + endpoint, + extractCacheTagNames(contract, group, endpoint) + ); call.queryKey = createQueryKey(group, endpoint); call.prefetch = createPrefetch(webClient, group, endpoint); @@ -146,3 +154,20 @@ export function createClient( } }); } + +function extractCacheTagNames( + contract: any, + group: string, + endpoint: string +): string[] | undefined { + try { + const ep = contract[group]?.[endpoint]; + if (!ep || typeof ep.introspect !== 'function') return undefined; + const meta = ep.introspect(); + const tags: Array<{ name: string }> = meta.cacheTags ?? []; + if (tags.length === 0) return undefined; + return tags.map(t => t.name); + } catch { + return undefined; + } +} diff --git a/libs/client/src/react/hooks.test.ts b/libs/client/src/react/hooks.test.ts index 1ee820b6..8ced4b8e 100644 --- a/libs/client/src/react/hooks.test.ts +++ b/libs/client/src/react/hooks.test.ts @@ -14,12 +14,14 @@ function mockEndpoint(meta: { pathTemplate?: | string | { serialize: (p: Record) => string }; + cacheTags?: ReadonlyArray<{ name: string }>; }) { return { introspect: () => ({ method: meta.method, basePath: meta.basePath, - pathTemplate: meta.pathTemplate ?? '' + pathTemplate: meta.pathTemplate ?? '', + cacheTags: meta.cacheTags ?? [] }) }; } @@ -35,7 +37,11 @@ function createMockContract() { serialize: (p: Record) => `/${p.id}` } }), - create: mockEndpoint({ method: 'POST', basePath: '/api/todos' }) + create: mockEndpoint({ + method: 'POST', + basePath: '/api/todos', + cacheTags: [{ name: 'todo-list' }] + }) }, users: { me: mockEndpoint({ method: 'GET', basePath: '/api/users/me' }) @@ -210,6 +216,74 @@ describe('hooks integration', () => { await waitFor(() => expect(onError).toHaveBeenCalledOnce()); }); + + test('invalidates query cache on success when endpoint has cacheTags', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 1 }, 201)); + + const { result } = renderHook( + () => queryApi.todos.create.useMutation(), + { wrapper: createWrapper(queryClient) } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'Test' } } as any); + }); + + await waitFor(() => + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['@cleverbrush', 'todos'] + }) + ); + }); + + test('does not invalidate when endpoint has no cacheTags', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 1 }, 201)); + + const { result } = renderHook( + () => queryApi.todos.list.useMutation(), + { wrapper: createWrapper(queryClient) } + ); + + await act(async () => { + result.current.mutate({} as any); + }); + + // Wait a tick to ensure no calls + await vi.waitFor( + () => + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: ['@cleverbrush', 'todos'] + }), + { timeout: 1000 } + ); + }); + + test('still calls onSuccess alongside auto-invalidation', async () => { + const onSuccess = vi.fn(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 1 }, 201)); + + const { result } = renderHook( + () => + queryApi.todos.create.useMutation({ + onSuccess + }), + { wrapper: createWrapper(queryClient) } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'Test' } } as any); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledOnce(); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['@cleverbrush', 'todos'] + }); + }); + }); }); // -- prefetch ----------------------------------------------------------- diff --git a/libs/client/src/react/hooks.ts b/libs/client/src/react/hooks.ts index 99c60a63..54c9aec2 100644 --- a/libs/client/src/react/hooks.ts +++ b/libs/client/src/react/hooks.ts @@ -13,6 +13,7 @@ import { useInfiniteQuery, useMutation, useQuery, + useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import type { WebError } from '../index.js'; @@ -153,18 +154,42 @@ export function createUseInfiniteQuery( /** * Creates a `useMutation` hook for the given endpoint. * + * When `cacheTagNames` is provided, the hook automatically invalidates + * TanStack Query entries for the endpoint's group on mutation success + * — no manual `queryClient.invalidateQueries()` needed. + * + * @param webClient - The underlying typed web client. + * @param group - API contract group name (e.g. `"todos"`). + * @param endpoint - Endpoint name within the group (e.g. `"create"`). + * @param cacheTagNames - Optional cache tag names declared on the + * endpoint via `.clearsCacheTag()`. When non-empty, triggers automatic + * `queryClient.invalidateQueries()` on mutation success. * @internal */ export function createUseMutation( webClient: AnyClient, group: string, - endpoint: string + endpoint: string, + cacheTagNames?: readonly string[] ) { return function hookUseMutation(options?: any): any { + const queryClient = useQueryClient(); + return useMutation({ ...options, mutationFn: (args: any) => - callEndpoint(webClient, group, endpoint, args) + callEndpoint(webClient, group, endpoint, args), + onSuccess: (...args: any[]) => { + options?.onSuccess?.(...args); + + // Auto-invalidate TanStack Query cache when endpoint + // declares cache tags and cacheTags middleware is active. + if (cacheTagNames && cacheTagNames.length > 0) { + queryClient.invalidateQueries({ + queryKey: ['@cleverbrush', group] + }); + } + } }); }; } diff --git a/libs/client/src/react/index.ts b/libs/client/src/react/index.ts index 492778bd..b29cc600 100644 --- a/libs/client/src/react/index.ts +++ b/libs/client/src/react/index.ts @@ -27,6 +27,8 @@ */ export { createClient } from './createClient.js'; +export type { OptimisticMutationConfig } from './optimisticMutation.js'; +export { useOptimisticMutation } from './optimisticMutation.js'; export { buildGroupQueryKey, buildQueryKey, diff --git a/libs/client/src/react/optimisticMutation.test.ts b/libs/client/src/react/optimisticMutation.test.ts new file mode 100644 index 00000000..0af4ac98 --- /dev/null +++ b/libs/client/src/react/optimisticMutation.test.ts @@ -0,0 +1,250 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useOptimisticMutation } from './optimisticMutation.js'; + +describe('useOptimisticMutation', () => { + let queryClient: QueryClient; + + function createWrapper() { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children + ); + }; + } + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + + // Seed some initial data + const initialData = [ + { id: 1, title: 'Buy milk', completed: false }, + { id: 2, title: 'Walk dog', completed: true } + ]; + queryClient.setQueryData(['todos', 'list'], initialData); + }); + + test('applies optimistic update before mutation resolves', async () => { + const mutationFn = vi + .fn() + .mockResolvedValue({ id: 1, title: 'Buy milk', completed: true }); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any, args: any) => + (oldData ?? []).map((t: any) => + t.id === args.params.id + ? { ...t, completed: args.body.completed } + : t + ) + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ + params: { id: 1 }, + body: { completed: true } + }); + }); + + // The optimistic update should have been applied immediately + const cached = queryClient.getQueryData(['todos', 'list']) as any[]; + expect(cached[0].completed).toBe(true); + }); + + test('rolls back on error', async () => { + const mutationFn = vi.fn().mockRejectedValue(new Error('Server error')); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any, args: any) => + (oldData ?? []).map((t: any) => + t.id === args.params.id + ? { ...t, completed: args.body.completed } + : t + ) + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ + params: { id: 1 }, + body: { completed: true } + }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + // Should have rolled back to original state + const cached = queryClient.getQueryData(['todos', 'list']) as any[]; + expect(cached[0].completed).toBe(false); + }); + + test('cancels in-flight queries before optimistic update', async () => { + const cancelSpy = vi.spyOn(queryClient, 'cancelQueries'); + const mutationFn = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any) => oldData + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'Test' } } as any); + }); + + expect(cancelSpy).toHaveBeenCalledWith({ queryKey: ['todos', 'list'] }); + }); + + test('calls onSuccess callback when mutation succeeds', async () => { + const onSuccess = vi.fn(); + const mutationFn = vi.fn().mockResolvedValue({ id: 3, title: 'New' }); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any) => oldData, + onSuccess + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'New' } } as any); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + }); + + test('calls onError callback when mutation fails', async () => { + const onError = vi.fn(); + const mutationFn = vi.fn().mockRejectedValue(new Error('Failed')); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any) => oldData, + onError + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'Fail' } } as any); + }); + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()); + }); + + test('calls onSettled callback after mutation completes', async () => { + const onSettled = vi.fn(); + const mutationFn = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any) => oldData, + onSettled + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'Test' } } as any); + }); + + await waitFor(() => expect(onSettled).toHaveBeenCalledOnce()); + }); + + test('invalidates query cache on settled', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const mutationFn = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any) => oldData + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'Test' } } as any); + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['todos', 'list'] + }); + }); + }); + + test('handles undefined cache data gracefully', async () => { + queryClient.removeQueries({ queryKey: ['todos', 'list'] }); + const mutationFn = vi.fn().mockResolvedValue({ ok: true }); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (_oldData: any, args: any) => { + return [args.body]; + } + }), + { wrapper: createWrapper() } + ); + + await act(async () => { + result.current.mutate({ body: { title: 'First' } } as any); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const cached = queryClient.getQueryData(['todos', 'list']) as any[]; + expect(cached).toEqual([{ title: 'First' }]); + }); + + test('returns standard UseMutationResult shape', () => { + const mutationFn = vi.fn(); + + const { result } = renderHook( + () => + useOptimisticMutation(mutationFn, { + queryKey: ['todos', 'list'], + optimisticUpdate: (oldData: any) => oldData + }), + { wrapper: createWrapper() } + ); + + expect(result.current).toHaveProperty('mutate'); + expect(result.current).toHaveProperty('mutateAsync'); + expect(result.current).toHaveProperty('isPending'); + expect(result.current).toHaveProperty('isSuccess'); + expect(result.current).toHaveProperty('isError'); + expect(result.current).toHaveProperty('data'); + expect(result.current).toHaveProperty('error'); + }); +}); diff --git a/libs/client/src/react/optimisticMutation.ts b/libs/client/src/react/optimisticMutation.ts new file mode 100644 index 00000000..a53bbb2f --- /dev/null +++ b/libs/client/src/react/optimisticMutation.ts @@ -0,0 +1,68 @@ +import { + type QueryKey, + useMutation, + useQueryClient +} from '@tanstack/react-query'; +import { useRef } from 'react'; +import type { WebError } from '../errors.js'; + +export interface OptimisticMutationConfig { + queryKey: QueryKey; + optimisticUpdate: (oldData: TData | undefined, args: TArgs) => TData; + onSuccess?: (data: TData, args: TArgs) => void; + onError?: (error: WebError, args: TArgs) => void; + onSettled?: ( + data: TData | undefined, + error: WebError | null, + args: TArgs + ) => void; +} + +export function useOptimisticMutation( + mutationFn: (args: TArgs) => Promise, + config: OptimisticMutationConfig +) { + const queryClient = useQueryClient(); + const { queryKey, optimisticUpdate, onSuccess, onError, onSettled } = + config; + const pendingCount = useRef(0); + + return useMutation({ + mutationFn, + onMutate: async (args: TArgs) => { + pendingCount.current++; + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (old: unknown) => + optimisticUpdate(old as TData | undefined, args) + ); + return { previous }; + }, + onError: (error: WebError, args: TArgs, context: unknown) => { + const ctx = context as { previous?: TData } | undefined; + if (ctx?.previous !== undefined) { + queryClient.setQueryData(queryKey, ctx.previous); + } + onError?.(error, args); + }, + onSuccess: (data: TData, args: TArgs) => { + onSuccess?.(data, args); + }, + onSettled: ( + data: TData | undefined, + error: WebError | null, + args: TArgs + ) => { + pendingCount.current--; + if (pendingCount.current === 0) { + // Cancel any background GET that may have snuck in (e.g. from a + // window-focus refetch) and potentially cached an intermediate + // server state, then trigger the definitive invalidation. + queryClient + .cancelQueries({ queryKey }) + .then(() => queryClient.invalidateQueries({ queryKey })); + onSettled?.(data, error, args); + } + } + }); +} diff --git a/libs/client/src/types.ts b/libs/client/src/types.ts index 9cf9b112..135dd85b 100644 --- a/libs/client/src/types.ts +++ b/libs/client/src/types.ts @@ -192,6 +192,16 @@ export interface PerCallOverrides { * Override the timeout (in milliseconds) for this call only. */ timeout?: number; + /** + * Override optimistic update middleware options for this call. + * Pass `{ skip: true }` to skip tagging for this mutation. + */ + optimisticUpdate?: { skip?: boolean }; + /** + * Override offline queue middleware options for this call. + * Pass `{ skip: true }` to bypass the queue for this mutation. + */ + offlineQueue?: { skip?: boolean }; } /** diff --git a/libs/client/tsup.config.ts b/libs/client/tsup.config.ts index 51abe83c..2e9edd8d 100644 --- a/libs/client/tsup.config.ts +++ b/libs/client/tsup.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ 'src/dedupe.ts', 'src/cache.ts', 'src/batching.ts', + 'src/optimisticUpdate.ts', + 'src/offlineQueue.ts', 'src/react.ts' ], format: ['esm'], diff --git a/libs/server/README.md b/libs/server/README.md index 360e3b67..941cc1ce 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -124,6 +124,36 @@ const CreateUser = endpoint .operationId('createUser'); ``` +### Cache Tags + +Tag-based cache invalidation. Tags declared on endpoints flow to the +[`cacheTags` middleware](/client/cache-tags) for automatic HTTP caching and +invalidation on mutating requests. + +```ts +const ListTodos = endpoint + .get('/api/todos') + .query(TodoListQuerySchema) + .cacheTag('todo-list', p => ({ page: p.query.page, limit: p.query.limit })) + .returns(array(TodoSchema)); + +const UpdateTodo = endpoint + .patch('/api/todos/:id') + .body(UpdateTodoBody) + .clearsCacheTag('todo-list') // clears the collection cache + .clearsCacheTag('todo', p => ({ id: p.params.id })) // clears specific entity + .returns(TodoSchema); +``` + +- **`.cacheTag(name)`** — declares the endpoint's data belongs to a cache + group. Use on GET endpoints. +- **`.clearsCacheTag(name)`** — declares that this mutation clears matching + cache entries on success. Use on POST / PUT / PATCH / DELETE. +- **`.cacheTag(name, p => ({ ... }))`** — property-based tag; each selected + property becomes part of the cache key (different pages → different entries). +- **Immutability** — both methods return a new builder; the original is + unchanged. + ## Registering and Handling Endpoints ```ts diff --git a/libs/server/src/CacheTag.ts b/libs/server/src/CacheTag.ts new file mode 100644 index 00000000..f6d44542 --- /dev/null +++ b/libs/server/src/CacheTag.ts @@ -0,0 +1,214 @@ +import { + type ObjectSchemaBuilder, + object, + type PropertyDescriptor, + type SchemaBuilder, + SYMBOL_HAS_PROPERTIES, + SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR +} from '@cleverbrush/schema'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * An accessor that can extract a property value from a structured + * request root. Wraps a {@link PropertyDescriptor}'s `getValue` + * closure so the middleware layer does not need to know about schemas. + */ +export interface CacheTagPropertyAccessor { + getValue(root: { + params: Record; + body: unknown; + query: Record; + headers: Record; + }): { value?: unknown; success: boolean }; +} + +/** + * A serialisable cache-tag definition stored on endpoint metadata. + * + * `properties` maps human-readable key names (used as label segments + * in the final cache key) to accessors that resolve the actual value + * from call-time request data. + */ +export interface CacheTagDefinition { + readonly name: string; + readonly properties: Readonly>; +} + +// --------------------------------------------------------------------------- +// Synthetic schema construction +// --------------------------------------------------------------------------- + +/** + * Builds a synthetic `object({ params, body, query, headers })` schema + * from the endpoint's schema definitions and returns its + * `PropertyDescriptorTree` so callers can write type-safe selectors like: + * + * ```ts + * endpoint.cacheTag('todo', p => ({ + * id: p.query.id, + * fromBodyId: p.body.id + * })) + * ``` + * + * Only non-null schemas are included in the synthetic schema. + */ +export function createCacheTagTree(schemas: { + paramsSchema?: SchemaBuilder | null; + bodySchema?: SchemaBuilder | null; + querySchema?: ObjectSchemaBuilder | null; + headerSchema?: ObjectSchemaBuilder< + any, + any, + any, + any, + any, + any, + any + > | null; +}): any { + const props: Record> = {}; + + if (schemas.paramsSchema) { + const ps = schemas.paramsSchema; + // ParseStringSchemaBuilder wraps an ObjectSchemaBuilder; + // extract it so PropertyDescriptorTree can recurse into params. + if ( + typeof (ps as any).introspect === 'function' && + (ps as any).introspect().objectSchema + ) { + props.params = (ps as any).introspect().objectSchema; + } else if ((ps as any)[SYMBOL_HAS_PROPERTIES] === true) { + props.params = ps; + } + } + + if (schemas.bodySchema) { + const bs = schemas.bodySchema; + if ( + (bs as any)[SYMBOL_HAS_PROPERTIES] === true || + typeof (bs as any).introspect === 'function' + ) { + props.body = bs; + } + } + + if (schemas.querySchema) { + props.query = schemas.querySchema; + } + + if (schemas.headerSchema) { + props.headers = schemas.headerSchema; + } + + const syn = object(props as any); + return (object as any).getPropertiesFor(syn); +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/** + * Validates that a value returned from a cache-tag selector is a valid + * property descriptor. + */ +function isPropertyDescriptor(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value !== 'object') return false; + return ( + typeof (value as any)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR] === 'object' && + (value as any)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR] !== null + ); +} + +/** + * Serialises the result of a cache-tag selector callback into a + * {@link CacheTagDefinition} that can be stored on endpoint metadata + * and forwarded to the client middleware. + * + * Each value in `descriptors` must be a {@link PropertyDescriptor} — + * its `getValue` closure is wrapped in a {@link CacheTagPropertyAccessor}. + * + * @throws If any value is not a valid property descriptor. + */ +export function serializeTag( + name: string, + descriptors: Record +): CacheTagDefinition { + const properties: Record = {}; + + for (const [key, value] of Object.entries(descriptors)) { + if (!isPropertyDescriptor(value)) { + throw new Error( + `Cache tag "${name}": property "${key}" is not a valid ` + + `PropertyDescriptor. Make sure you select a leaf property ` + + `from the tree (e.g. p.query.id, not p.query).` + ); + } + + const inner = (value as PropertyDescriptor)[ + SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR + ]; + + properties[key] = { + getValue: (root: { + params: Record; + body: unknown; + query: Record; + headers: Record; + }) => inner.getValue(root as any) + }; + } + + return { name, properties }; +} + +// --------------------------------------------------------------------------- +// Key computation (client-side) +// --------------------------------------------------------------------------- + +/** + * Computes a deterministic cache key from a tag definition and live + * request data. + * + * - Simple tags (no properties) produce just the tag name. + * - Tags with properties produce `name:key1=val1,key2=val2` where + * keys are sorted alphabetically for determinism. + * + * Properties whose `getValue` returns `success: false` are skipped + * (their value is not included in the key). + */ +export function computeCacheKey( + tag: CacheTagDefinition, + root: { + params: Record; + body: unknown; + query: Record; + headers: Record; + } +): string { + const entries = Object.entries(tag.properties); + + if (entries.length === 0) { + return tag.name; + } + + const parts: string[] = []; + for (const [key, accessor] of entries.sort(([a], [b]) => + a.localeCompare(b) + )) { + const result = accessor.getValue(root); + if (result.success && result.value !== undefined) { + parts.push(`${key}=${String(result.value)}`); + } + } + + if (parts.length === 0) { + return tag.name; + } + + return `${tag.name}:${parts.join(',')}`; +} diff --git a/libs/server/src/Endpoint.ts b/libs/server/src/Endpoint.ts index 7710e460..7bfcce4e 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -1,3 +1,4 @@ +// biome-ignore-all lint/suspicious/useAdjacentOverloadSignatures: each method in ScopedEndpointFactoryMethods and EndpointFactory has a single signature; they are separate methods, not overloads import type { InferType, ObjectSchemaBuilder, @@ -5,6 +6,7 @@ import type { PropertyDescriptorTree, SchemaBuilder } from '@cleverbrush/schema'; +import { SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR } from '@cleverbrush/schema'; import type { ActionResult, ContentResult, @@ -15,6 +17,8 @@ import type { StatusCodeResult, StreamResult } from './ActionResult.js'; +import type { CacheTagDefinition } from './CacheTag.js'; +import { createCacheTagTree, serializeTag } from './CacheTag.js'; import type { RequestContext } from './RequestContext.js'; import { createSubscription, @@ -534,6 +538,11 @@ export interface EndpointMetadata { * OpenAPI Operation Object. */ readonly callbacks: Record | null; + /** + * Cache tags declared via `.clearsCacheTag()`, providing tag-based cache + * key computation for the client middleware. + */ + readonly cacheTags: readonly CacheTagDefinition[]; } /** @@ -566,6 +575,61 @@ type InferResponsesMap< : null; }; +// --------------------------------------------------------------------------- +// Cache-tag selector type — gives the consumer IDE hints when selecting +// properties from the tree passed to the `.clearsCacheTag()` callback. +// --------------------------------------------------------------------------- + +/** + * A leaf node in a cache-tag property tree — mirrors the shape of the + * actual runtime {@link PropertyDescriptor} so the compiler accepts + * values selected by the consumer. + */ +interface CacheTagPropertyLeaf { + readonly [SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]: { + readonly getValue: (obj: Record) => { + readonly value?: unknown; + readonly success: boolean; + }; + }; +} + +/** Recursively builds a typed property tree from an inferred object shape. */ +type CacheTagPropertyTree = CacheTagPropertyLeaf & + (T extends Record + ? { readonly [K in keyof T]-?: CacheTagPropertyTree } + : unknown); + +/** + * The typed tree passed to the `.clearsCacheTag(name, selector)` callback. + * + * `p.params`, `p.query`, and `p.headers` provide IDE completion for + * each schema's property names, while `p.body` resolves through the + * body schema's `InferType`. + */ +type CacheTagSelector = { + readonly params: [keyof TParams] extends [never] + ? Record + : TParams extends Record + ? CacheTagPropertyTree + : Record; + readonly body: TBody extends undefined + ? undefined + : TBody extends SchemaBuilder + ? CacheTagPropertyTree> + : Record; + readonly query: [keyof TQuery] extends [never] + ? Record + : TQuery extends Record + ? CacheTagPropertyTree + : Record; + readonly headers: [keyof THeaders] extends [never] + ? Record + : THeaders extends Record + ? CacheTagPropertyTree + : Record; +}; + export class EndpointBuilder< TParams = {}, TBody = undefined, @@ -639,6 +703,7 @@ export class EndpointBuilder< readonly #externalDocs: { url: string; description?: string } | null; readonly #links: Record | null; readonly #callbacks: Record | null; + readonly #cacheTags: readonly CacheTagDefinition[]; constructor( method: string, @@ -702,7 +767,8 @@ export class EndpointBuilder< > | null = null, externalDocs: { url: string; description?: string } | null = null, links: Record | null = null, - callbacks: Record | null = null + callbacks: Record | null = null, + cacheTags: readonly CacheTagDefinition[] = [] ) { this.#method = method; this.#basePath = basePath; @@ -727,6 +793,7 @@ export class EndpointBuilder< this.#externalDocs = externalDocs; this.#links = links; this.#callbacks = callbacks; + this.#cacheTags = cacheTags; } /** Define the request body schema. Validation failures return 422 Problem Details. */ @@ -766,7 +833,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -809,7 +877,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -852,7 +921,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -895,7 +965,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -987,7 +1058,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1054,7 +1126,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1121,7 +1194,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1162,7 +1236,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1203,7 +1278,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1244,7 +1320,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1285,7 +1362,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1324,7 +1402,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1372,7 +1451,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1423,7 +1503,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1473,7 +1554,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1528,7 +1610,8 @@ export class EndpointBuilder< responseHeaderSchema: this.#responseHeaderSchema, externalDocs: this.#externalDocs, links: this.#links, - callbacks: this.#callbacks + callbacks: this.#callbacks, + cacheTags: this.#cacheTags }; } @@ -1592,7 +1675,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1652,7 +1736,8 @@ export class EndpointBuilder< schema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1701,7 +1786,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, { url, description }, this.#links, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1761,7 +1847,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, defs as Record, - this.#callbacks + this.#callbacks, + this.#cacheTags ); } @@ -1823,7 +1910,214 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - defs as Record + defs as Record, + this.#cacheTags + ); + } + + /** + * Declare a cache group for this endpoint. + * + * Use on GET / query endpoints to group responses into a named cache. + * The client-side {@code cacheTags} middleware caches responses keyed + * by this tag and flushes matching entries when a mutation calls + * {@link clearsCacheTag}. + * + * @overload Simple tag (no properties — single cache entry). + * @overload Tag with property descriptors for fine-grained keys. + * + * @example + * ```ts + * // GET — responses cached under "todo" group, keyed by id + * endpoint.get('/api/todos/:id') + * .cacheTag('todo', p => ({ + * id: p.params.id + * })) + * ``` + */ + cacheTag( + name: string + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses + >; + cacheTag( + name: string, + selector: ( + tree: CacheTagSelector + ) => Record + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses + >; + cacheTag( + name: string, + selector?: (tree: any) => Record + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses + > { + return this.clearsCacheTag(name, selector!); + } + + /** + * Declare which cache groups are cleared when this mutation succeeds. + * + * Use on POST / PUT / PATCH / DELETE endpoints. When the mutation + * completes, the {@code cacheTags} client middleware invalidates all + * cache entries matching the declared tag names (prefix match). + * + * @overload Simple tag (clears all entries prefixed with the name). + * @overload Tag with property descriptors for targeted invalidation. + * + * @example + * ```ts + * // PATCH — clears "todo-list" and "todo:id=42" on success + * endpoint.patch('/api/todos/:id') + * .clearsCacheTag('todo-list') + * .clearsCacheTag('todo', p => ({ + * id: p.params.id + * })) + * ``` + */ + clearsCacheTag( + name: string + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses + >; + clearsCacheTag( + name: string, + selector: ( + tree: CacheTagSelector + ) => Record + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses + >; + clearsCacheTag( + name: string, + selector?: (tree: any) => Record + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses + > { + if (!selector) { + return new EndpointBuilder( + this.#method, + this.#basePath, + this.#pathTemplate, + this.#bodySchema, + this.#querySchema, + this.#headerSchema, + this.#serviceSchemas, + this.#authRoles, + this.#summary, + this.#description, + this.#tags, + this.#operationId, + this.#deprecated, + this.#responseSchema, + this.#responsesSchemas, + this.#example, + this.#examples, + this.#producesFile, + this.#produces, + this.#responseHeaderSchema, + this.#externalDocs, + this.#links, + this.#callbacks, + [...this.#cacheTags, { name, properties: {} }] + ); + } + + const paramsSchema = extractParamsObjectSchema(this.#pathTemplate); + + const tree = createCacheTagTree({ + paramsSchema, + bodySchema: this.#bodySchema, + querySchema: this.#querySchema, + headerSchema: this.#headerSchema + }); + + const descriptors = selector(tree); + + if (typeof descriptors !== 'object' || descriptors === null) { + throw new Error( + `Cache tag "${name}": selector must return an object ` + + `with property descriptors (e.g. { id: p.query.id }).` + ); + } + + const definition = serializeTag(name, descriptors); + + return new EndpointBuilder( + this.#method, + this.#basePath, + this.#pathTemplate, + this.#bodySchema, + this.#querySchema, + this.#headerSchema, + this.#serviceSchemas, + this.#authRoles, + this.#summary, + this.#description, + this.#tags, + this.#operationId, + this.#deprecated, + this.#responseSchema, + this.#responsesSchemas, + this.#example, + this.#examples, + this.#producesFile, + this.#produces, + this.#responseHeaderSchema, + this.#externalDocs, + this.#links, + this.#callbacks, + [...this.#cacheTags, definition] ); } } @@ -1849,7 +2143,7 @@ function createEndpoint( pathTemplate?: ParseStringSchemaBuilder, authRoles?: readonly string[] | null, meta?: EndpointMetadataDescriptors -): EndpointBuilder; +): EndpointBuilder; function createEndpoint( method: string, @@ -1981,6 +2275,84 @@ type ScopedEndpointFactoryMethods< any, {} >; + post( + pathTemplate?: ParseStringSchemaBuilder + ): EndpointBuilder< + TParams extends undefined ? {} : TParams, + undefined, + {}, + {}, + {}, + TPrincipal, + TRoles, + any, + {} + >; + put( + pathTemplate?: ParseStringSchemaBuilder + ): EndpointBuilder< + TParams extends undefined ? {} : TParams, + undefined, + {}, + {}, + {}, + TPrincipal, + TRoles, + any, + {} + >; + patch( + pathTemplate?: ParseStringSchemaBuilder + ): EndpointBuilder< + TParams extends undefined ? {} : TParams, + undefined, + {}, + {}, + {}, + TPrincipal, + TRoles, + any, + {} + >; + delete( + pathTemplate?: ParseStringSchemaBuilder + ): EndpointBuilder< + TParams extends undefined ? {} : TParams, + undefined, + {}, + {}, + {}, + TPrincipal, + TRoles, + any, + {} + >; + head( + pathTemplate?: ParseStringSchemaBuilder + ): EndpointBuilder< + TParams extends undefined ? {} : TParams, + undefined, + {}, + {}, + {}, + TPrincipal, + TRoles, + any, + {} + >; + options( + pathTemplate?: ParseStringSchemaBuilder + ): EndpointBuilder< + TParams extends undefined ? {} : TParams, + undefined, + {}, + {}, + {}, + TPrincipal, + TRoles, + any, + {} + >; }; export type ScopedEndpointFactory = @@ -2044,108 +2416,58 @@ function createScopedFactory(basePath: string): ScopedEndpointFactory { } // --------------------------------------------------------------------------- -// EndpointFactory — top-level endpoint creation +// endpoint factory — creates EndpointBuilder instances // --------------------------------------------------------------------------- +/** + * Extracts an ObjectSchemaBuilder from a ParseStringSchemaBuilder path template. + * Used for constructing the synthetic cache tag tree. + */ +function extractParamsObjectSchema( + pathTemplate: RoutePath +): ObjectSchemaBuilder | null { + if ( + pathTemplate && + typeof pathTemplate !== 'string' && + typeof (pathTemplate as any).introspect === 'function' + ) { + const info = (pathTemplate as any).introspect(); + if (info.objectSchema) { + return info.objectSchema; + } + } + return null; +} + type EndpointFactory = { get( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; post( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; put( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; patch( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; delete( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; head( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; options( basePath: string, pathTemplate?: ParseStringSchemaBuilder - ): EndpointBuilder< - TParams, - undefined, - {}, - {}, - {}, - undefined, - TRoles, - any, - {} - >; + ): EndpointBuilder; resource(basePath: string): ScopedEndpointFactory; subscription( basePath: string, diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index 181bf826..49423002 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -27,6 +27,10 @@ * @module */ +export type { + CacheTagDefinition, + CacheTagPropertyAccessor +} from './CacheTag.js'; export { type ActionContext, type AllowedResponseReturn, diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index d813777b..3513bd5c 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -8,6 +8,13 @@ export { StatusCodeResult, StreamResult } from './ActionResult.js'; +export { + type CacheTagDefinition, + type CacheTagPropertyAccessor, + computeCacheKey, + createCacheTagTree, + serializeTag +} from './CacheTag.js'; export { type ApiContract, type ApiGroup, @@ -44,6 +51,14 @@ export { NotFoundError, UnauthorizedError } from './HttpError.js'; +export { + idempotency, + type ServerIdempotencyOptions +} from './middlewares/Idempotency.js'; +export { + cacheResponse, + type ServerCacheOptions +} from './middlewares/ResponseCache.js'; export { createProblemDetails, createValidationProblemDetails, diff --git a/libs/server/src/middlewares/Idempotency.ts b/libs/server/src/middlewares/Idempotency.ts new file mode 100644 index 00000000..b30bb8b2 --- /dev/null +++ b/libs/server/src/middlewares/Idempotency.ts @@ -0,0 +1,185 @@ +/** + * Server-side idempotency middleware. + * + * Ensures mutating requests with the same idempotency key produce the + * same result exactly once — subsequent replays return the stored + * response without re-executing the handler. + * + * @module + */ + +import type { ServerResponse } from 'node:http'; +import type { RequestContext } from '../RequestContext.js'; +import type { Middleware } from '../types.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Configuration for {@link idempotency}. + */ +export interface ServerIdempotencyOptions { + /** + * TTL in milliseconds for stored responses. + * Defaults to `86_400_000` (24 hours). + */ + ttl?: number; + + /** + * Header name to read the idempotency key from. + * Defaults to `"x-idempotency-key"`. + */ + headerName?: string; + + /** + * Predicate that decides whether a request should be skipped. + * Defaults to skipping non-mutating requests (GET, HEAD, OPTIONS). + */ + skip?: (ctx: RequestContext) => boolean; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +interface StoredResponse { + status: number; + headers: Record; + body: Buffer; + expiresAt: number; +} + +function isMutating(method: string): boolean { + return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase()); +} + +const CLEANUP_INTERVAL = 60_000; + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- + +/** + * Server-side idempotency middleware. + * + * Reads the `x-idempotency-key` header from mutating requests. If a + * response has already been stored for that key, it is returned + * immediately — the handler is never called. Otherwise the handler + * executes and its response is stored for future replays. + * + * GET, HEAD, and OPTIONS requests pass through without checking. + * + * @param options - Configuration. + * @returns A server-side {@link Middleware}. + * + * @example + * ```ts + * server.handle(CreateTodo, createHandler, { + * middlewares: [idempotency({ ttl: 86_400_000 })] + * }); + * ``` + */ +export function idempotency( + options: ServerIdempotencyOptions = {} +): Middleware { + const { + ttl = 86_400_000, + headerName = 'x-idempotency-key', + skip = (ctx: RequestContext) => !isMutating(ctx.method) + } = options; + + const store = new Map(); + + // Periodic cleanup of expired entries + const cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (entry.expiresAt <= now) { + store.delete(key); + } + } + }, CLEANUP_INTERVAL); + + if (cleanupTimer.unref) { + cleanupTimer.unref(); + } + + return async (ctx: RequestContext, next: () => Promise) => { + if (skip(ctx)) { + return next(); + } + + const key = + ctx.headers[headerName] ?? ctx.headers[headerName.toLowerCase()]; + if (!key || typeof key !== 'string' || key.length === 0) { + return next(); + } + + // Check if we already have a stored response for this key + const stored = store.get(key); + if (stored) { + if (stored.expiresAt <= Date.now()) { + store.delete(key); + // Expired — fall through to handler + } else { + // Replay stored response + const res = ctx.response as ServerResponse; + res.writeHead(stored.status, stored.headers); + res.end(stored.body); + ctx.responded = true; + return; + } + } + + // Capture the handler's response for future replays + const originalWriteHead = ( + ctx.response as ServerResponse + ).writeHead.bind(ctx.response); + const originalEnd = (ctx.response as ServerResponse).end.bind( + ctx.response + ); + + let capturedStatus = 200; + let capturedHeaders: Record = {}; + const chunks: Buffer[] = []; + + (ctx.response as ServerResponse).writeHead = function ( + this: ServerResponse, + statusCode: number, + ...args: any[] + ) { + capturedStatus = statusCode; + if (args.length > 0) { + capturedHeaders = args[0]; + } + return originalWriteHead(statusCode, ...args) as ServerResponse; + } as any; + + (ctx.response as ServerResponse).end = function ( + this: ServerResponse, + chunk?: any, + ...args: any[] + ) { + if (chunk) { + chunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ); + } + return originalEnd(chunk, ...args) as ServerResponse; + } as any; + + await next(); + + // Store the response for future replays + if (capturedStatus >= 200 && capturedStatus < 500) { + const body = Buffer.concat(chunks); + store.set(key, { + status: capturedStatus, + headers: capturedHeaders, + body, + expiresAt: Date.now() + ttl + }); + } + }; +} diff --git a/libs/server/src/middlewares/ResponseCache.ts b/libs/server/src/middlewares/ResponseCache.ts new file mode 100644 index 00000000..1e07f915 --- /dev/null +++ b/libs/server/src/middlewares/ResponseCache.ts @@ -0,0 +1,264 @@ +/** + * Server-side cache response middleware. + * + * Caches successful handler responses keyed by endpoint-defined cache tags. + * On cache hit, the response is served directly — the handler never runs. + * Mutating requests invalidate matching cache entries after the handler + * completes successfully. + * + * @module + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { RequestContext } from '../RequestContext.js'; +import type { Middleware } from '../types.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Configuration for {@link cacheResponse}. + */ +export interface ServerCacheOptions { + /** + * Default TTL in milliseconds for tags without an explicit TTL. + * Defaults to `60000` (60 seconds). + */ + defaultTtl?: number; + + /** + * Per-tag TTL overrides: `{ [tagName]: ttlMs }`. + */ + ttlByTag?: Record; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +interface CacheEntry { + status: number; + headers: Record; + body: Buffer; + expiresAt: number; +} + +function isMutating(method: string): boolean { + return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase()); +} + +function computeKey( + tags: ReadonlyArray<{ + name: string; + properties: Readonly< + Record< + string, + { + getValue(root: any): { + value?: unknown; + success: boolean; + }; + } + > + >; + }>, + root: any +): string[] { + return tags.map(tag => { + const parts: string[] = []; + for (const [key, accessor] of Object.entries(tag.properties).sort( + ([a], [b]) => a.localeCompare(b) + )) { + const result = accessor.getValue(root); + if (result.success && result.value !== undefined) { + parts.push(`${key}=${String(result.value)}`); + } + } + return parts.length > 0 ? `${tag.name}:${parts.join(',')}` : tag.name; + }); +} + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- + +/** + * Server-side cache response middleware. + * + * Uses cache-tag definitions from the matched endpoint (already available + * on `ctx.items.__endpoint_meta.cacheTags`) to compute deterministic cache + * keys from request data (params, query, body, headers). + * + * - **GET**: Computes cache key → serves cached response if valid → + * handler never executes. On cache miss, runs the handler and caches + * the response. + * - **Mutation (POST/PUT/PATCH/DELETE)**: Lets the handler run, then + * invalidates all cache entries whose key starts with any of the + * endpoint's cache tag names. + * + * @param options - Cache configuration. + * @returns A server-side {@link Middleware}. + * + * @example + * ```ts + * server.handle(ListTodos, listHandler, { + * middlewares: [cacheResponse({ defaultTtl: 30_000 })] + * }); + * ``` + */ +export function cacheResponse(options: ServerCacheOptions = {}): Middleware { + const { ttlByTag = {}, defaultTtl = 60_000 } = options; + + const cache = new Map(); + + return async (ctx: RequestContext, next: () => Promise) => { + const meta = ctx.items.get('__endpoint_meta') as any; + const tags: ReadonlyArray<{ + name: string; + properties: Record< + string, + { getValue(root: any): { value?: unknown; success: boolean } } + >; + }> = meta?.cacheTags ?? []; + + if (tags.length === 0) { + return next(); + } + + if (isMutating(ctx.method)) { + // Run handler first (so cache is invalidated only on success) + await next(); + + if ( + (ctx.response as ServerResponse).statusCode >= 200 && + (ctx.response as ServerResponse).statusCode < 300 + ) { + // Build root for key computation + const rawBody = ctx.items.get('__raw_body') as + | unknown + | undefined; + const root = { + params: ctx.pathParams ?? {}, + body: rawBody, + query: ctx.queryParams ?? {}, + headers: ctx.headers ?? {} + }; + + const keys = computeKey(tags, root); + for (const tag of tags) { + for (const [cachedKey] of cache) { + if ( + keys.includes(cachedKey) || + keys.some(k => cachedKey.startsWith(tag.name)) + ) { + cache.delete(cachedKey); + } + } + // Also delete by pure tag name prefix + for (const [cachedKey] of cache) { + if (cachedKey.startsWith(tag.name)) { + cache.delete(cachedKey); + } + } + } + } + return; + } + + if (ctx.method === 'GET') { + // Build root for key computation + const root = { + params: ctx.pathParams ?? {}, + body: undefined, + query: ctx.queryParams ?? {}, + headers: ctx.headers ?? {} + }; + + const keys = computeKey(tags, root); + + // Check all keys — first valid cache hit wins + for (const key of keys) { + const entry = cache.get(key); + if (entry && entry.expiresAt > Date.now()) { + // Serve from cache + const res = ctx.response as ServerResponse; + res.writeHead(entry.status, entry.headers); + res.end(entry.body); + ctx.responded = true; + return; + } + if (entry) { + cache.delete(key); + } + } + + // Cache miss — run handler, then capture the response + const originalWriteHead = ( + ctx.response as ServerResponse + ).writeHead.bind(ctx.response); + const originalEnd = (ctx.response as ServerResponse).end.bind( + ctx.response + ); + + let capturedStatus = 200; + let capturedHeaders: Record = + {}; + const chunks: Buffer[] = []; + + (ctx.response as ServerResponse).writeHead = function ( + this: ServerResponse, + statusCode: number, + ...args: any[] + ) { + capturedStatus = statusCode; + if (args.length > 0) { + capturedHeaders = args[0]; + } + return originalWriteHead(statusCode, ...args) as ServerResponse; + } as any; + + (ctx.response as ServerResponse).end = function ( + this: ServerResponse, + chunk?: any, + ...args: any[] + ) { + if (chunk) { + chunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ); + } + return originalEnd(chunk, ...args) as ServerResponse; + } as any; + + await next(); + + // Store in cache on success + if (capturedStatus >= 200 && capturedStatus < 300) { + const body = Buffer.concat(chunks); + const ttl = tags.reduce((max, tag) => { + const t = + ttlByTag[tag.name] !== undefined + ? ttlByTag[tag.name] + : defaultTtl; + return t > max ? t : max; + }, 0); + + if (ttl > 0) { + for (const key of keys) { + cache.set(key, { + status: capturedStatus, + headers: capturedHeaders, + body, + expiresAt: Date.now() + ttl + }); + } + } + } + return; + } + + // Non-GET, non-mutation — pass through + return next(); + }; +} diff --git a/libs/server/tests/CacheTag.test.ts b/libs/server/tests/CacheTag.test.ts new file mode 100644 index 00000000..edf3fc1f --- /dev/null +++ b/libs/server/tests/CacheTag.test.ts @@ -0,0 +1,331 @@ +import { + number, + object, + parseString, + SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR, + string +} from '@cleverbrush/schema'; +import { describe, expect, it } from 'vitest'; +import type { CacheTagPropertyAccessor } from '../src/CacheTag.js'; +import { + computeCacheKey, + createCacheTagTree, + serializeTag +} from '../src/CacheTag.js'; + +// --------------------------------------------------------------------------- +// createCacheTagTree +// --------------------------------------------------------------------------- + +describe('createCacheTagTree', () => { + it('returns a tree with params from a ParseStringSchemaBuilder', () => { + const ps = parseString( + object({ id: number() }), + $t => $t`/todos/${t => t.id}` + ); + const tree = createCacheTagTree({ paramsSchema: ps }); + + const desc = tree.params?.id; + expect(desc).toBeDefined(); + const inner = (desc as any)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; + const result = inner.getValue({ params: { id: 42 } }); + expect(result.success).toBe(true); + expect(result.value).toBe(42); + }); + + it('returns a tree with body', () => { + const bodySchema = object({ title: string() }); + const tree = createCacheTagTree({ bodySchema }); + + const desc = tree.body?.title; + expect(desc).toBeDefined(); + const inner = (desc as any)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; + const result = inner.getValue({ body: { title: 'hello' } }); + expect(result.success).toBe(true); + expect(result.value).toBe('hello'); + }); + + it('returns a tree with query', () => { + const querySchema = object({ page: number() }); + const tree = createCacheTagTree({ querySchema }); + + const desc = tree.query?.page; + expect(desc).toBeDefined(); + const inner = (desc as any)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; + const result = inner.getValue({ query: { page: 3 } }); + expect(result.success).toBe(true); + expect(result.value).toBe(3); + }); + + it('returns a tree with headers', () => { + const headerSchema = object({ 'x-tenant': string() }); + const tree = createCacheTagTree({ headerSchema }); + + const desc = tree.headers?.['x-tenant']; + expect(desc).toBeDefined(); + const inner = (desc as any)[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; + const result = inner.getValue({ + headers: { 'x-tenant': 'acme' } + }); + expect(result.success).toBe(true); + expect(result.value).toBe('acme'); + }); + + it('omits null schemas', () => { + const querySchema = object({ search: string() }); + const tree = createCacheTagTree({ querySchema }); + + expect(tree.params).toBeUndefined(); + expect(tree.body).toBeUndefined(); + expect(tree.headers).toBeUndefined(); + expect(tree.query?.search).toBeDefined(); + }); + + it('works with all schemas populated', () => { + const ps = parseString( + object({ id: string() }), + $t => $t`/todos/${t => t.id}` + ); + const bodySchema = object({ title: string() }); + const querySchema = object({ filter: string() }); + const headerSchema = object({ 'x-api-key': string() }); + + const tree = createCacheTagTree({ + paramsSchema: ps, + bodySchema, + querySchema, + headerSchema + }); + + expect(tree.params?.id).toBeDefined(); + expect(tree.body?.title).toBeDefined(); + expect(tree.query?.filter).toBeDefined(); + expect(tree.headers?.['x-api-key']).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// serializeTag +// --------------------------------------------------------------------------- + +describe('serializeTag', () => { + it('serializes a tag with named property accessors', () => { + const bodySchema = object({ + id: number(), + name: string() + }); + const tree = createCacheTagTree({ bodySchema }); + + const definition = serializeTag('todo', { + id: tree.body?.id, + fromName: tree.body?.name + }); + + expect(definition.name).toBe('todo'); + expect(Object.keys(definition.properties).sort()).toEqual([ + 'fromName', + 'id' + ]); + + // Accessor for id should extract body.id + const idResult = definition.properties['id'].getValue({ + params: {}, + body: { id: 123, name: 'test' }, + query: {}, + headers: {} + }); + expect(idResult.success).toBe(true); + expect(idResult.value).toBe(123); + + // Accessor for fromName should extract body.name + const nameResult = definition.properties['fromName'].getValue({ + params: {}, + body: { id: 123, name: 'test' }, + query: {}, + headers: {} + }); + expect(nameResult.success).toBe(true); + expect(nameResult.value).toBe('test'); + }); + + it('returns a tag with empty properties when no descriptors given', () => { + const definition = serializeTag('simple', {}); + expect(definition.name).toBe('simple'); + expect(definition.properties).toEqual({}); + }); + + it('throws when a value is not a valid property descriptor', () => { + expect(() => + serializeTag('bad', { + notADescriptor: 'hello' + }) + ).toThrow(/not a valid.*PropertyDescriptor/); + }); + + it('throws when a value is null', () => { + expect(() => + serializeTag('bad', { + missing: null + }) + ).toThrow(/not a valid.*PropertyDescriptor/); + }); + + it('throws when a value is an object without the descriptor symbol', () => { + expect(() => + serializeTag('bad', { + plain: { foo: 'bar' } + }) + ).toThrow(/not a valid.*PropertyDescriptor/); + }); +}); + +// --------------------------------------------------------------------------- +// computeCacheKey +// --------------------------------------------------------------------------- + +describe('computeCacheKey', () => { + function makeTag( + name: string, + accessors: Record + ) { + return { name, properties: accessors }; + } + + function makeConstAccessor(value: unknown): CacheTagPropertyAccessor { + return { + getValue: () => ({ success: true, value }) + }; + } + + function makeFailingAccessor(): CacheTagPropertyAccessor { + return { + getValue: () => ({ success: false }) + }; + } + + const emptyRoot = { + params: {}, + body: undefined, + query: {}, + headers: {} + }; + + it('returns tag name for simple tags with no properties', () => { + const tag = makeTag('invalidate-all', {}); + expect(computeCacheKey(tag, emptyRoot)).toBe('invalidate-all'); + }); + + it('builds key with single property', () => { + const tag = makeTag('todo', { + id: makeConstAccessor(42) + }); + expect(computeCacheKey(tag, emptyRoot)).toBe('todo:id=42'); + }); + + it('builds key with multiple properties sorted alphabetically', () => { + const tag = makeTag('todo', { + z: makeConstAccessor('last'), + a: makeConstAccessor('first'), + m: makeConstAccessor('middle') + }); + expect(computeCacheKey(tag, emptyRoot)).toBe( + 'todo:a=first,m=middle,z=last' + ); + }); + + it('skips properties where getValue returns success: false', () => { + const tag = makeTag('todo', { + id: makeConstAccessor(42), + optional: makeFailingAccessor() + }); + expect(computeCacheKey(tag, emptyRoot)).toBe('todo:id=42'); + }); + + it('returns tag name when all properties fail', () => { + const tag = makeTag('todo', { + a: makeFailingAccessor(), + b: makeFailingAccessor() + }); + expect(computeCacheKey(tag, emptyRoot)).toBe('todo'); + }); + + it('skips properties with undefined value', () => { + const tag = makeTag('todo', { + id: makeConstAccessor(undefined) + }); + expect(computeCacheKey(tag, emptyRoot)).toBe('todo'); + }); + + it('produces stable output for the same inputs', () => { + const tag = makeTag('todo', { + id: makeConstAccessor(42), + name: makeConstAccessor('test') + }); + const k1 = computeCacheKey(tag, emptyRoot); + const k2 = computeCacheKey(tag, emptyRoot); + expect(k1).toBe(k2); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: createCacheTagTree + serializeTag + computeCacheKey +// --------------------------------------------------------------------------- + +describe('integration', () => { + it('end-to-end: descriptor tree → serialize → compute key', () => { + const bodySchema = object({ + orgId: number(), + userId: string() + }); + const querySchema = object({ filter: string() }); + + const tree = createCacheTagTree({ bodySchema, querySchema }); + + const definition = serializeTag('resource', { + orgId: tree.body?.orgId, + userId: tree.body?.userId, + filter: tree.query?.filter + }); + + const root = { + params: {}, + body: { orgId: 10, userId: 'u1' }, + query: { filter: 'active' }, + headers: {} + }; + + const key = computeCacheKey(definition, root); + // Sorted: filter, orgId, userId + expect(key).toBe('resource:filter=active,orgId=10,userId=u1'); + }); + + it('end-to-end with params and headers', () => { + const ps = parseString( + object({ orgId: number(), projectId: string() }), + $t => $t`/orgs/${t => t.orgId}/projects/${t => t.projectId}` + ); + const headerSchema = object({ 'x-tenant': string() }); + + const tree = createCacheTagTree({ + paramsSchema: ps, + headerSchema + }); + + const definition = serializeTag('project', { + orgId: tree.params?.orgId, + projectId: tree.params?.projectId, + tenant: tree.headers?.['x-tenant'] + }); + + const root = { + params: { orgId: 42, projectId: 'p1' }, + body: undefined, + query: {}, + headers: { 'x-tenant': 'acme' } + }; + + const key = computeCacheKey(definition, root); + // Sorted: orgId, projectId, tenant + expect(key).toBe('project:orgId=42,projectId=p1,tenant=acme'); + }); +}); diff --git a/libs/server/tests/Endpoint.test.ts b/libs/server/tests/Endpoint.test.ts index cd4ecbbe..1c988b05 100644 --- a/libs/server/tests/Endpoint.test.ts +++ b/libs/server/tests/Endpoint.test.ts @@ -612,3 +612,165 @@ describe('endpoint HTTP method factories', () => { expect(ep.introspect().method).toBe('OPTIONS'); }); }); + +// --------------------------------------------------------------------------- +// .clearsCacheTag() +// --------------------------------------------------------------------------- + +describe('EndpointBuilder clearsCacheTag / cacheTag', () => { + it('simple tag stores name with empty properties in introspect', () => { + const ep = endpoint.get('/api/items').clearsCacheTag('tag-a'); + + const tags = ep.introspect().cacheTags; + expect(tags).toHaveLength(1); + expect(tags[0].name).toBe('tag-a'); + expect(tags[0].properties).toEqual({}); + }); + + it('property tag stores name and property accessors', () => { + const querySchema = object({ filter: string(), page: number() }); + const ep = endpoint + .get('/api/items') + .query(querySchema) + .clearsCacheTag('tag-b', p => ({ + filter: p.query.filter, + page: p.query.page + })); + + const tags = ep.introspect().cacheTags; + expect(tags).toHaveLength(1); + expect(tags[0].name).toBe('tag-b'); + expect(Object.keys(tags[0].properties).sort()).toEqual([ + 'filter', + 'page' + ]); + }); + + it('multiple tags accumulate in order', () => { + const ep = endpoint + .get('/api/items') + .clearsCacheTag('first') + .clearsCacheTag('second'); + + const tags = ep.introspect().cacheTags; + expect(tags).toHaveLength(2); + expect(tags[0].name).toBe('first'); + expect(tags[1].name).toBe('second'); + }); + + it('cacheTag returns a new builder (immutable)', () => { + const a = endpoint.get('/api/items'); + const b = a.clearsCacheTag('test'); + + expect(a).not.toBe(b); + expect(a.introspect().cacheTags).toEqual([]); + expect(b.introspect().cacheTags).toHaveLength(1); + expect(b.introspect().cacheTags[0].name).toBe('test'); + }); + + it('selector with invalid value throws', () => { + const ep = endpoint.get('/api/items'); + + expect(() => + ep.clearsCacheTag('bad', () => ({ + notADescriptor: 'hello' as any + })) + ).toThrow(/not a valid.*PropertyDescriptor/); + }); + + it('selector returning null throws', () => { + const ep = endpoint.get('/api/items'); + + expect(() => + ep.clearsCacheTag('bad', () => ({ + missing: null as any + })) + ).toThrow(/not a valid.*PropertyDescriptor/); + }); + + it('combines simple and property tags', () => { + const querySchema = object({ search: string() }); + const ep = endpoint + .get('/api/items') + .query(querySchema) + .clearsCacheTag('list') + .clearsCacheTag('item', p => ({ search: p.query.search })); + + const tags = ep.introspect().cacheTags; + expect(tags).toHaveLength(2); + expect(tags[0].name).toBe('list'); + expect(tags[0].properties).toEqual({}); + expect(tags[1].name).toBe('item'); + expect(Object.keys(tags[1].properties)).toEqual(['search']); + }); + + it('property tag with body schema', () => { + const bodySchema = object({ title: string() }); + const ep = endpoint + .post('/api/items') + .body(bodySchema) + .clearsCacheTag('item', p => ({ title: p.body.title })); + + const tags = ep.introspect().cacheTags; + expect(tags).toHaveLength(1); + expect(tags[0].name).toBe('item'); + expect(Object.keys(tags[0].properties)).toEqual(['title']); + }); + + it('property tag with params from ParseStringSchemaBuilder', () => { + const ps = parseString( + object({ id: number() }), + $t => $t`/items/${t => t.id}` + ); + const ep = endpoint + .get('/api/items', ps as any) + .clearsCacheTag('item', p => ({ id: p.params.id })); + + const tags = ep.introspect().cacheTags; + expect(tags).toHaveLength(1); + expect(tags[0].name).toBe('item'); + expect(Object.keys(tags[0].properties)).toEqual(['id']); + }); + + it('property accessors actually resolve values', () => { + const querySchema = object({ filter: string() }); + const ep = endpoint + .get('/api/items') + .query(querySchema) + .clearsCacheTag('item', p => ({ filter: p.query.filter })); + + const tags = ep.introspect().cacheTags; + const accessor = tags[0].properties['filter']; + const result = accessor.getValue({ + params: {}, + body: undefined, + query: { filter: 'active' }, + headers: {} + }); + expect(result.success).toBe(true); + expect(result.value).toBe('active'); + }); + + it('cacheTag and clearsCacheTag produce identical results', () => { + const querySchema = object({ x: string() }); + const ep1 = endpoint + .get('/api/items') + .query(querySchema) + .cacheTag('group', p => ({ x: p.query.x })); + + const ep2 = endpoint + .get('/api/items') + .query(querySchema) + .clearsCacheTag('group', p => ({ x: p.query.x })); + + const tags1 = ep1.introspect().cacheTags; + const tags2 = ep2.introspect().cacheTags; + + expect(tags1).toHaveLength(1); + expect(tags2).toHaveLength(1); + expect(tags1[0].name).toBe(tags2[0].name); + expect(Object.keys(tags1[0].properties)).toEqual( + Object.keys(tags2[0].properties) + ); + }); +}); diff --git a/libs/server/tests/Idempotency.test.ts b/libs/server/tests/Idempotency.test.ts new file mode 100644 index 00000000..184dc1de --- /dev/null +++ b/libs/server/tests/Idempotency.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { idempotency } from '../src/middlewares/Idempotency.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContext( + headers: Record = {}, + method = 'POST' +): any { + const response: any = { + statusCode: 200, + _headers: {} as Record, + _body: null as any, + writeHead(statusCode: number, hdrs?: any) { + this.statusCode = statusCode; + if (hdrs) this._headers = hdrs; + return this; + }, + end(chunk?: any) { + this._body = chunk; + return this; + } + }; + + return { + method, + headers, + response, + responded: false, + pathParams: {}, + queryParams: {}, + items: new Map() + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('idempotency middleware', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('passes through GET requests', async () => { + const mw = idempotency({ ttl: 5000 }); + const ctx = makeContext({ 'x-idempotency-key': 'key-1' }, 'GET'); + const next = vi.fn().mockResolvedValue(undefined); + await mw(ctx, next); + expect(next).toHaveBeenCalledOnce(); + }); + + test('passes through mutations without idempotency key', async () => { + const mw = idempotency({ ttl: 5000 }); + const ctx = makeContext({}, 'POST'); + const next = vi.fn().mockResolvedValue(undefined); + await mw(ctx, next); + expect(next).toHaveBeenCalledOnce(); + }); + + test('passes through on first request with key', async () => { + const mw = idempotency({ ttl: 5000 }); + const ctx = makeContext({ 'x-idempotency-key': 'key-1' }, 'POST'); + let handlerCalled = false; + await mw(ctx, async () => { + handlerCalled = true; + ctx.response.writeHead(201); + ctx.response.end('created'); + }); + expect(handlerCalled).toBe(true); + }); + + test('returns stored response on duplicate key', async () => { + const mw = idempotency({ ttl: 10_000 }); + + // First request + const ctx1 = makeContext({ 'x-idempotency-key': 'key-dup' }, 'POST'); + await mw(ctx1, async () => { + ctx1.response.writeHead(201, { + 'content-type': 'application/json' + }); + ctx1.response.end(JSON.stringify({ id: 1 })); + }); + + // Duplicate with same key + const ctx2 = makeContext({ 'x-idempotency-key': 'key-dup' }, 'POST'); + let handlerCalled = false; + await mw(ctx2, async () => { + handlerCalled = true; + }); + + expect(handlerCalled).toBe(false); + expect(ctx2.responded).toBe(true); + }); + + test('different keys store independently', async () => { + const mw = idempotency({ ttl: 10_000 }); + + const ctx1 = makeContext({ 'x-idempotency-key': 'key-a' }, 'POST'); + await mw(ctx1, async () => { + ctx1.response.writeHead(200); + ctx1.response.end('result-a'); + }); + + const ctx2 = makeContext({ 'x-idempotency-key': 'key-b' }, 'POST'); + let handlerCalled = false; + await mw(ctx2, async () => { + handlerCalled = true; + }); + + expect(handlerCalled).toBe(true); + }); + + test('expired key calls handler again', async () => { + const mw = idempotency({ ttl: 1000 }); + + const ctx1 = makeContext({ 'x-idempotency-key': 'key-exp' }, 'POST'); + await mw(ctx1, async () => { + ctx1.response.writeHead(200); + ctx1.response.end('first'); + }); + + // Advance past TTL + vi.advanceTimersByTime(1001); + + const ctx2 = makeContext({ 'x-idempotency-key': 'key-exp' }, 'POST'); + let handlerCalled = false; + await mw(ctx2, async () => { + handlerCalled = true; + }); + + expect(handlerCalled).toBe(true); + }); + + test('handles case-insensitive header name', async () => { + const mw = idempotency({ + ttl: 10_000, + headerName: 'X-Idempotency-Key' + }); + + const ctx1 = makeContext({ 'x-idempotency-key': 'key-ci' }, 'POST'); + await mw(ctx1, async () => { + ctx1.response.writeHead(200); + ctx1.response.end('ok'); + }); + + const ctx2 = makeContext({ 'x-idempotency-key': 'key-ci' }, 'POST'); + let handlerCalled = false; + await mw(ctx2, async () => { + handlerCalled = true; + }); + + expect(handlerCalled).toBe(false); + }); + + test('stores error responses too (non-2xx under 500)', async () => { + const mw = idempotency({ ttl: 10_000 }); + + const ctx1 = makeContext({ 'x-idempotency-key': 'key-err' }, 'POST'); + await mw(ctx1, async () => { + ctx1.response.writeHead(422); + ctx1.response.end('validation error'); + }); + + const ctx2 = makeContext({ 'x-idempotency-key': 'key-err' }, 'POST'); + let handlerCalled = false; + await mw(ctx2, async () => { + handlerCalled = true; + }); + + expect(handlerCalled).toBe(false); + }); + + test('custom skip predicate', async () => { + const mw = idempotency({ + ttl: 10_000, + skip: ctx => ctx.method === 'DELETE' + }); + + const ctx = makeContext({ 'x-idempotency-key': 'key-skip' }, 'DELETE'); + const next = vi.fn().mockResolvedValue(undefined); + await mw(ctx, next); + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/libs/server/tests/ResponseCache.test.ts b/libs/server/tests/ResponseCache.test.ts new file mode 100644 index 00000000..096efe4b --- /dev/null +++ b/libs/server/tests/ResponseCache.test.ts @@ -0,0 +1,279 @@ +import { object, string } from '@cleverbrush/schema'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cacheResponse } from '../src/middlewares/ResponseCache.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConstAccessor(value: unknown) { + return { + getValue: () => ({ success: true, value }) + }; +} + +function makeContext( + meta: { cacheTags?: any } = {}, + overrides: Partial<{ + method: string; + }> = {} +): any { + const items = new Map(); + items.set('__endpoint_meta', meta); + + const response: any = { + statusCode: 200, + _headers: {} as Record, + _body: null as any, + writeHead(statusCode: number, headers?: any) { + this.statusCode = statusCode; + if (headers) this._headers = headers; + return this; + }, + end(chunk?: any) { + this._body = chunk; + return this; + } + }; + + return { + method: overrides.method ?? 'GET', + pathParams: {}, + queryParams: {}, + headers: {}, + items, + response, + responded: false + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('cacheResponse middleware', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('passes through when endpoint has no cache tags', async () => { + const mw = cacheResponse({ defaultTtl: 5000 }); + const ctx = makeContext({ cacheTags: [] }); + const next = vi.fn().mockResolvedValue(undefined); + await mw(ctx, next); + expect(next).toHaveBeenCalledOnce(); + }); + + test('serves cached response on second GET', async () => { + const mw = cacheResponse({ defaultTtl: 5000 }); + const meta = { + cacheTags: [ + { name: 'todo', properties: { id: makeConstAccessor(42) } } + ] + }; + + const ctx1 = makeContext(meta); + let handlerCalled1 = false; + await mw(ctx1, async () => { + handlerCalled1 = true; + ctx1.response.writeHead(200, { + 'content-type': 'application/json' + }); + ctx1.response.end(JSON.stringify({ name: 'test' })); + }); + expect(handlerCalled1).toBe(true); + + const ctx2 = makeContext(meta); + let handlerCalled2 = false; + await mw(ctx2, async () => { + handlerCalled2 = true; + }); + expect(handlerCalled2).toBe(false); + }); + + test('does not serve cached response after TTL expiry', async () => { + const mw = cacheResponse({ defaultTtl: 5000 }); + const meta = { + cacheTags: [ + { name: 'todo', properties: { id: makeConstAccessor(42) } } + ] + }; + + const ctx1 = makeContext(meta); + await mw(ctx1, async () => { + ctx1.response.writeHead(200); + ctx1.response.end('ok'); + }); + + vi.advanceTimersByTime(5001); + + const ctx2 = makeContext(meta); + let handlerCalled2 = false; + await mw(ctx2, async () => { + handlerCalled2 = true; + }); + expect(handlerCalled2).toBe(true); + }); + + test('different property values produce different cache keys', async () => { + const mw = cacheResponse({ defaultTtl: 5000 }); + + const ctx1 = makeContext({ + cacheTags: [ + { name: 'todo', properties: { id: makeConstAccessor(1) } } + ] + }); + let handlerCalled1 = false; + await mw(ctx1, async () => { + handlerCalled1 = true; + ctx1.response.writeHead(200); + ctx1.response.end('value-1'); + }); + expect(handlerCalled1).toBe(true); + + const ctx2 = makeContext({ + cacheTags: [ + { name: 'todo', properties: { id: makeConstAccessor(2) } } + ] + }); + let handlerCalled2 = false; + await mw(ctx2, async () => { + handlerCalled2 = true; + }); + expect(handlerCalled2).toBe(true); + }); + + test('simple tags (no properties) cache key is tag name', async () => { + const mw = cacheResponse({ defaultTtl: 5000 }); + const meta = { + cacheTags: [{ name: 'global', properties: {} }] + }; + + const ctx1 = makeContext(meta); + let handlerCalled1 = false; + await mw(ctx1, async () => { + handlerCalled1 = true; + ctx1.response.writeHead(200); + ctx1.response.end('ok'); + }); + expect(handlerCalled1).toBe(true); + + const ctx2 = makeContext(meta); + let handlerCalled2 = false; + await mw(ctx2, async () => { + handlerCalled2 = true; + }); + expect(handlerCalled2).toBe(false); + }); + + test('mutation invalidates cache entries by tag name prefix', async () => { + const mw = cacheResponse({ defaultTtl: 10_000 }); + const getMeta = { + cacheTags: [{ name: 'todo-list', properties: {} }] + }; + const mutMeta = { + cacheTags: [{ name: 'todo-list', properties: {} }] + }; + + // Populate cache with GET + const ctxGet = makeContext(getMeta); + await mw(ctxGet, async () => { + ctxGet.response.writeHead(200); + ctxGet.response.end('list'); + }); + + // Mutation + const ctxMut = makeContext(mutMeta, { method: 'POST' }); + await mw(ctxMut, async () => { + ctxMut.response.writeHead(201); + ctxMut.response.end('created'); + }); + + // GET after mutation — should miss cache + const ctxGetAfter = makeContext(getMeta); + let handlerCalled = false; + await mw(ctxGetAfter, async () => { + handlerCalled = true; + }); + expect(handlerCalled).toBe(true); + }); + + test('mutation does not invalidate on non-2xx status', async () => { + const mw = cacheResponse({ defaultTtl: 10_000 }); + const meta = { + cacheTags: [{ name: 'todo-list', properties: {} }] + }; + + // Populate cache with GET + const ctxGet = makeContext(meta); + await mw(ctxGet, async () => { + ctxGet.response.writeHead(200); + ctxGet.response.end('list'); + }); + + // Failed mutation (404) + const ctxMut = makeContext(meta, { method: 'DELETE' }); + ctxMut.response.statusCode = 404; + await mw(ctxMut, async () => { + // Handler sets 404 — no writeHead needed + }); + + // GET after failed mutation — should hit cache + const ctxGetAfter = makeContext(meta); + let handlerCalled = false; + await mw(ctxGetAfter, async () => { + handlerCalled = true; + }); + expect(handlerCalled).toBe(false); + }); + + test('uses per-tag TTL when configured', async () => { + const mw = cacheResponse({ + defaultTtl: 5000, + ttlByTag: { 'fast-tag': 1000 } + }); + const meta = { + cacheTags: [{ name: 'fast-tag', properties: {} }] + }; + + // Populate cache + const ctx1 = makeContext(meta); + await mw(ctx1, async () => { + ctx1.response.writeHead(200); + ctx1.response.end('ok'); + }); + + // Within per-tag TTL — should hit cache + vi.advanceTimersByTime(500); + const ctx2 = makeContext(meta); + let handlerCalled2 = false; + await mw(ctx2, async () => { + handlerCalled2 = true; + }); + expect(handlerCalled2).toBe(false); + + // Past per-tag TTL but within default — should miss cache + vi.advanceTimersByTime(501); + const ctx3 = makeContext(meta); + let handlerCalled3 = false; + await mw(ctx3, async () => { + handlerCalled3 = true; + }); + expect(handlerCalled3).toBe(true); + }); + + test('non-GET non-mutation passes through', async () => { + const mw = cacheResponse({ defaultTtl: 5000 }); + const meta = { + cacheTags: [{ name: 'tag', properties: {} }] + }; + const ctx = makeContext(meta, { method: 'OPTIONS' }); + const next = vi.fn().mockResolvedValue(undefined); + await mw(ctx, next); + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/websites/docs/app/client/[[...slug]]/page.tsx b/websites/docs/app/client/[[...slug]]/page.tsx index 77c72ebf..9e3e48ad 100644 --- a/websites/docs/app/client/[[...slug]]/page.tsx +++ b/websites/docs/app/client/[[...slug]]/page.tsx @@ -1,10 +1,12 @@ import { redirect } from 'next/navigation'; import BatchingSection from '../sections/batching'; import CacheSection from '../sections/cache'; +import CacheTagsSection from '../sections/cacheTags'; import DedupeSection from '../sections/dedupe'; import ErrorHandlingSection from '../sections/error-handling'; import GettingStartedSection from '../sections/getting-started'; import HooksSection from '../sections/hooks'; +import IdempotencySection from '../sections/idempotency'; import { CLIENT_SECTIONS } from '../sections/index'; import MiddlewareSection from '../sections/middleware'; import PerCallOverridesSection from '../sections/per-call-overrides'; @@ -25,7 +27,9 @@ const SECTION_COMPONENTS: Record = { retry: RetrySection, timeout: TimeoutSection, dedupe: DedupeSection, + idempotency: IdempotencySection, cache: CacheSection, + 'cache-tags': CacheTagsSection, batching: BatchingSection, 'error-handling': ErrorHandlingSection, 'per-call-overrides': PerCallOverridesSection, diff --git a/websites/docs/app/client/sections/cacheTags.tsx b/websites/docs/app/client/sections/cacheTags.tsx new file mode 100644 index 00000000..846e81a6 --- /dev/null +++ b/websites/docs/app/client/sections/cacheTags.tsx @@ -0,0 +1,157 @@ +/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: it is intentional */ +import { highlightTS } from '@cleverbrush/website-shared/lib/highlight'; + +export default function CacheTagsSection() { + return ( + <> +
+

Cache-Tag Middleware

+

+ Tag-based HTTP caching with automatic invalidation driven by + endpoint annotations +

+
+ +
+

Basic Usage

+
+                    
+                
+
+ +
+

Server Integration

+

+ Cache tags are declared on the server-side endpoint + definition via .clearsCacheTag(). Tags flow + through the contract metadata to the client automatically. +

+
+                     ({
+        page: p.query.page,
+        limit: p.query.limit
+    }))
+    .returns(array(TodoSchema));
+
+const UpdateTodo = todosResource
+    .patch(ById)
+    .body(UpdateTodoBodySchema)
+    .clearsCacheTag('todo-list')            // invalidates list
+    .clearsCacheTag('todo', p => ({        // invalidates entity
+        id: p.params.id
+    }))
+    .returns(TodoSchema);
+`)
+                        }}
+                    />
+                
+
+ +
+

How It Works

+
    +
  • + On GET: Computes cache key from the + endpoint's cache tag names and property selectors. + Serves cached response if within TTL. +
  • +
  • + On mutation (POST/PUT/PATCH/DELETE):{' '} + Invalidates all entries whose key starts with any of the + endpoint's tag names — no manual callbacks needed. +
  • +
  • + Property-based keys: Tags with + properties differentiate cache entries by request + params, query, body, or headers (e.g. different pages + get different cache keys). +
  • +
  • + TanStack Query bridge: When used with{' '} + @cleverbrush/client/react,{' '} + useMutation hooks automatically invalidate + TanStack Query cache for the affected group. +
  • +
+
+ +
+

Options

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefault
+ defaultTtl + + number + + 0 +
+ ttlByTag + + + {'{'} [tagName: string]: number {'}'} + + + {'{ }'} +
+ condition + + (response) => boolean + + response.ok +
+
+

+ Set defaultTtl: 0 for invalidation-only mode: + GET responses are not cached, but mutations still invalidate + entries created by other endpoints. +

+
+ + ); +} diff --git a/websites/docs/app/client/sections/idempotency.tsx b/websites/docs/app/client/sections/idempotency.tsx new file mode 100644 index 00000000..b99987f4 --- /dev/null +++ b/websites/docs/app/client/sections/idempotency.tsx @@ -0,0 +1,181 @@ +/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: it is intentional */ +import { highlightTS } from '@cleverbrush/website-shared/lib/highlight'; + +export default function IdempotencySection() { + return ( + <> +
+

Idempotency Middleware

+

+ Deduplicate replays of mutating requests via idempotency + keys +

+
+ +
+

Basic Usage

+
+                    
+                
+
+ +
+

Server Integration

+

+ The server-side idempotency() middleware reads + the header, stores the response, and replays it for + duplicate keys. +

+
+                    
+                
+
+ +
+

How It Works

+
    +
  • + On mutation: Client auto-generates a + UUID v4 as X-Idempotency-Key header. +
  • +
  • + On server: First request with a key + runs the handler and stores the response. Replays return + the stored response immediately. +
  • +
  • + On retry: The key is preserved — + retried requests are treated as replays, not new + operations. +
  • +
+
+ +
+

Options (Client)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefault
+ headerName + + string + + "X-Idempotency-Key" +
+ keyGenerator + + (url, init) => string + + uuid v4 +
+ condition + + (url, init) => boolean + + mutations only +
+
+
+ +
+

Options (Server)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefault
+ ttl + + number + + 86400000 (24h) +
+ headerName + + string + + "x-idempotency-key" +
+ skip + + (ctx) => boolean + + non-mutating requests +
+
+
+ + ); +} diff --git a/websites/docs/app/client/sections/index.ts b/websites/docs/app/client/sections/index.ts index c1023adc..68a8d423 100644 --- a/websites/docs/app/client/sections/index.ts +++ b/websites/docs/app/client/sections/index.ts @@ -15,7 +15,15 @@ export const SECTION_GROUPS = [ }, { label: 'Resilience', - slugs: ['retry', 'timeout', 'dedupe', 'cache', 'batching'] + slugs: [ + 'retry', + 'timeout', + 'dedupe', + 'idempotency', + 'cache', + 'cache-tags', + 'batching' + ] }, { label: 'Advanced', @@ -34,7 +42,17 @@ export const CLIENT_SECTIONS: ClientSection[] = [ { slug: 'retry', title: 'Retry', group: 'Resilience' }, { slug: 'timeout', title: 'Timeout', group: 'Resilience' }, { slug: 'dedupe', title: 'Deduplication', group: 'Resilience' }, + { + slug: 'idempotency', + title: 'Idempotency', + group: 'Resilience' + }, { slug: 'cache', title: 'Cache', group: 'Resilience' }, + { + slug: 'cache-tags', + title: 'Cache Tags', + group: 'Resilience' + }, { slug: 'batching', title: 'Batching', group: 'Resilience' }, { slug: 'error-handling', diff --git a/websites/schema/app/playground/schemaDeclarations.ts b/websites/schema/app/playground/schemaDeclarations.ts index 072e9b75..d37d12f7 100644 --- a/websites/schema/app/playground/schemaDeclarations.ts +++ b/websites/schema/app/playground/schemaDeclarations.ts @@ -2960,6 +2960,15 @@ export declare class ParseStringSchemaBuilder; protected constructor(props: ParseStringSchemaBuilderCreateProps); + /** + * The human-readable message template pattern with \`{property}\` holes, + * e.g. \`"Todo created: #{TodoId} \\"{Title}\\" by user {UserId}"\`. + * + * Useful for structured logging — pass this as the log \`messageTemplate\` + * so events with the same shape can be grouped, and pass + * \`serialize(params)\` as the rendered message. + */ + get template(): string; /** * Return a snapshot of this builder's configuration. * @@ -5770,8 +5779,9 @@ type MergeExtensionMethods[], TT ...infer TRest extends readonly ExtensionDescriptor[] ] ? ExtractMethods & MergeExtensionMethods : {}; /** - * Unique symbol used by {@link FixedMethods} to detect extension methods whose - * first-argument literal should be accumulated in the return type. + * Unique string-literal brand key used by {@link FixedMethods} to detect + * extension methods whose first-argument literal should be accumulated in the + * return type. * * Declare the return type of any extension method as * \`this & { readonly [METHOD_LITERAL_BRAND]?: N }\` (where \`N extends string\`)