# Column and input types

> Read when declaring a table column (`f.*`) or a function/query input (`input.*`) — a type's options and accessor methods, and the `s.precondition` error/status contract that rides the same catalog.

Author table columns + function/API inputs with the typed catalog: `f.<type>(opts?)`
for columns, `input.<type>(opts?)` for inputs. Common opts: `required`, `nullable`,
`default`, `description`.
**`nullable` defaults PER TYPE, matching the engine's own column-creation API**: `true`
for `f.vector`, `f.uuid`, every `f.geo.*` and every file type (`f.image`/`f.video`/
`f.audio`/`f.attachment`), `false` for everything else (text, int, decimal, bool, email,
enum, json, object, password, date, tableRef). Pass `nullable` explicitly to override —
e.g. `f.geo.polygon({ nullable: false })`. This is why `f.vector(8)` deploys: the engine
turns an empty default into SQL NULL only for a nullable column, so a non-null vector
would reach PostgreSQL as `vector(8) not null default ''` and fail to create.
**`f.geo.*` values are `{ type, data }`, not GeoJSON.** The same shape goes in and comes
back: `{ type: "point", data: { lng, lat } }`, `{ type: "poly", data: [{ lng, lat }, …] }`
— `type` is the engine's abbreviation, and a polygon ring is closed for you. Raw WKT text
(`c.text("POINT(1 2)")`) is accepted on write too, but a read never returns one.
`methods` is a bind-time validator/transform pipeline whose
valid names depend on the field type (below) — pass bare names (`"trim"`), the
colon-form with args (`"min:8"`), or `{ name, arg }` for anything not listed.
`f.json({children})` declares the nested shape stored INSIDE a json column — an ARRAY of
`{name, type, methods?, children?}`, order-significant, distinct from the `FieldMap` that
`f.object` takes positionally. Omit it for an unstructured json column.
`f.enum(values)`/`f.vector(size)`/`f.object(children)`/`f.tableRef(table)` take a
positional payload before opts — and still accept the standard `FieldOptions` after it
(`f.enum([])`/`input.enum([])` are accepted, because the engine stores an enum whose
options were never filled in — that is a pulled-workspace shape, not one to author; it
brands the column `never`, which `InferRow` surfaces at read time as `undefined` — so
code written against `never` is dead in a way `undefined` is not.)
(e.g. `f.tableRef(users, { required: true })` — only `min`/`max` are listed as tableRef
methods below, but `required`/`nullable`/`description`/… apply like any field.)
An **OPTIONAL foreign key wants a `0` sentinel, not `nullable: true`.** `f.tableRef` stores
an `int`, and a null in it is unqueryable: `null` is never a legal `fieldValue`/`id`, so
`s.db.get`/`edit`/`del` on that column answer HTTP 400 `Missing param: field_value` rather
than matching nothing. Declare `f.tableRef(users, { required: true, default: 0 })` for
"not set yet" — `s.db.get({ fieldName: "driver", fieldValue: c.int(0) })` matches no row and
binds `null`, which is the answer the null was reaching for. `export()` warns on a literal
`c.null()` in that slot.
An `f.vector(size)` column is SEARCHED through `s.db.query`'s `eval` pipeline, not through
any `SearchOp`: give the table `index: [{ type: "vector", fields: [{ name: "embedding", op:
"vector_cosine_ops" }] }]`, then rank with a distance filter + a sort on its alias (see
`s.db.query` → `eval`). Without that pairing the column stores and indexes but nothing
queries it.
`{ array: true }` makes any `f.*` scalar a **list column** — `f.text({ array: true })`
surfaces as `string[]` in `InferRow<typeof table>` (the column analogue of `input.list`).
A **column `default` must stay within the BMP** — a 4-byte character (codepoint > U+FFFF,
e.g. an emoji) is mangled into invalid UTF-8 by the engine's default pipeline and is rejected
at export rather than 500ing at deploy (Postgres `22021`); BMP defaults (accents, `€`, most
CJK) are fine, or put the value on an `input.<type>({ default })`, applied at runtime bind.
`input.*` mirrors `f.*` — every column type below is
a legal input (scalars, files `input.image/video/audio/attachment`, `input.geo.*`,
`input.vector(size)`, `input.tableRef(table)`, `input.object(children)`), plus
`input.dbLink(table)` is the odd one: ONE entry that EXPANDS into one input per
COLUMN of the linked table, so read them by column name (`inp("email")`), never by
the entry's own name. `hidden: ["created_at"]` drops columns from that expansion.
`input.list(element)` for arrays — wrap any element constructor, e.g.
`input.list(input.text())` or `input.list(input.object({ id: f.int() }))`. Prefer the
typed forms over `input.json()` when the shape is known.
**Typed inputs validate/coerce on bind, before your stack runs** — so reach for the
specific type instead of hand-rolling checks. `input.email({ required: true })` rejects a
malformed address with a 400 (and trims; add `methods: ["lower"]` to downcase) — no
`regex_matches` needed; `input.int`/`input.decimal`/`input.uuid`/`input.enum([...])`/`input.date`
likewise reject or coerce bad input at the boundary. Drop to `input.text` + `s.precondition`
only for rules no type expresses (README: "Validate input at the boundary").
⚠ `input.url` is NOT one of them — there is no engine `url` type, so it stores as `text`
and validates NOTHING: a `javascript:`/`data:` URL type-checks, imports, and binds. It
names intent and carries the `text` methods, nothing more. When the value gets navigated
to, check the scheme in the stack. It is INPUT ONLY — there is no `f.url` column.
⚠ `s.precondition`'s `error` must be a TAGGED value — `c.text("…")`, not a bare string.
The engine falls back to the generic "Precondition failed." whenever it reads an empty or
non-scalar message, and a bare string lands there, so the client never sees your text. The
`error_type` → HTTP status mapping is correct either way; only the message is lost. The bare
form stays accepted so a pulled workspace round-trips, not as a spelling to choose.
`error_type` IS how a stack sets a response status: `badrequest`/`inputerror` → 400, `unauthorized` → 401, `accessdenied` → 403, `notfound` → 404, `toomanyrequests` → 429, `standard` → 500.
Normalizing transforms run on bind too — put `trim`/`lower`/`upper` on the input's `methods`
so `inp("name")` reads already-normalized; don't reroll `var $x = inp("name")|trim` in the stack.

- `f.text` — methods: alphaOk, digitOk, lower, max, min, ok, pattern, startsWith, trim, upper
- `f.int` — methods: max, min
- `f.decimal` — methods: max, min
- `f.bool`
- `f.uuid`
- `f.date`
- `f.email` — methods: lower, trim
- `f.password` — methods: max, min, minAlpha, minDigit, minLowerAlpha, minSymbol, minUpperAlpha, salt
- `f.json`
- `f.timestamp` (stored `epochms`)
- `f.image` (stored `blob_img`)
- `f.video` (stored `blob_video`)
- `f.audio` (stored `blob_audio`)
- `f.attachment` (stored `blob`)
- `input.file` — INPUT ONLY (no `f.` form)
- `input.dbLink` (stored `<tableGuid>_mvpschema`) — INPUT ONLY (no `f.` form)
- `f.geo.point` (stored `geo_point`)
- `f.geo.multipoint` (stored `geo_multipoint`)
- `f.geo.linestring` (stored `geo_linestring`)
- `f.geo.multilinestring` (stored `geo_multilinestring`)
- `f.geo.polygon` (stored `geo_polygon`)
- `f.geo.multipolygon` (stored `geo_multipolygon`)
- `f.enum`
- `f.vector` — methods: max, min
- `f.object` (stored `obj`)
- `f.tableRef` (stored `int`) — methods: max, min
