# 101. Actions and the session

The question this chapter answers: how does the browser change something, how is the input typed, where does the session live and what stops an empty cart from reaching the order service?

**For:** app developers.

## Declaring an action

An action is an exported constant in a route's `actions.ts`, built with `action` around a body whose parameter names its input type:

```ts
export const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => {
  const key = String(input.product_id);
  const wanted = (session.cart[key] ?? 0n) + input.quantity;
  if (wanted <= 0n) delete session.cart[key];
  else session.cart = { ...session.cart, [key]: wanted };
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { lines: session.cart, count };
});
```

`AddToCart` is an interface under `app/schemas/`; the build lowers every interface there into the contract beside the imported services. The host checks an action's input against its schema before the body runs, so a body never sees a shape it did not declare. The annotation on the parameter is what tells the build which schema. It is also what lets TypeScript infer the action's result for the browser and the tests; `action<AddToCart>(...)` also works, but with an explicit type argument TypeScript stops inferring the rest, so the parameter form is the one to write.

The build declares every action in the plan file by id, `cart.addToCart`, so the host refuses to boot if any declared action has nothing answering it. So an action a page can call but nothing implements stops the boot instead of answering 404 in production.

## The session

`app/schemas/session.ts` declares the session's shape as an interface, plus `defaults` for what a body reads when a key is absent:

```ts
export interface Session {
  cart: Record<string, bigint>;
}

export const defaults: Session = { cart: {} };
```

A body reads `session.cart` and gets `{}` on a fresh session rather than `undefined`, because the build folds the default into every read it lowers. Writes are statements the interpreter applies: `session.cart = ...` and `delete session.cart[key]`. They land in a draft and commit only when the body finishes; a body that fails halfway leaves the session untouched. The write itself is what the host persists, through the signed cookie and the store [chapter 203](https://snapfirers.com/fsr/the_guide/203-sessions-and-identity.md) describes; a body never sees a cookie.

An action, a route handler or middleware can also move the session's end: `session.extend(7200)` makes it end two hours from now once the body commits and the host sets the cookie again to match. Nothing extends on its own, so a session read a thousand times still ends one `ttl` after it opened; extend on sign-in, on a write the application makes anyway or once the remaining time drops under a threshold. A loader cannot call it, since a loader runs on every navigation and extending there would be a store write per page view; the build refuses it by name.

The typed shape is why the cart is written as `session.cart = { ...session.cart, [key]: wanted }` rather than an index assignment. With `cart` typed as a record, `session.cart[key] = wanted` is a type error when the record may be absent; the correct TypeScript is the spread with a computed key, which the recogniser supports.

## Guards

`fail(kind, message)` inside an `if` is a guard. There is one kind per HTTP error status and the response carries that status: `unauthorized` 401, `forbidden` 403, `not_found` 404, `conflict` 409, `too_many_requests` 429, `invalid` 400, `internal` 500 and the rest the runtime reference lists. The storefront's checkout has one:

```ts
if (lines.length === 0) fail("invalid", "the cart is empty");
```

A guard that reads nothing a call has to produce runs before any call is made, so an empty cart never reaches the order service. The test in chapter 103 asserts exactly that. A guard that depends on a call's result runs where it sits. The kind is a string literal, because the build matches it. The message is any expression and is evaluated only when the guard fires, so a guard can say what it knows: `` fail("not_found", `there is no talk ${params.id}`) `` names the id the caller asked for.

## Calling an action from the browser

The build writes one typed callable per action into `generated/client.ts`, nested by route: `actions.cart.addToCart({ product_id, quantity })` returns the body's result, typed. The nesting follows the route's own directories, so an action in the root `routes/actions.ts` is reached as `actions.$root.play(...)`: the root has no directory to nest under and `$root` stands in for it, exactly as chapter 100 names it. The `$` is ordinary TypeScript, a property of the generated object like any other. The client holds action ids, never URLs; the host answers them at one path and checks the input before dispatch. A failure comes back as an `ActionFailure` carrying the kind and the message the guard gave, which is what the storefront's toast shows.

A successful call re-fetches the current route by default and patches the segments that changed, so the header's badge follows the cart without a page reload and without the page asking. A call that should not revalidate says so when it is created.

## File uploads

An action posted as `multipart/form-data` receives the parts as its input: one with no filename is a text field, coerced against the schema exactly as a urlencoded field is; one with a filename is an `Upload`.

```ts
export interface Deposit {
  caption: string;
  file: Upload;
}
```

`Upload` is the host's own type. A schema names it without declaring it, the way it names `Uint8Array`. An application that declares its own `Upload` is refused, so it cannot disagree with the host about the shape. It carries `filename` and `content_type` as the browser claimed them, `size` and `bytes`.

```ts
export const deposit = action(async ({ input, session }: ActionCtx<Deposit>) => {
  if (!ACCEPTED.includes(input.file.content_type)) {
    fail("invalid", `${input.file.content_type} is not a type this takes`);
  }
  return await services.files.store({ name: input.file.filename, body: input.file.bytes });
});
```

Do not trust the name or the type, since both are strings the client chose. The host only guarantees the length. `server.max_upload` refuses a part over it before the body runs, with `server.max_body` bounding the request whole. The request is buffered before anything parses it, so an upload costs memory for its size while it is in flight; this is for a file a person picks in a form rather than for a large transfer.

A body has no filesystem, so `bytes` goes to a service method or to the application's own Rust through `ctx.native`.

A form and a script reach the same action. A `<form method="post" enctype="multipart/form-data">` posts it with no JavaScript and is answered with a redirect back to the page, so the session the action wrote is what the next render reads. From the page, `upload(id, formData)` in the client posts a `FormData` and names JSON in `Accept`, so the host answers the action's value rather than the redirect. Both carry `_csrf`, since a form post is verified where a JSON call is not. A form anonymous visitors post needs `[session] csrf = "always"`. The token is minted when a page renders it. Only that response starts a session for a visitor who has none, so a page with no form sets no cookie and stays cacheable.

## The lab

Run `fsr check --verbose app`, then remove the `export` from `checkout` in `actions.ts` and check again: the report's actions section loses `cart.checkout`, `generated/client.ts` loses its callable and `tsc` fails in the cart page at the call. A page cannot call an action the build did not declare. Put it back.

Then run `fsr test app checkout`. The test named "checkout refuses an empty cart before any call" asserts that the order service saw nothing, which is the guard running first; the one after it places the held lines and asserts the cart came back empty in the same body. Add a product in the browser and check out to watch the second one happen for real.
