Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
160c67f
fix(docs): correct API examples across 6 docs pages
andrewzolotukhin Apr 29, 2026
733b6f4
feat: add intersect() schema builder
andrewzolotukhin May 2, 2026
7527294
feat: add multipart/form-data parsing and file upload
andrewzolotukhin May 2, 2026
1de45cf
feat: updated client to support file uploads
andrewzolotukhin May 2, 2026
1e0e24d
feat: updated todo-client/backend
andrewzolotukhin May 2, 2026
9e9bb4c
feat: add async to standard schema .validate()
andrewzolotukhin May 2, 2026
4609106
feat: optmimistic updates
andrewzolotukhin May 2, 2026
e11caf2
feat: separated knex-schema operations
andrewzolotukhin May 2, 2026
d5223b7
fix: optimistic updates
andrewzolotukhin May 3, 2026
ccc2dd7
feat: add enpoint info to cache invalidation callback
andrewzolotukhin May 3, 2026
6e7b5a6
fix: docker
andrewzolotukhin May 3, 2026
38b291b
fix: docker
andrewzolotukhin May 3, 2026
e87b685
fix: docker
andrewzolotukhin May 3, 2026
abf7e67
fix: optimistic mutation
andrewzolotukhin May 3, 2026
54e7e05
feat: automatic cache invalidation
andrewzolotukhin May 4, 2026
44f3995
fix: typings
andrewzolotukhin May 4, 2026
0f05efe
feat: cache page for todo-frontend
andrewzolotukhin May 4, 2026
87fae81
feat: improve cache page
andrewzolotukhin May 4, 2026
d213330
fix: added cache tags
andrewzolotukhin May 4, 2026
7ed535b
fix: caching
andrewzolotukhin May 4, 2026
d022f8e
fix: cache invalidation
andrewzolotukhin May 4, 2026
ba1d509
fix: cache
andrewzolotukhin May 4, 2026
5e19c7f
fix: caching
andrewzolotukhin May 4, 2026
ede7e9e
fix: caching tags
andrewzolotukhin May 4, 2026
91743ab
chore: improved docs, tests
andrewzolotukhin May 4, 2026
a904ae1
feat: server side tag based cache
andrewzolotukhin May 5, 2026
68d85fd
feat: idempotency handling
andrewzolotukhin May 5, 2026
23dc778
chore: update documentation
andrewzolotukhin May 5, 2026
9fb3eae
chore: restructured knex-schema
andrewzolotukhin May 5, 2026
29c52b0
chore: improved documentation
andrewzolotukhin May 5, 2026
a85c862
chore: add .clearsCacheTag()
andrewzolotukhin May 5, 2026
4640fa0
chore: cleaned up endpoints.ts
andrewzolotukhin May 5, 2026
2e3ee48
Merge pull request #186 from cleverbrush/feat/optimistic-update
andrewzolotukhin May 5, 2026
7c6f1b6
Merge pull request #188 from cleverbrush/feat/separate-query-operations
andrewzolotukhin May 5, 2026
e54d627
fix: typing for intersection
andrewzolotukhin May 5, 2026
4fb7938
fix(tests): update validation calls to use 'as any' for undefined and…
andrewzolotukhin May 5, 2026
69e030f
fix: typings
andrewzolotukhin May 5, 2026
d588f1b
Merge pull request #187 from cleverbrush/feat/intersect
andrewzolotukhin May 5, 2026
0fe45b3
fix: update validation logic to use 'never' type for coerced values
andrewzolotukhin May 5, 2026
218c9ec
Merge pull request #189 from cleverbrush/feat/standard-schema-async
andrewzolotukhin May 5, 2026
88dbd3d
feat: add @fastify/busboy dependency for file upload support
andrewzolotukhin May 5, 2026
b37bcf3
fix: runtime error
andrewzolotukhin May 5, 2026
f40ea18
Merge branch 'development' into feat/file-upload
andrewzolotukhin May 5, 2026
6a9c44f
fix: file upload
andrewzolotukhin May 5, 2026
0cc1e4f
Merge branch 'feat/file-upload' of github.com:cleverbrush/framework i…
andrewzolotukhin May 5, 2026
c593a8d
fix: merge errors
andrewzolotukhin May 5, 2026
276aec3
Merge pull request #190 from cleverbrush/feat/file-upload
andrewzolotukhin May 6, 2026
389a958
fix: file upload limit
andrewzolotukhin May 6, 2026
25ad446
docs: v4.1.0
andrewzolotukhin May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/lovely-socks-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cleverbrush/schema': minor
---

fix(schema): make ~standard.validate return Promise to support async validation per Standard Schema v1 spec
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
5 changes: 5 additions & 0 deletions .changeset/separate-knex-schema-operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cleverbrush/knex-schema": patch
---

Extract query operations into modular files (helpers, select, where, insert, update, delete, join, pagination, state) from monolithic SchemaQueryBuilder. No public API changes.
11 changes: 11 additions & 0 deletions .changeset/tiny-dragons-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cleverbrush/schema': minor
'@cleverbrush/schema-json': minor
---

Add `intersection()` schema builder for combining two schemas (both must pass)

- New `IntersectionSchemaBuilder` class with `intersection(left, right)` factory
- Validates both schemas against the input and merges outputs
- Maps to `allOf` in JSON Schema (to/from bidirectional)
- Supports all standard modifiers: `.optional()`, `.nullable()`, `.default()`, `.catch()`, `.brand()`, `.readonly()`, `.addValidator()`, `.addPreprocessor()`, etc.
24 changes: 24 additions & 0 deletions .changeset/tiny-socks-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@cleverbrush/server": minor
"@cleverbrush/server-openapi": minor
---

feat(server): add file upload support via `.upload()` and `FilePart` type

Adds `multipart/form-data` parsing with `@fastify/busboy`, a new `.upload()`
method on `EndpointBuilder`, and the `FilePart` type for handling uploaded
files in endpoint handlers. The OpenAPI generator emits `multipart/form-data`
request bodies for upload-enabled endpoints.

```ts
const ep = endpoint
.post("/api/avatar")
.upload({ maxFileSize: 2 * 1024 * 1024 })
.body(object({ description: string().optional() }));

server.handle(ep, ({ files }) => {
const avatar = files["avatar"];
// { filename, mimeType, buffer, size }
});
```

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
await knex.schema.table('todos', (table) => {
table.binary('attachment_data').nullable();
table.string('attachment_name', 1024).nullable();
table.string('attachment_mime_type', 255).nullable();
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.table('todos', (table) => {
table.dropColumn('attachment_data');
table.dropColumn('attachment_name');
table.dropColumn('attachment_mime_type');
});
}
53 changes: 47 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 @@ -164,10 +176,25 @@ export const api = defineApi({
route({ id: number().coerce() })`/${t => t.id}/attachment`
),

uploadAttachment: todosResource
.post(
route({ id: number().coerce() })`/${t => t.id}/attachment`
)
.upload({
maxFileSize: 10 * 1024 * 1024,
allowedMimeTypes: [
'image/*',
'application/pdf',
'text/plain'
]
})
.body(object({ description: string().optional() })),

listActivity: todosResource
.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 +206,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 +240,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
37 changes: 30 additions & 7 deletions demos/todo-backend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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 { TodoResponseSchema } from './schemas.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 Expand Up @@ -179,16 +185,32 @@ export const ExportTodosEndpoint = api.todos.exportCsv

export const DownloadAttachmentEndpoint = api.todos.downloadAttachment
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.producesFile('text/plain', 'A plain-text summary of the todo.')
.inject({ knex: KnexToken })
.summary('Download todo attachment')
.description(
'Downloads a plain-text summary of the todo as a file attachment. ' +
'Demonstrates `.producesFile()` and `ActionResult.file()`.'
'Downloads the uploaded file attachment for a todo. ' +
'Returns the original file with its original content type.'
)
.tags('todos')
.operationId('downloadTodoAttachment');

// ── Upload attachment ─────────────────────────────────────────────────────────
// Features: .upload(), multipart/form-data, FilePart, file persistence in DB

export const UploadAttachmentEndpoint = api.todos.uploadAttachment
.authorize(PrincipalSchema)
.inject({ db: DbToken, knex: KnexToken })
.responses({ 201: TodoResponseSchema })
.summary('Upload todo attachment')
.description(
'Uploads a file attachment for a todo. ' +
'Supports images, PDFs, and plain text files up to 10 MB. ' +
'The file is stored in the database and can be downloaded via ' +
'the download attachment endpoint.'
)
.tags('todos')
.operationId('uploadTodoAttachment');

// ── Import todos ──────────────────────────────────────────────────────────────
// Features: .example(), .examples(), .headers(), ActionResult.json(), ActionResult.accepted()

Expand Down Expand Up @@ -391,6 +413,7 @@ export const endpoints = {
sendEvent: SendTodoEventEndpoint,
exportCsv: ExportTodosEndpoint,
downloadAttachment: DownloadAttachmentEndpoint,
uploadAttachment: UploadAttachmentEndpoint,
importBulk: ImportTodosEndpoint,
legacyReplace: LegacyReplaceTodoEndpoint,
complete: CompleteTodoEndpoint,
Expand Down
4 changes: 3 additions & 1 deletion demos/todo-backend/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
listTodoActivityHandler,
listTodosHandler,
sendTodoEventHandler,
updateTodoHandler
updateTodoHandler,
uploadAttachmentHandler
} from './todos.js';
import {
deleteUserHandler,
Expand All @@ -51,6 +52,7 @@ export const handlers: HandlerMap<typeof endpoints> = {
sendEvent: sendTodoEventHandler,
exportCsv: exportTodosHandler,
downloadAttachment: downloadAttachmentHandler,
uploadAttachment: uploadAttachmentHandler,
importBulk: importTodosHandler,
legacyReplace: legacyReplaceTodoHandler,
complete: completeTodoHandler,
Expand Down
Loading
Loading