# Defining your schema

> Declare collections, fields, relations, and access rules with the Go DSL.

_Updated: 2026-06-10_

Your data model is Go code. You declare **collections** (think tables) and their
fields with a fluent builder, register them from an `init()`, and Railbase
generates the storage, the REST API, the realtime topics, and the typed client
from that one definition.

## A collection

```go
package schema

import s "github.com/railbase/railbase/pkg/railbase/schema"

var Posts = s.Collection("posts").
    Field("title", s.Text().Required().MaxLen(200)).
    Field("body", s.RichText()).
    Field("status", s.Status("draft", "published").Default("draft")).
    Field("author", s.Relation("_users").Required().CascadeDelete()).
    Index("idx_status", "status").
    ListRule("status = 'published' || @request.auth.id != ''").
    ViewRule("status = 'published' || @request.auth.id != ''").
    CreateRule("@request.auth.id != ''").
    UpdateRule("@request.auth.id = author").
    DeleteRule("@request.auth.id = author")

func init() { s.Register(Posts) }
```

`id`, `created`, and `updated` are added automatically — don't declare them.

## Fields

Beyond the primitives — `Text() Number() Bool() Date() Email() URL() JSON()
Select(...) MultiSelect(...) File() Files() Relation(t) Relations(t) Password()
RichText()` — the DSL ships a large library of **domain types** that carry
validation and rendering: `Currency() Finance() Money… Address() Tel()
PersonName() Slug() SequentialCode() Status(...) Priority() Rating() Tags()
Country() Percentage() Coordinates() IBAN() Duration()` and more.

Chain modifiers on any field:

```go
s.Finance().Required().Min("0.01")          // money — use Finance, never Number().Float()
s.Slug().From("title").Unique()
s.SequentialCode().Prefix("PO-").Pad(5)      // PO-00001, PO-00002, …
s.Text().FTS()                               // full-text searchable
```

> [!IMPORTANT]
> Use `Finance()` for monetary values, not `Number().Float()` — floats lose
> precision on money. The domain types exist so you don't reinvent these rules.

## Indexes & constraints

```go
.Index("idx_status", "status")
.UniqueIndex("idx_po_number", "tenant_id", "po_number")
```

Row-level **checks** (invariant expressions evaluated on every write) are
defined per collection in the admin schema editor; the Go DSL's `.Checks(...)`
seam exists but its `CheckSpec` argument type isn't exported from
`pkg/railbase/schema` yet.

## Access rules

Every collection has five rules — `ListRule`, `ViewRule`, `CreateRule`,
`UpdateRule`, `DeleteRule` — written in a small expression language
(`@request.auth.id != ''`, `@request.auth.id = author`, `status = 'published'`).

> [!CAUTION]
> **Secure by default.** An operation with **no rule set is locked** (server-only),
> not public. Call `.PublicRules()` or set explicit rules to open an endpoint.
> Forgetting a rule fails closed, never open.

## Mixins

Common behaviours attach with one call:

| Mixin | Adds |
|---|---|
| `.Tenant()` | `tenant_id` + row-level isolation for multi-tenant apps |
| `.SoftDelete()` | soft delete + restore (records hidden, not destroyed) |
| `.Audit()` | append to the tamper-evident audit log on every change |

> [!NOTE]
> **End-user sign-in: use the built-in `_users`.** The core guarantees a built-in
> auth-collection named **`_users`** (email, password_hash, verified, token_key,
> last_login_at, name) — it is the default owner for API tokens / SCIM and the
> target of `/api/collections/_users/auth-signup` and `auth-with-password`. You
> don't declare it; relate to it with `s.Relation("_users")`. To own its shape,
> register your own `s.AuthCollection("_users")` and the core uses yours instead.
>
> The auth endpoints exist for **registered** auth collections only — nothing
> registers a plain `users` (no underscore), so `/api/collections/users/auth-*`
> returns 404 unless your schema declares it; prefer the built-in `_users` (or a
> distinct name like `staff`) to avoid confusion with the pre-`_users` holdover.
> `.AuthCollection()` and `.Tenant()` can't be combined — the validator
> rejects it.

After editing the schema, generate and apply a migration — see
[Migrations](migrations).
