Mutations & forms
Your shop shows live data; now you'll sell something. In this chapter you add cart/add: a
schema-validated, transactional write whose UI is a real HTML form. One endpoint answers
browsers without JavaScript (POST-redirect-GET) and enhanced browsers (the fragment wire, Kovo's
readable chunk format for partial updates) — one handler, two response modes. Step state:
site/tutorial/steps/04-mutations/.
Declare the write once#
export const addToCart = mutation('cart/add', {
csrf: shopCsrf,
input: s.object({
productId: s.string(),
quantity: s.number().int().min(1).default(1), // FormData coercion declared here
}),
errors: {
OUT_OF_STOCK: s.object({ availableQuantity: s.number().int().min(0) }),
},
transaction(request: ShopRequest, run) {
return request.db.transaction((db) => run({ ...request, db }));
},
handler(input, request: ShopRequest, context) {
const found = request.db.products.get(input.productId);
if (!found || found.stock < input.quantity) {
return context.fail('OUT_OF_STOCK', { availableQuantity: found?.stock ?? 0 });
}
request.db.write('cart_items', {
productId: input.productId,
qty: input.quantity,
unitPrice: found.unitPrice,
});
request.db.write('products', {
...found,
stock: found.stock - input.quantity,
});
return { productId: input.productId, quantity: input.quantity };
},
});Each declaration in there derives several surfaces:
inputis the single source of truth for field names, types, and FormData coercion. Attribute and form values arrive as strings, sos.number().int().min(1).default(1)says howquantitybecomes a number, once. The same schema validates the wire at runtime.errorsdeclares the failure vocabulary.context.fail('OUT_OF_STOCK', …)is typed against it, and every consumer — fragment renderers,onErrorcallbacks — receives an exhaustive discriminated union.transactionwraps the handler in a fixed lifecycle: validate → guard →BEGIN→ handler →COMMIT, withfail()rolling back. The step's tiny database makes the commit/rollback boundary concrete:
async transaction(run) {
const draft = cloneShopDb(db);
const result = await run(draft);
// Commit: the draft becomes the database. A thrown error (fail()
// rolls back this way) discards the draft instead.
db.cartItems = draft.cartItems;
db.products = draft.products;
return result;
},Add CSRF protection#
Before you write the form, the request shell needs one more declaration. Mutations are browser-reachable POSTs, so CSRF protection is default-on: a mutation with no token source refuses every request rather than accepting forged ones. The token is a session-bound synchronizer the framework stamps into forms and verifies before input parsing:
// SPEC.md section 6.6: kovo-csrf is a session-bound synchronizer token stamped
// into every emitted form and verified before input parsing on every POST.
export const shopCsrf = {
secret: 'tutorial-shop-secret',
sessionId(request: ShopRequest) {
return request.session?.id;
},
};it('fails closed on a POST without the session-bound CSRF token', async () => {
const request = shopRequest();
const response = await submitAddToCart(
{ productId: 'p1', quantity: '1' }, // no kovo-csrf field
request,
{ 'Kovo-Fragment': 'true', 'Kovo-Targets': 'product-form:p1' },
);
expect(response.status).toBe(422);
expect(response.body).toContain('data-error-code="CSRF"');
expect(request.db.cartItems).toEqual([]);
});Render the no-JS form#
The product list component renders the add-to-cart form — a real form, posting to the mutation's named endpoint. The no-JS form is the contract the enhanced path upgrades, not a fallback bolted on afterward:
// SPEC.md section 6.3: the no-JS add-to-cart form posts to the mutation
// endpoint; `enhance` upgrades it to the fragment wire. Rendered standalone
// as the failure-rerender fragment (kovo-fragment-target). The kovo-csrf token
// is stamped into the form whenever the request carries a session
// (SPEC.md section 6.6).
export function renderAddToCartForm(
item: Pick<ShopProduct, 'id' | 'stock'>,
failure?: AddToCartFailure,
request?: ShopRequest,
): string {
return (
<form
method="post"
action="/_m/cart/add"
enhance
data-mutation="cart/add"
kovo-fragment-target={productFormTarget(item.id)}
>
{request?.session?.id ? csrfField(request, shopCsrf) : ''}
<input type="hidden" name="productId" value={item.id} />
<label>
Qty
<input name="quantity" type="number" min="1" max={item.stock} value="1" />
</label>
<button type="submit">Add</button>
{failure ? renderAddToCartError(failure) : ''}
</form>
);
}enhance is the entire opt-in: with JavaScript, the loader intercepts the submit and speaks the
fragment wire; without it, the browser posts natively. kovo-fragment-target names this form as a
patchable region so failures can re-render just it. Either way the wire stays legible — a named
POST to /_m/cart/add with schema-shaped fields.
it('renders the add-to-cart form, CSRF token included, as the page output', () => {
const request = shopRequest();
const html = renderShopPage(request.db, undefined, request);
expect(html).toContain(
'<form method="post" action="/_m/cart/add" enhance data-mutation="cart/add"',
);
expect(html).toContain('name="kovo-csrf"');
expect(html).toContain('name="productId" value="p1"');
expect(html).toContain('name="quantity" type="number" min="1" max="5" value="1"');
expect(html).toContain('kovo-fragment-target="product-form:p1"');
});Mode one: no JavaScript#
it('handles no-JS success as POST-redirect-GET', async () => {
const request = shopRequest();
// FormData arrives as strings; the schema declared the coercion once.
const response = await submitAddToCartNoJs(
formInput(request, { productId: 'p1', quantity: '2' }),
request,
);
expect(response).toEqual({
body: '',
headers: {
'Cache-Control': 'no-store',
Location: '/',
},
status: 303,
});
expect(request.db.cartItems).toEqual([{ productId: 'p1', qty: 2, unitPrice: 1499 }]);
expect(renderShopPage(request.db)).toContain('(3 in stock)');
});Success is POST-redirect-GET — status 303, fresh page, no resubmit-on-refresh. Failure re-renders the full page with the typed error in place and HTTP 422, the form still filled in:
it('handles no-JS failure as a full 422 page with the form re-rendered', async () => {
const request = shopRequest();
const response = await submitAddToCartNoJs(
formInput(request, { productId: 'p2', quantity: '3' }),
request,
);
expect(response.status).toBe(422);
expect(response.headers['Content-Type']).toBe('text/html; charset=utf-8');
expect(response.body).toContain('<form method="post" action="/_m/cart/add" enhance');
expect(response.body).toContain('data-error-code="OUT_OF_STOCK"');
expect(response.body).toContain('Only 2 available.');
expect(request.db.cartItems).toEqual([]); // fail() rolled the transaction back
});Users without the enhancements get a working website. That degradation is structural, not aspirational.
Mode two: the fragment wire#
With JavaScript, the same endpoint sees an Kovo-Fragment header and answers with readable chunks:
re-rendered fragments for the targets the live DOM declared via its kovo-deps stamps. The server
holds no record of what's on screen — it answers a stateless question:
it('answers the enhanced path with readable fragments from the same endpoint', async () => {
const request = shopRequest();
const response = await submitAddToCart(
formInput(request, { productId: 'p1', quantity: '2' }),
request,
{
'Kovo-Fragment': 'true',
'Kovo-Targets': 'cart-badge,product-list',
},
);
expect(response.status).toBe(200);
expect(response.headers['Content-Type']).toBe('text/vnd.kovo.fragment+html; charset=utf-8');
expect(response.body).toContain('<kovo-fragment target="cart-badge">');
expect(response.body).toContain('<span data-bind="cart.count">2</span>');
expect(response.body).toContain('<kovo-fragment target="product-list">');
expect(response.body).toContain('(3 in stock)');
});Fragments come from the same component renders as full pages, so partials can't drift from pages. They are DOM-morphed in — patched in place, not replaced — so focus, scroll, and island state survive; a fragment update is a tiny navigation. Failures ride the same wire, scoped to the form that caused them:
it('answers enhanced failures as a re-rendered form fragment', async () => {
const request = shopRequest();
const response = await submitAddToCart(
formInput(request, { productId: 'p2', quantity: '3' }),
request,
{
'Kovo-Fragment': 'true',
'Kovo-Targets': 'product-form:p2',
},
);
expect(response.status).toBe(422);
expect(response.body).toContain('<kovo-fragment target="product-form:p2">');
expect(response.body).toContain('kovo-fragment-target="product-form:p2"');
expect(response.body).toContain('data-error-code="OUT_OF_STOCK"');
expect(request.db.cartItems).toEqual([]);
});The mutations guide covers guards, file uploads, and response headers.
You now have a real write, working with and without JavaScript. But the enhanced response carried no updated query JSON: nothing told the server which queries this write invalidated. That's the next chapter.
Spec & diagnostics
input schema as single source of truth, validators required: SPEC §6.3, §6.6. errors as a
typed discriminated union: SPEC §9.2. Transaction lifecycle and rollback: SPEC §10.3. CSRF
default-on, fail-closed: SPEC §6.6. No-JS degradation as a structural contract: SPEC §8. Legible
named POST: Constitution #4. Stateless fragment responses keyed off live kovo-deps: SPEC §9.1.