GitHub
Menu

Tutorial

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#

ts
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:

  • input is the single source of truth for field names, types, and FormData coercion. Attribute and form values arrive as strings, so s.number().int().min(1).default(1) says how quantity becomes a number, once. The same schema validates the wire at runtime.
  • errors declares the failure vocabulary. context.fail('OUT_OF_STOCK', …) is typed against it, and every consumer — fragment renderers, onError callbacks — receives an exhaustive discriminated union.
  • transaction wraps the handler in a fixed lifecycle: validate → guard → BEGIN → handler → COMMIT, with fail() rolling back. The step's tiny database makes the commit/rollback boundary concrete:
ts
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:

ts
// 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;
  },
};
ts
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:

tsx
// 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.

ts
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#

ts
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:

ts
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:

ts
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:

ts
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.