# Statement runtime behavior

> Read when you need to know what a statement's `as:` output actually holds, or why a bound variable is not the shape you expected.

Runtime behavior (what the `as:` output holds, and misses):

- `db.get` binds **`null`** when no row matches (it does NOT throw) — so the output is `InferRow<typeof table> | null`; null-check it. On a hit it binds the **full row**. (`db.has` is the boolean existence test.)
- `db.edit` binds the **full, post-mutation row** (the freshly-written values, not the pre-edit ones). `db.add` binds the **full inserted row**, including the auto-assigned `id` and `created_at`. So `InferRow<typeof table>` is the right response type for those two. `db.del` **binds `null`** — the engine deletes the row and returns no value, so don't return the `as` var expecting the deleted row.
- Unlike `db.get`, `db.edit` and `db.del` **throw** `NotFound` (HTTP 404) when no row matches the field. `db.add` throws on a unique-constraint violation.
- **`InferResponse<typeof query>`** derives an endpoint's response type (read-side round trip, no codegen). It resolves object-literal responses to those keys; a `response: ref("x")` returning a variable bound by a TOP-LEVEL db op on a `table()` resolves to that op's result:
  | statement | resolves to | on a miss |
  |---|---|---|
  | `db.add` / `db.edit` / `db.patch` / `db.add_or_edit` | `Row` (the full written row, non-nullable) | throws — `NotFound`/404 for `edit`/`patch`, a unique-constraint error for `add`; `add_or_edit` upserts and never misses |
  | `db.get` | `Row \| null` | binds `null` rather than throwing |
  | `db.query` / `db.bulk.patch` | `Row[]` | — |
  | `db.has` | `boolean` | — |
  | `db.bulk.delete` | `number` (count) | — |
  | `db.del`, `db.bulk.add`/`bulk.update`, raw `direct_query` | `unknown` (the engine leaves them untyped) | — |
  - A `get`/`query` `output: [...]` selection narrows to a `Pick` (still `| null` for `get`). A dotted `ref("row.col")` into a `db.get` row projects that column carrying the `| null` (→ `Col | null`).
  - A value reshaped by a filter/lambda, or a variable built by control flow / `set_var`, also resolves to `unknown`.
  - A CALL binds the TARGET's response type: `s.function.call`/`s.function.run`/`s.api.call`/`s.tool.call` given a def HANDLE brand their `as` with `InferResponse` of the target, so `ref("out.field")` types to that field. What the target resolves to is what propagates — declare `responseShape` on the TARGET to fix every caller at once. A target named by STRING has no def to read and stays `unknown`, as does an async `s.function.run` (it binds a job handle, not the result).
  - ⚠ In an object-literal response the KEYS are always known, but a VALUE is typed only when it traces to a binding — a `ref`, or a `ref` under `withFilters`. A CONSTANT does not trace: `response: { success: c.bool(true), hello: c.text("hi") }` derives `{ success: unknown, hello: unknown }`, NOT `{ success: boolean, hello: string }`. Same for an `inp` outside a trace. Only a reference carries a type the derivation can follow. Declare `responseShape` when a client needs those keys typed.
  - A NESTED member resolves to any depth by these same rules, in either spelling — `{ user: obj({ id: ref("row.id") }) }` and the raw literal `{ user: { id: ref("row.id") } }` both derive `{ user: { id: Col | null } }`. A raw scalar member types itself (`{ count: 3 }` → `number`).
  - Runs on every response-bearing kind — `query`, `defineFunction`, `realtimeMessage`, `tool`, `middleware`, and the response-bearing triggers — each of which also accepts `responseShape`. A trigger builds its stack and response through CALLBACKS (`stack: (t) => [...]`), and the trace follows through them.
  - Close any `unknown` by declaring `responseShape` on the def (`responseShape: null as InferRow<typeof t> | null`) — the declaration ALWAYS overrides derivation.
  - ⚠ A `resultStrategy: "replace"` middleware attached `post` reshapes the endpoint's output at runtime, which the static walk cannot see. Declare `responseShape` when a post middleware rewrites the response.
  - ⚠ **Spreading a `Statement[]` helper into a stack kills the whole walk.** The trace needs the stack's TUPLE type, so `...myHelper()` where the helper returns `Statement[]` widens it and EVERY `as` in that stack — including ones declared after the spread — stops resolving. The response then types as `StackTupleWidened`, whose name says so. Fix: return `statements(s.a(...), s.b(...))` from the helper (a const-generic identity export — the tuple survives the spread). A helper that builds its array in a LOOP cannot be a tuple; declare `responseShape` there.
- **Addons** enrich returned rows. `db.query`/`get`/`add`/`edit`/`patch` accept `addon: [{ addon, as, input?, output?, children? }]`; `db.add_or_edit`/`del`/`has`/`truncate` take no `addon`.
  - `addon` is the target (name or def handle). `as` is the destination on the row — a bare alias (`"_user"`) or a dotted `offset.alias`, authored relative to a row. Under a metadata paging envelope the `items[]` offset is prefixed automatically; writing it yourself is tolerated and not double-prefixed.
  - `input` maps addon inputs — bind a parent-row column with `out(col)`. `output` restricts addon columns. `children` nests addons.
  - An addon is a single table-bound db query, NOT a statement stack: `addon({ name, table, tableAlias?, where?, sort?, output: [cols], cardinality?: "single"|"list"|"count"|"exists"|"aggregate", group?, eval?, input?, context? })`, registered via `registerAddons([...])`.
    - `table` auto-fills the `context.dbo` binding. ⚠ Never author `table: null` — that is a BROKEN table-less addon returning nothing; `codegen` emits it only for an already-broken pulled object.
    - `tableAlias` is its SQL alias (`context.dbo.as`), qualifying `where`/`sort` columns (`col("merchant.id")`).
    - `where`/`sort` take the same surface as `s.db.query` and encode `context.search`/`context.sort`. `where` is the predicate binding the addon to the parent row — `expr(col("id"), "=", inp("user_id"))`.
    - `cardinality` shapes the result (`context.return.type`, omitted for the `"list"` default). Rarer context (`eval`/`bind`/`lock`) stays raw `context` passthrough.
  - Attaching a typed `addon({ table, output })` handle merges its alias (the last `as` segment) onto the row in `InferResponse`: `{cols}` for `single`, `{cols}[]` for `list`, `number` for `count`, `boolean` for `exists`, and for `aggregate` an array keyed by the `group`/`eval` aliases (`unknown` values; `unknown` when neither is declared).
  - An attachment-level `output` narrows an object/array graft further. A bare-NAME reference grafts `unknown` — narrow it at the call site.
  - ⚠ An alias that shadows an existing column on the queried table throws at build time; rename with a `_` prefix.
- **Middleware attachment** runs a reusable `middleware({...})` before/after a host's own stack. Attach with the host's `middleware: { pre, post }` field on `query`/`function`/`task`/`tool`/`apiGroup` (NOT triggers): each phase is an ordered list of middleware refs (def handle or name), or `{ middleware, active: false }` to keep an entry disabled. Providing a phase **overrides** it (sets the stored `pre_customize`/`post_customize` flag); omitting a phase **inherits** the parent tier's chain — the engine resolves Query → API Group → Workspace at request time (override, not merge; the API-Group tier applies to queries — functions/tasks/tools have no API-group binding and inherit straight from the workspace). Prefer a def handle over a bare name when the middleware pins an explicit `guid`. `pre: middleware.clear()` (an empty list) overrides with nothing — stop inheriting. Workspace-level defaults are the terminal tier: `workspaceConfig({ middleware: { query: { pre }, function, task, tool } })` emits the flat `{host}_{phase}` map (no `_customize` flags) — setting it replaces the whole workspace map, so unlisted hosts are cleared; omit the field to leave existing workspace middleware untouched. Distinct from `s.middleware.call` (inline invoke).
- **Middleware request context.** A `pre` middleware runs **after** auth resolution, so `auth()` is available inside the middleware when the host is authenticated (its `auth` names an auth table); on a public host `auth()` is `null`. This matters for the canonical use — a rate limit keyed by `auth("id")`: on an authenticated endpoint the bucket is per-user, but attach the same middleware to a public endpoint and every anonymous caller keys under the same `null` id (one shared bucket), silently. To catch that, `export()` **warns** (never blocks) when a middleware whose stack references `auth()` is directly attached to a host where `auth()` may be null — a `query` with no auth table, a `task` (scheduled, never authenticated), or a `function`/`tool` (whose auth is caller-dependent). An authenticated query (its own `auth` table set) is skipped. The check is direct-attachment only; a middleware reaching a public query via API-group/workspace tier inheritance is not caught.
- **Rate-limit recipe (the canonical middleware).** Per-user rate limiting is the most common middleware. Author it with `s.redis.ratelimit` and a **composite key** built via the filter chain — `"prefix" + auth("id")` does not exist, you build the key: `middleware({ name: "write_rl", exceptionPolicy: "rethrow", stack: [ s.redis.ratelimit({ key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), max: c.int(10), ttl: c.int(30), error: c.text("Too fast.") }) ] })`. `exceptionPolicy` defaults to `"rethrow"`, which is what makes a tripped limit abort with HTTP 429; `"silent"` would let the over-limit request through. Attach it with `middleware: { pre: [writeRl] }` on an **authenticated** host (its `auth` set) so `auth("id")` keys per-user; on a public host `auth("id")` is null and every caller shares one bucket (`export()` warns — see request context above). **Shared-bucket rule:** co-attaching one middleware object to N hosts means all N share the *same* key ⇒ *one* counter — `max: 10` is a global per-user budget across them, not 10-per-host. Vary the key (fold in the host/action name) for an independent limit per host.
- **Middleware `exceptionPolicy`** governs what a **throw** in the middleware stack does to the request (XanoTS passes the value through; the Xano engine interprets it). `"rethrow"` is the **default** — the throw aborts the request and surfaces the authored `error`/status (a tripped `s.redis.ratelimit` → HTTP 429); the `post` chain still runs. `"silent"` swallows the throw, so a guard set to it is **not enforced** — advisory middleware only. `"critical"` is `"rethrow"` plus skipping the `post` chain. The only difference between `rethrow` and `critical` is whether `post` runs — no status or logging change.

- **Request history** controls per-object execution capture (the request/task/trigger debugger). Authored as a single scalar `history` field on any primitive: `false` off, `true` on at the default capture depth, a number = capture depth (how many statement executions are recorded per history record — NOT record retention), `"all"` unlimited. **Omit `history` to inherit** — the engine resolves object → container → workspace at request time (a query inherits from its API group, a tool from its toolset envelope, everything else straight from the workspace). Any authored value stops inheriting for that object. Per-kind defaults (when inheriting): query/task/tool capture ON, function/trigger/middleware OFF; default depth 100. Container tiers are authorable too — `apiGroup({ history })` sets the `query_*` default its queries inherit, and an agent/mcp_server/toolset `history` sets the `tool_*` default its tools inherit. Workspace-level defaults are the terminal tier: `workspaceConfig({ history: { query, function, task, tool, trigger, middleware } })` emits the flat `{objType}_enabled`/`{objType}_limit` map (no inherit flag) — setting it is wholesale (unlisted types fall back to their engine default), so declare every default you want to keep; omit the field to leave existing workspace history untouched.

- **Workspace environment variables** set a tenant's env vars through the workspace object: `workspaceConfig({ env: { STRIPE_KEY: process.env.STRIPE_KEY!, APP_BASE_URL: "https://…" } })`. Author them as a name→value MAP. Read a var back with `env("NAME")` (→ `$env.NAME`), which compiles to tag "setting" with the plain name. Values are SECRETS: prefer sourcing from `process.env` over committing literals, and don't commit a compiled bundle with real values. `deploy` REPLACES the tenant's env with the declared map; `release` (merge) is ADD-ONLY — it creates missing keys but does NOT update or remove existing ones, so changing a value in code and releasing leaves the live value unchanged. Omit `env` to leave existing env untouched. The separate `settings` field is a plain object.
