Mutations & forms
Use a mutation when a browser form or enhanced submit changes server data. The same declaration works with JavaScript disabled and with the enhanced client. The handler validates input and can do reads. The writes themselves live in a named domain helper. Kovo commits, then reruns the invalidated queries and sends fresh fragments back.
Add the mutation#
Start with the smallest useful POST:
import { domain, publicAccess, mutation, s } from '@kovojs/server';
const cart = domain('cart');
const addCartRow = async (_db: unknown, _input: unknown) => {};
export const addToCart = mutation({
access: publicAccess('demo cart is intentionally public'),
csrf: cartCsrf,
input: s.object({ productId: s.string(), quantity: s.number().int().min(1) }),
registry: { touches: [cart] },
async handler(input, request: { db: unknown }) {
await addCartRow(request.db, input);
},
});The input schema parses form data. The access decision is explicit. CSRF stays on for browser forms.
registry.touches names the invalidation domain. The write goes through addCartRow(...), not a
direct request.db.insert(...) inside the handler body.
Render the form#
<form enhance mutation={addToCart}>
<input type="hidden" name="productId" value={product.id} />
<input name="quantity" type="number" min="1" defaultValue={1} />
<button type="submit">Add to cart</button>
</form>The served HTML is still a real form:
<form method="post" action="/_m/cart/add-to-cart" enhance>
<input type="hidden" name="kovo-csrf" value="..." />
<input type="hidden" name="Kovo-Idem" value="..." />
<input type="hidden" name="productId" value="p1" />
<input name="quantity" type="number" min="1" value="1" />
<button type="submit">Add to cart</button>
</form>Kovo emits the CSRF and Kovo-Idem fields as one bundle. Do not build a mutation form with a
standalone token helper: that would omit the replay identity. No JavaScript path gets a different
claim. Enhancement only changes how the response is applied. Retrying one token through the other
response mode returns an idempotency conflict instead of running the handler twice. A deterministic
typed failure is replayed in its original mode; validation and rate-limit failures remain retryable.
Run it#
Submit the form once with JavaScript disabled, then again with enhancement turned back on:
- Disable JavaScript in devtools and post the form. The browser does a full-page
POSTto/_m/cart/add-to-cart, then lands on the redirected page with the updated cart. - Re-enable JavaScript and submit again. In the network panel you now see the same mutation route
return a fragment response with fresh
<kovo-query>or<kovo-fragment>frames instead of a full document reload.
Return typed failures#
Expected failures belong in the mutation contract:
import { mutation, publicAccess, s } from '@kovojs/server';
import { type CsrfOptions } from '@kovojs/server/security';
declare const cartCsrf: Readonly<CsrfOptions<{ db: any }>>;
declare const cart: any;
declare const products: any;
const addCartRow = async (_db: unknown, _input: unknown) => {};
export const addToCart = mutation({
access: publicAccess('demo cart is intentionally public'),
csrf: cartCsrf,
input: s.object({ productId: s.string(), quantity: s.number().int().min(1) }),
registry: { touches: [cart] },
errors: {
OUT_OF_STOCK: s.object({ available: s.number().int().min(0) }),
},
async handler(input, request: { db: any }, context) {
const [row] = await request.db
.select({ stock: products.stock })
.from(products)
.where(eq(products.id, input.productId));
if (!row || row.stock < input.quantity) {
return context.fail('OUT_OF_STOCK', { available: row?.stock ?? 0 });
}
await addCartRow(request.db, input);
return { ok: true };
},
});The enhanced response morphs the submitted form with the failure state. The no-JS response rerenders the page with the same typed failure.
Put the write in the domain layer#
Here's the helper shape the compiler accepts:
import { domain } from '@kovojs/server';
export const cart = domain('cart');
export async function addCartRow(
db: { insert(table: unknown): { values(value: unknown): Promise<void> } },
input: { productId: string; quantity: number },
) {
await db.insert(cartItems).values({
productId: input.productId,
qty: input.quantity,
});
}This is the contract from packages/compiler/src/direct-db.test.ts: mutation handlers may read
through request.db, but writes in the handler body fail the graph check. Route the write through a
named helper or domain operation instead.
Declare opaque writes#
Raw SQL and helper calls that hide the write need registry facts:
import { mutation, publicAccess, s } from '@kovojs/server';
import { type CsrfOptions } from '@kovojs/server/security';
declare const cartCsrf: Readonly<CsrfOptions<{ db: any }>>;
declare const cart: any;
const mergeCartRows = async (_db: unknown, _cartId: string) => {};
export const mergeCart = mutation({
access: publicAccess('demo cart merge mutation'),
csrf: cartCsrf,
input: s.object({ cartId: s.string() }),
registry: {
tables: ['cart_items'],
touches: [cart],
},
async handler(input, request: { db: any }) {
await mergeCartRows(request.db, input.cartId);
return { ok: true };
},
});The helper may use raw SQL internally. tables is checked at the SQL execution boundary. If
production SQL mutates a table outside the allowlist, the runtime fails closed and invalidates the
declared touches domains conservatively.
Handle failure#
Two failures matter on the first pass:
- Validation and business-rule failures should stay typed and visible in the form. Return
context.fail(...), then bind the result with<FormError>or<FieldError>from@kovojs/core. - A successful no-JS submit should still take the POST-redirect-GET path. Return a typed
redirect('/cart/:id', { params }), or setdefaultRedirectTowhen the destination is fixed.
If you write directly in the handler body, kovo check reports the graph diagnostic instead:
ERROR KV330 cart.mutation.ts:12 Direct db access in a mutation handler; route through domain. handler addToCart receives db.That wording comes from the compiler's direct-db coverage. The fix is always the same: keep reads in
the handler, move writes into a named helper or domain module, and point registry.touches at the
Domain values those writes affect.
Add optimism only after the write is clear#
Optimism is keyed to queries:
import { queue, s, type InferSchema } from '@kovojs/server';
import { app } from './kovo.js';
import { cartSummaryQuery, productDetailQuery } from './queries.js';
declare const addToCartHandler: (input: {
productId: string;
quantity: number;
}) => Promise<{ ok: true }>;
const addToCartInput = s.object({
productId: s.string(),
quantity: s.number().int().min(1),
});
type AddToCartInput = InferSchema<typeof addToCartInput>;
export function predictCartSummary(current: Readonly<{ count: number }>, input: AddToCartInput) {
return { count: current.count + input.quantity };
}
export const addToCart = app.mutation({
access: app.publicAccess('demo cart is intentionally public'),
input: addToCartInput,
optimistic: [
cartSummaryQuery.optimistic(addToCartInput, predictCartSummary),
productDetailQuery.optimistic('await-fragment'),
],
queue: queue('cart'),
handler: addToCartHandler,
});The query handles preserve result typing and the exact mutation schema by identity. Use
'await-fragment' when the safe prediction is not obvious. Server truth still wins after commit.
Check it#
kovo check
kovo checkkovo check covers the type/lint side of the form and handler wiring, then derives the graph gate
that reports direct handler writes, opaque writes, and the invalidation coverage facts.
Next#
- File uploads & storage — accept multipart files and serve them back safely.
- Queries & invalidation — see how visible queries refresh after a mutation.
- Optimistic updates — predict a query while the mutation is in flight.
Spec & diagnostics
Mutation lifecycle: SPEC §9.1 and §10.3. Access decisions: SPEC §10.2/KV436. CSRF and replay order: SPEC §10.3 request lifecycle. Opaque write declarations: KV406. Direct handler writes are KV330: "Direct db access in a mutation handler; route through domain." Stale version conflicts use KV429.
API reference: @kovojs/core, @kovojs/server.