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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/quiet-pans-create.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion demos/todo-backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
Expand Down
39 changes: 33 additions & 6 deletions demos/todo-backend/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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: {
Expand All @@ -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
})
Expand Down
11 changes: 8 additions & 3 deletions demos/todo-backend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions demos/todo-frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
4 changes: 4 additions & 0 deletions demos/todo-frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<Flex justify="center" align="center" p="8">
Expand Down Expand Up @@ -51,6 +53,8 @@ const router = createBrowserRouter([
{ path: '/react-query', element: <Suspense fallback={<PageFallback />}><ReactQueryPage /></Suspense> },
{ path: '/live', element: <Suspense fallback={<PageFallback />}><LivePage /></Suspense> },
{ path: '/activity', element: <Suspense fallback={<PageFallback />}><ActivityFeedPage /></Suspense> },
{ path: '/cache', element: <Suspense fallback={<PageFallback />}><CachePage /></Suspense> },
{ path: '/idempotency', element: <Suspense fallback={<PageFallback />}><IdempotencyPage /></Suspense> },
{
element: <ProtectedRoute adminOnly />,
children: [
Expand Down
32 changes: 23 additions & 9 deletions demos/todo-frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(),
Expand All @@ -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
});
2 changes: 2 additions & 0 deletions demos/todo-frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '🔔' },
Expand Down
Loading
Loading