# Rip Schema
> **One keyword. A validator, a class, an ORM, migrations, and a live API contract — from a single declaration.**
In a typical TypeScript application the shape of a `User` is described five
times. Once as a Zod schema for input validation. Once as a Prisma model for
the database. Once as a generated TypeScript type for the editor. Once as a
DTO class for API projections. Once more as an OpenAPI document for clients.
Every change has to be propagated across all five. Every divergence becomes
a bug.
Rip Schema collapses all of them into one declaration:
```coffee
User = schema :model
name! string, 1..100
email! email @unique
phone? ~:phone
@timestamps
@has_many Order
identifier: ~> "#{@name} <#{@email}>"
beforeValidation: -> @email = @email.toLowerCase()
```
From that single declaration, the language gives you:
- a **runtime validator** — `User.parse(data)` / `.safe()` / `.ok()` (plus
async variants), with strict wire coercion (`~integer`, `~:phone`),
cross-field refinements, and discriminated unions
- a **generated class** with your methods and `~>` computed getters bound as prototype getters
- a **TypeScript type** — `ModelSchema`, automatic, no codegen step
- an **async ORM** — `User.find! 1`, `User.where(active: true).all!`, `user.save!`
- **transactions** — `schema.transaction! ->` with ambient propagation,
rollback, and `afterCommit` hooks
- **query economics** — `User.includes(:orders)` eager loading, composable
`@scope`s, batch writes; no N+1 by default
- **migrations** — `rip schema make/migrate` diffs the declared models
against the live database and writes plain-SQL migration files
- **a wire contract** — `User.toJSONSchema()`, and rip-server routes that
validate with a schema contribute to a generated `GET /openapi.json`
- **schema algebra** — `User.omit("password")` produces a correctly-typed derived shape
Schemas are runtime values. You pass them around, export them, derive from
them, reference them anywhere an expression is valid. They're not a separate
language — they're a vocabulary inside Rip.
This guide is the canonical reference. Part I teaches the concepts and
syntax. Part II is reference tables you'll look up. Part III covers
architecture for contributors.
---
# Contents
## Part I — Using Rip Schema
1. [What Rip Schema is](#1-what-rip-schema-is)
2. [A quick tour](#2-a-quick-tour)
3. [Schemas vs types](#3-schemas-vs-types)
4. [The six kinds](#4-the-six-kinds)
5. [Body syntax](#5-body-syntax)
6. [The runtime API](#6-the-runtime-api)
7. [What `.parse()` returns by kind](#7-what-parse-returns-by-kind)
8. [`:model` — the ORM](#8-model--the-orm)
9. [Transactions & data integrity](#9-transactions--data-integrity)
10. [Query economics](#10-query-economics)
11. [Adapters](#11-adapters)
12. [DDL & schema evolution](#12-ddl--schema-evolution)
13. [Wire contracts — JSON Schema & OpenAPI](#13-wire-contracts--json-schema--openapi)
14. [Mixins](#14-mixins)
15. [Schema algebra](#15-schema-algebra)
16. [Shadow TypeScript](#16-shadow-typescript)
17. [SchemaError and diagnostics](#17-schemaerror-and-diagnostics)
18. [Common mistakes](#18-common-mistakes)
19. [Recipes](#19-recipes)
20. [What's not here yet](#20-whats-not-here-yet)
## Part II — Reference
21. [Capability matrix](#21-capability-matrix)
22. [Field types](#22-field-types)
23. [Directives](#23-directives)
24. [Hook reference](#24-hook-reference)
25. [Constraints](#25-constraints)
26. [Relations](#26-relations)
27. [Design invariants](#27-design-invariants)
## Part III — Architecture
28. [Runtime architecture](#28-runtime-architecture)
29. [Compiler integration](#29-compiler-integration)
30. [FAQ](#30-faq)
---
# Part I — Using Rip Schema
## 1. What Rip Schema is
A *schema* in Rip is a runtime value that describes data. You create one with
the `schema` keyword and an optional `:kind` symbol:
```coffee
SignupInput = schema; email! # default :input
Role = schema :enum; :admin; :user
User = schema :model; name!
```
Every schema is a real JavaScript object at runtime. It has methods
(`.parse`, `.safe`, `.ok`, plus ORM methods on `:model` and algebra methods
on derived shapes). It carries its own metadata (fields, constraints,
relations, hooks) and lazily builds the validator plan, ORM plan, and DDL
plan on first use.
Because schemas are values, you pass them around, export them, derive from
them, and reference them anywhere an expression is valid. They're not a
separate language — they're a vocabulary inside Rip.
**Why schemas exist as a distinct thing:** most applications need one
coherent description of each data shape for three audiences:
1. **Runtime** — validate external input, produce clean typed values
2. **Database** — issue migrations, run queries, hydrate rows
3. **Editor / compile time** — autocomplete, typecheck, hover docs
Rip Schema gives all three from a single declaration. Write the shape once
and the language handles the rest.
### What this replaces
In the JavaScript and TypeScript ecosystem, covering the same surface area
requires stitching together several independent libraries — each with its own
schema dialect, its own types, its own runtime, its own failure modes:
| Concern | Typical TypeScript stack | Rip Schema |
| --------------------------- | ----------------------------------- | ------------------------------------ |
| Input validation | Zod, Yup, Joi, io-ts, Valibot | `schema :input` + `.parse/.safe` |
| Domain objects with logic | hand-written classes + `zod.infer` | `schema :shape` |
| Database models | Prisma, Drizzle, TypeORM, Sequelize | `schema :model` |
| Migrations / DDL | Prisma migrate, Drizzle Kit, knex | `Model.toSQL()` + `rip schema make/migrate` |
| API projections / DTOs | `.pick` / `.omit` on Zod + class | `Model.pick/.omit/.partial/.extend` |
| Static types for the editor | Inferred from every library above | Automatic shadow TS — no codegen |
| Fixed value sets | TS `enum` or string unions | `schema :enum` (runtime + static) |
| Shared field groups | Intersection types + manual merge | `schema :mixin` + `@mixin Name` |
The equivalent TypeScript stack for a single model is roughly:
```ts
// validator.ts
export const UserInput = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})
// schema.prisma
model User {
id Int @id @default(autoincrement())
name String
email String @unique
orders Order[]
}
// user.ts
export class User {
constructor(public data: Prisma.User) {}
get identifier() { return `${this.data.name} <${this.data.email}>` }
}
// dto.ts
export const UserPublic = UserInput.omit({ email: true })
export type UserPublic = z.infer
```
Four files. Three dialects (Zod, Prisma DSL, TS). Two codegen steps. Drift
between them is a category of bug that only exists because the description
lives in more than one place.
The Rip Schema equivalent is the five-line `:model` declaration in the
opening of this document. The validator, the database model, the class with
its derived property, and the `UserPublic` DTO all fall out of that one
declaration — as runtime values, with full editor support, without codegen.
This is not incremental. One keyword replaces an entire category of tooling.
---
## 2. A quick tour
### Input validation
```coffee
SignupInput = schema
email! email
password! string, 8..100
age? integer, 18..120
# Throws SchemaError on failure, returns a cleaned value on success
input = SignupInput.parse rawJson
# Structured result, no throwing
result = SignupInput.safe rawJson
# → {ok: true, value, errors: null} or {ok: false, value: null, errors: [...]}
# Fast boolean check
valid = SignupInput.ok rawJson
```
### Validating a list
`.array` turns any schema into a *list-of-that* schema, exposing the same
validation family — the common shape for an API response (one request, many
records):
```coffee
Product = schema :shape
id! integer
name! string
items = Product.array.parse rawJson # → Product[]; throws on any bad item
result = Product.array.safe rawJson # → {ok, value: Product[], errors}
allGood = Product.array.ok rawJson # → boolean
```
A non-array input fails fast with an error naming what it got — so an enveloped
`{ items: [...] }` passed whole, or a renamed key, is obvious — and each item
failure carries its `[index]` so a bad record is locatable. `parseAsync` /
`safeAsync` / `okAsync` mirror the async variants.
### A shape with behavior
```coffee
Address = schema :shape
street! string, 1..200
city! string
state! string, 2..2
zip! string, /^\d{5}$/
# Computed getters (~>) read instance fields and return derived values
full: ~> "#{@street}, #{@city}, #{@state} #{@zip}"
# Methods (->) run with `this` bound to the instance
normalize: ->
@city = @city.trim()
@
a = Address.parse street: "123 Main", city: " Palo Alto ", state: "CA", zip: "94301"
a.full # "123 Main, Palo Alto, CA 94301" — using the raw city
a.normalize()
a.city # "Palo Alto" — trimmed
```
### An enum
```coffee
Status = schema
:pending 0
:active 1
:done 2
Status.parse "pending" # 0 — name resolves to value
Status.parse 0 # 0 — value resolves to value
Status.ok "unknown" # false
```
### A DB-backed model
```coffee
User = schema :model
name! string, 1..100
email! email @unique
@timestamps
@has_many Order
identifier: ~> "#{@name} <#{@email}>"
beforeSave: -> @email = @email.toLowerCase()
Order = schema :model
total! integer
@belongs_to User
@timestamps
# DDL for migration (works with or without the ORM adapter configured)
sql = User.toSQL()
# ORM operations (async — `!` is the dammit operator, Rip's await)
user = User.create! name: "Alice", email: "ALICE@EXAMPLE.COM"
found = User.find! user.id
orders = user.orders! # has_many relation → Order[]
owner = orders[0]?.user! # belongs_to relation → User
```
### The production layer
The same declaration carries the whole data layer — atomic writes,
constant-query-count reads, schema evolution, and a live API contract:
```coffee
# Transactions — ambient propagation; model code inside is unchanged
order = schema.transaction! ->
o = Order.create! userId: user.id, total: 100
user.name = "Alice Q."
user.save!
o # block value = transaction value
# Eager loading + scopes — a list view in three queries, any row count
users = User.includes(orders: :user).where(active: true).all!
# Migrations — diff the declared models against the live database
# rip schema status applied / pending / drift + the classified plan
# rip schema make NAME write migrations/NNNN_NAME.sql from the diff
# rip schema migrate apply pending files, checksummed history
# Wire contract — schema-validated routes feed a generated OpenAPI doc
post '/signup', input: SignupInput, ->
{ welcome: @input.email } # 400 + issues automatic
```
Each of these has its own section: [§9](#9-transactions--data-integrity),
[§10](#10-query-economics), [§12](#12-ddl--schema-evolution),
[§13](#13-wire-contracts--json-schema--openapi).
### Schema algebra — derive new shapes
```coffee
UserPublic = User.omit "email" # → Schema>
UserCreate = User.pick "name", "email" # → Schema>
UserUpdate = User.partial() # → Schema>
AdminUser = User.extend (schema :shape
permissions! string[])
```
Derived schemas are always `:shape`. **Field semantics survive** —
type, constraints, inline transforms all carry through. **Instance
behavior is dropped** — methods, computed getters (`~>`), eager
derived fields (`!>`), hooks, and ORM methods don't carry through.
Algebra is a structural operation on fields, not a behavioral one.
### Idiomatic shorthands
Most production code uses a few syntactic sugars. All are optional;
the Quick Tour above works as written.
```coffee
# Open-ended ranges. With `!` (required), `..N` implies `min=1`.
User = schema
firstName! ..50 # required, 1..50 chars
bio? text # `text` is unbounded by design
phone? 1..20
# File-level default cap for uncapped VARCHAR-like fields.
schema.defaultMaxString = 500
Profile = schema :model
name! # → {min: 1, max: 500}
email! email @unique # → {max: 500}
bio? text # → uncapped (text opts out)
# One-line small shapes, plus a registered schema used as a field type.
Address = schema :shape; street? ..200; city? ..100; zip? ..10
Order = schema :shape
address! Address # validation recurses; errors like "address.street"
```
See §5 for body-syntax details, §22 for nested type references, and
§25 for constraint and pragma rules.
---
## 3. Schemas vs types
Rip has two ways to describe data: the `type` / `interface` / `enum`
compile-time system and the `schema` runtime system. They don't compete —
they handle different concerns.
| Feature | `type` / `interface` / `enum` | `schema` |
| ------------------------------ | ----------------------------- | ------------------------- |
| Exists at runtime | No | Yes |
| Validates data | No | Yes |
| Produces values / instances | No | Yes |
| Generates SQL / ORM | No | `:model` only |
| Used by shadow TS | Yes | Yes |
| Supports `.parse()` | No | Yes |
| Erased from JS output | Yes | No |
**Rules of thumb:**
- Use `type` / `interface` for shapes you want the editor and `rip check` to
understand, but where the data is already trusted at runtime — internal
function signatures, intermediate values, return types.
- Use `schema :input` when data enters your program from outside (HTTP body,
`JSON.parse`, stdin, query params) and you need runtime guarantees.
- Use `schema :shape` when you want the same runtime guarantees plus
behavior (methods, computed getters) — for example a `Point` or `Address`
value that carries derived computations.
- Use `schema :model` for DB-backed entities. You get the validator, the
ORM, the migration DDL, the relation methods, and the shadow TS all from
one declaration.
- Use `schema :enum` when the set of values is fixed and runtime membership
matters. The compile-time `enum` keyword still exists for cases where you
only need the type and don't need runtime validation.
- Use `schema :mixin` when two or more schemas share a field group.
A schema is never the wrong tool for runtime data. A `type` is never the
wrong tool for purely compile-time descriptions. When both apply, use the
schema — it includes everything the type would give you (via shadow TS)
plus the runtime dimension.
---
## 4. The six kinds
Every schema has one of six kinds, selected by a `:symbol` after the
`schema` keyword:
```coffee
input = schema # default — :input
shape = schema :shape
enum = schema :enum
mixin = schema :mixin
model = schema :model
union = schema :union
```
The kind determines which body forms are legal, what `.parse()` returns,
and whether ORM / DDL surface is active.
### `:input`
A field validator. Body allows fields and `@mixin` only. `.parse(data)`
returns a plain validated object. No behavior, no persistence.
```coffee
SignupInput = schema
email! email
password! string, 8..100
```
### `:shape`
A validator with behavior. Body accepts every field form — including
inline transforms (`name! type, -> body`) and the three colon-anchored
forms: methods (`name: -> body`), computed getters (`name: ~> body`),
and eager-derived fields (`name: !> body`). `@mixin` is the one
directive allowed. `.parse(data)` returns a class instance — declared
fields and eager-derived are own enumerable properties, methods live
on the prototype, computed getters are non-enumerable prototype
getters.
```coffee
Address = schema :shape
street! string
city! string
full: ~> "#{@street}, #{@city}"
```
`:shape` cannot carry lifecycle hooks (there's no lifecycle) or ORM-bound
directives (`@timestamps`, `@belongs_to`, `@has_many`, `@has_one`,
`@softDelete`, `@index`). Known hook names like `beforeSave` used on
`:shape` are just methods — no lifecycle binding.
### `:enum`
A fixed set of values. Members are `:symbol` literals; valued members add
a space-separated literal.
```coffee
Role = schema # :enum kind inferred from :symbol body
:admin
:user
:guest
Status = schema
:pending 0
:active 1
:done 2
```
`.parse()` accepts either the member name or its value and returns the
value. For bare enums (no values), members map to their own name strings.
### `:mixin`
A reusable field group. Non-instantiable — you can't `.parse()` or `.ok()`
a mixin. Other schemas pull the fields in with `@mixin Name`.
```coffee
Timestamps = schema :mixin
createdAt! datetime
updatedAt! datetime
User = schema :model
name! string
@mixin Timestamps # contributes createdAt + updatedAt
```
Mixins are fields-only. Methods, computed, hooks, and non-`@mixin`
directives inside a mixin body are compile errors.
### `:union`
A discriminated union over registered schemas. The body names the
discriminator field (`@on :field`, required — untagged unions are a
non-goal: they make dispatch O(n) and error messages incoherent) and
two or more constituent schemas, one per line:
```coffee
ClickEvent = schema :shape
kind! "click" # single-literal constant — the tag
x! integer
y! integer
ScrollEvent = schema :shape
kind! "scroll"
delta! integer
Event = schema :union
@on :kind
ClickEvent
ScrollEvent
Event.parse(kind: "click", x: 1, y: 2) # → ClickEvent instance
Event.safe(kind: "hover") # → {field: "kind", error: "union",
# message: "expected one of click | scroll"}
```
- Each constituent must declare the discriminator as a string-literal
type, all values distinct across the union — checked at first parse
(lazy, consistent with registry resolution), with the colliding
constituents named in the error.
- `.parse(data)` reads the discriminator and dispatches O(1) to the
matching constituent's validator; the result is that constituent's
instance (shapes keep their behavior).
- Usable as a field type like any registered schema: `events! Event[]`.
- If any constituent is async-validating (`@ensure!`), the union is too
— use `parseAsync!` / `safeAsync!` / `okAsync!`.
- Schema algebra on a union throws — distribute-vs-intersect has no
obviously-right answer, so v1 defers; derive from a constituent.
- Shadow TS: `type Event = ClickEvent | ScrollEvent;` — narrowing via
the discriminator works natively.
### `:model`
A DB-backed entity. Everything `:shape` offers (all field forms,
methods, computed, eager-derived, inline transforms), plus: relations,
lifecycle hooks, the full ORM surface (`find`, `where`, `create`,
`save`, `destroy`, `toSQL`), and a process-global registry entry.
Eager-derived fields re-run on DB hydrate so they appear on instances
returned from `.find()` / `.where()` exactly as they do on parsed
instances.
```coffee
User = schema :model
name! string
email! email @unique
@timestamps
@has_many Order
greet: -> "Hello, #{@name}!"
beforeValidation: -> @email = @email.toLowerCase()
```
---
## 5. Body syntax
Schema bodies are intentionally not general Rip code — they're declarative.
Only these line forms are allowed; anything else is a compile error with
a schema-specific diagnostic.
### Field
```coffee
name[!|?|#]* [type] [range] [default] [regex] [attrs] [, -> transform]
```
Modifiers:
| Modifier | Meaning |
| -------- | -------- |
| `!` | required |
| `?` | optional |
`!` and `?` are *shape* modifiers — they apply to every schema kind, and
order doesn't matter. No modifier means "present but not required" —
equivalent to `?` for validation purposes.
Uniqueness is **not** a modifier — it's a *storage* constraint (only
meaningful on `:model`), spelled `@unique`: inline as `email! email @unique`,
or as a directive `@unique :email` (composite: `@unique [:partnerId, :mrn]`).
**Type is optional** — when omitted, the field defaults to `string`. Type
expressions accept:
- a type identifier (`string`, `email`, `integer`, …)
- an array suffix (`string[]`)
- a string-literal union (`"M" | "F" | "U"`) — value must be one of the
listed members; no mixing with base types. A single literal
(`kind! "click"`) is a constant field — the building block of
[discriminated unions](#union)
- a `~`-prefixed coercible type (`~integer`, `~number`, `~boolean`,
`~date`) — "coerce, then validate" (see below)
```coffee
Example = schema
name! # required string (default type)
tags! string[] # required array of strings
email! email @unique # required, unique, email-format-validated
bio? text, 0..1000 # optional text, 0-1000 chars
role? string, ["user"] # optional, default "user"
status string, [:draft] # default :draft — same as ["draft"]
zip! string, /^\d{5}$/ # regex-validated
sex? "M" | "F" | "U" # literal union
priority "low" | "med" | "high", [:med] # literal union + default
```
### Coercion types (`~type`)
Wire data arrives as strings; a `~` prefix on a coercible built-in
means the value converts through a strict table before validation:
```coffee
SearchParams = schema
page? ~integer # "42" → 42; "abc" → {error: 'coerce'}
minPrice? ~number # "19.95" → 19.95
active? ~boolean # "true"/"1"/1 → true; "false"/"0"/0 → false
since? ~date # ISO-8601 string or epoch ms → Date
```
- Coercion tables are strict and documented: `~integer` accepts
integral strings and integral numbers, rejects `NaN` and `"12.5"`;
`~boolean` accepts exactly the six tokens above; `~date` accepts
ISO-8601 strings and finite epoch numbers. Failed coercion is
`{error: 'coerce'}` — distinct from `{error: 'type'}`, because "looked
like wire data but didn't convert" is a different mistake than "wrong
shape entirely".
- Constraints apply **after** coercion — `age? ~integer, 18..120` range-
checks the coerced number.
- Coercion is field semantics, so it **survives algebra** (`.pick`,
`.omit`, …) like transforms do, and is **skipped on DB hydrate**
(rows arrive canonical).
- `~` doesn't combine with a `->` transform (the transform IS manual
control of the same step), doesn't apply to arrays, and only covers
the four wire-friendly built-ins — everything else wants a named
coercer or an explicit transform.
### Named coercers (`~:name`)
A `~:symbol` in the type slot coerces through the **named-coercer
registry** — and `@rip-lang/validate` (the zero-dependency,
browser-safe package behind `@rip-lang/server`'s `read()` vocabulary)
registers every battle-tested wire normalizer (`id`, `money`, `ssn`,
`phone`, `name`, `date`, `state`, `zipplus4`, `slug`, `ids`, …) there
at load, so they all work in a schema field:
```coffee
Patient = schema :model
chart! ~:id, 1..99999 # "42" → 42 (integer; constraint after coercion)
ssn? ~:ssn # "123-45-6789" → "123456789"
phone? ~:phone # "8016542000" → "(801) 654-2000"
state? ~:state # "ut" → "UT"
dob? ~:date # normalized "YYYY-MM-DD" string
amount? ~:money # "$1,234.50" → 123450 (dollars in → integer cents)
kids? ~:ids # "3, 1, 2, 2" → [1, 2, 3]
```
- The normalizer behind `read 'dob', 'date'` is the *same function*
behind `dob? ~:date` — one vocabulary, two call sites.
`registerValidator` registers both; schema-only code can use
`schema.registerCoercer name, fn` directly.
- The vocabulary is **isomorphic**: importing `@rip-lang/server` loads
it on the server, and a side-effect `import '@rip-lang/validate'` in
any component file loads it in the browser bundle — so a schema with
`~:ssn` parses identically on both sides of the wire.
- A coercer returning `null`/`undefined`/`false` fails the field with
`{error: 'coerce', message: " is not a valid "}`. A
coercer that isn't registered at parse time is a **config error**
(fail loud), not a validation failure.
- Output types: the shipped names carry static output types for shadow
TS and DDL (`~:id` → `number`/INTEGER, `~:money` → `number`/INTEGER (cents),
`~:ids` → `number[]`, `~:ssn` → `string`, …). Custom-registered
names type as `any` — use an explicit transform when you need a
precise static type.
- Note the namespaces differ: `~date` (built-in) coerces to a `Date`
instance; `~:date` (named) normalizes to a `"YYYY-MM-DD"` string —
exactly what `read 'x', 'date'` returns.
### Inline field transform
A `-> body` at the end of a field line derives the field's value from the
raw input. `it` inside the body refers to the **whole raw input object**
(not just the field's wire value), so transforms can pick from a
differently-named key, compose across multiple inputs, or coerce types:
```coffee
Imported = schema
id! -> it.Id # remap PascalCase input
displayName! -> it.DisplayName
shippedAt? date, -> new Date(it.shippedAt) # wire string → Date
slug! -> "#{it.FirstName}-#{it.LastName}".toLowerCase()
email! email @unique, -> it.email.toLowerCase().trim() # normalize + validate
```
Rules:
- **Declared type is the OUTPUT type** — the validator checks the
transform's *return value*. The input shape is implicit.
- **Transform is terminal** on the field line — nothing follows `->`.
- **Comma before `->` is required** whenever anything precedes it on
the line (type, range, regex, default, attrs). The comma is a
structural boundary between the field declaration and the
transform, not an argument separator — without it, lines like
`email! email -> fn` misleadingly suggest `email` is an input to
the arrow. The bare form `name! -> fn` (nothing before the arrow
except the name and modifiers) parses comma-less because there's
nothing to elide. This is unlike Rip's general `get '/path' ->`
rule: in a function call the arrow is the last argument; in a
schema field it's a distinct semantic slot.
- **Runs once at `.parse()`**, never on DB hydrate (rows arrive
canonical).
- **Survives algebra** (`.pick`, `.omit`, etc.) — field semantics, not
instance behavior. A picked schema may still read raw-input keys not
in its output shape.
- **Errors** in the transform wrap as `{error: 'transform'}` issues.
### Directive
```coffee
@name [args]
```
Directives attach behavior that isn't a field. The set depends on the
kind (see [§23](#23-directives)). Examples:
```coffee
@timestamps # adds createdAt/updatedAt columns (:model only)
@softDelete # adds deletedAt, soft-deletes on .destroy() (:model only)
@index [role, active] # composite index (:model only)
@idStart 10001 # seed for the auto-id sequence (:model only, .toSQL())
@belongs_to Organization? # nullable FK (:model only)
@has_many Order # has-many relation (:model only)
@mixin Timestamps # pull in a mixin's fields (any fielded kind)
```
### Method
```coffee
name: -> body
name: (params) -> body
```
Thin-arrow method bound on the generated class prototype. `this` is the
instance. Parameters are optional and may carry Rip type annotations,
which flow into shadow TS — a fully-annotated method gets its complete
signature (typed params, `this`, and the inferred return) instead of
`(...args: any[]) => unknown`:
```coffee
greet: -> "Hello, #{@name}!"
add: (other: Money) ->
Money.parse amount: @amount + other.amount, currency: @currency
# shadow TS: add(this: Money, other: Money): Money
beforeSave: ->
@email = @email.toLowerCase()
@slug = @name.toLowerCase().replace(/\s+/g, '-')
```
Parameters are method-only — lifecycle hooks, computed getters (`~>`),
and eager-derived fields (`!>`) are accessor-shaped and reject them.
For `:model`, method names matching known [hook
names](#24-hook-reference) bind to the lifecycle; on other kinds those
names are just methods.
### Computed getter (lazy)
```coffee
name: ~> body
```
Reactive-style arrow, emitted as a non-enumerable prototype getter via
`Object.defineProperty(proto, name, {get: fn})`. **Re-evaluates on every
access** — reflects the current instance state. Excluded from DDL and
persistence.
```coffee
full: ~> "#{@street}, #{@city}"
identifier: ~> "#{@name} <#{@email}>"
isAdmin: ~> @role is 'admin'
```
### Eager-derived field
```coffee
name: !> body
```
Materialized-once derivation. Runs during `.parse()` (and on DB hydrate)
after all declared fields are populated. Stored as an **own enumerable
property**, so it appears in `Object.keys(inst)` and `JSON.stringify(inst)`.
Excluded from DDL and persistence — re-computed on hydrate from the
declared fields.
```coffee
Person = schema :shape
firstName! string
lastName! string
fullName: !> "#{@firstName} #{@lastName}".trim()
slug: !> @fullName.toLowerCase().replace(/\s+/g, '-')
```
Declaration order matters — an `!>` can read earlier declared fields
and earlier `!>` values, but not later ones.
### `!>` vs `~>` — pick the right one
They look similar and come from the same grammar family, but they
behave very differently after mutation. This is the single most
important distinction in the schema body:
| | `name: !> body` (eager) | `name: ~> body` (lazy) |
|---|---|---|
| Fires | once at parse / hydrate | every access |
| Stored as | own enumerable property | non-enumerable prototype getter |
| `Object.keys(inst)` | includes it | does not |
| `JSON.stringify(inst)` | includes it | does not |
| After `inst.field = x` | **stale** — does not recompute | **live** — reflects the new value |
| Use for | serialized/materialized derivations, labels that ship over the wire | computed properties that should always reflect current state |
> **Important**: an `!>` field will appear *stale* if you mutate a
> dependency afterwards. That's by design — it's a snapshot, not a
> reactive binding. When in doubt, pick `~>` for live values and save
> `!>` for cases where the materialization is itself the goal
> (JSON payload shape, computed labels at construction time).
### Refinement (`@ensure`)
Schema-level cross-field invariants. Where field constraints check one
value against its own type and range, `@ensure` checks the whole object
against a predicate — "these fields together must satisfy this rule."
Two forms, same semantics:
```coffee
# Inline — a single invariant
@ensure "name and email must differ", (u) -> u.name isnt u.email
# Array — multiple invariants in one block
@ensure [
"end after start", (u) -> u.start < u.end
"complex rule", (u) ->
normalized = u.name.toLowerCase()
not RESERVED_NAMES.includes(normalized)
]
```
An optional `:field` symbol between the message and the predicate
**attributes the failure to a specific field**, so form libraries can
attach the error to the right input (without it, the issue stays
schema-wide with `field: ''`):
```coffee
@ensure "passwords must match", :password2, (u) -> u.password is u.password2
# fails as {field: "password2", error: "ensure", message: "passwords must match"}
```
**Async refinements** use the dammit operator — `@ensure!` — for
predicates that await a database or network check:
```coffee
Signup = schema
email! email
@ensure! "email already registered", :email, (u) ->
not await User.where(email: u.email).first()
```
A schema with ≥1 `@ensure!` is **async-validating**: sync `.parse` /
`.safe` / `.ok` throw immediately ("use parseAsync!/safeAsync!/okAsync!")
— no silent promise-leak, no sometimes-sync API. Sync refinements run
first (cheap before expensive); async refinements then run
**concurrently** (`Promise.all`) with all results collected in
declaration order, preserving the no-short-circuit rule. A rejected
async predicate counts as failed with the declared message. Async
refinements are skipped on hydrate and dropped by algebra, same as
sync ones.
Both forms compile to the same internal representation; use whichever
reads cleanest for the case at hand. The inline form is nicer for
one-offs; the array form keeps related invariants visually grouped.
Rules:
- **Message is required** and must be a string literal. It comes first
(before the fn) and is the only thing reported when the predicate
fails — write it from the user's perspective, not the developer's.
- **Predicate takes an explicit parameter.** Refinements declare the
object parameter by name (`(u) -> ...`) rather than using implicit
`this`. Makes the contract of "what the predicate sees" visible.
- **Truthy passes, falsy fails.** The predicate's return is coerced to
boolean — any truthy value (object, array, non-zero number, non-empty
string, `true`) passes; any falsy value (`false`, `null`, `undefined`,
`0`, `''`, `NaN`) fails with the declared message.
- **Thrown exceptions fail.** If the predicate throws, the refinement
counts as failed with the declared message — the exception doesn't
propagate. Write safe predicates; this is a guard, not error
recovery.
- **All refinements run.** No short-circuit between refinements —
every predicate runs even if earlier ones failed. Issues collect in
declaration order.
- **Refinements run after field validation.** Predicates can assume
declared fields are typed and defaulted. If any per-field error fires,
refinements don't run at all — their input would be malformed.
- **Refinements run before eager-derived fields.** An `!>` body can
assume the instance satisfies its invariants.
- **Refinements are skipped on DB hydrate.** `.find()`, `.where()`,
`.all()` deliver trusted rows; re-validating predicates on hydrate
would be wasted work.
- **Refinements drop on algebra.** Any derivation (`.pick`, `.omit`,
`.partial`, `.required`, `.extend`) returns a `:shape` without any
refinements from the source. See [§15](#15-schema-algebra).
**Scope**: `:input`, `:shape`, and `:model` accept `@ensure`. `:enum`
and `:mixin` reject it at compile time with a diagnostic pointing at
where to put the invariant instead.
**Issue shape** when a refinement fails:
```js
{ field: '', error: 'ensure', message: 'your declared message' }
```
`field: ''` matches the convention for other schema-level errors
(`enum`, `mixin`, `derived`) — the issue isn't attached to any single
declared field.
### Rules to remember
- Fields use `name type` — **no colon**. `name: type` is a compile error.
- Methods and computed both use `name:` — the colon before the arrow is how
you distinguish them from fields.
- `~>` produces a getter. `->` produces a method.
- A body cannot contain arbitrary statements — only the four forms above
(plus enum members in `:enum`).
- The grammar is whitespace-sensitive: indentation opens the body, dedent
closes it, trailing comma + indent continues a field line onto the next.
### Inline one-liner body
For small sub-shapes — the ones where indented-block ceremony outweighs
the declaration itself — the body can be written inline, with `;` as
the entry separator:
```coffee
Address = schema :shape; street?; line2?; city? ..100; state? ..2; zip? ..10
Billing = schema :shape; type? "client" | "insurance" | "patient"
Money = schema :shape; amount! integer, 0..; currency! 3..3
```
Same grammar as the indented form — every field / directive / enum
form works inline. The emitted `__schema({...})` descriptor is
byte-for-byte identical to the equivalent indented block, so runtime
behavior (parse, safe, ok, algebra) is unchanged.
**What's not allowed inline:**
Method bodies can themselves contain `;`, which would be ambiguous
with the entry separator. So anything with an arrow — `->` (method /
hook / transform), `~>` (computed getter), `!>` (eager-derived) — is
rejected on the inline form with a message pointing to the indented
form:
```coffee
# compile error — point at the indented form:
X = schema :shape; name!; greet: -> @name # ✗ '->' not allowed inline
X = schema :shape; name!; full: ~> @name # ✗ '~>' not allowed inline
X = schema :shape; name!; tag: !> @x # ✗ '!>' not allowed inline
X = schema :shape; id! -> it.Id # ✗ inline transform not allowed
```
An **empty inline body** (`X = schema :shape;` with nothing after
the leading `;`) is also rejected — almost always a typo.
### When to use which form
- **Inline** for small sub-shapes that exist to be referenced from
another schema (`Address`, `Money`, `Coord`, short wire fragments
for external APIs). The whole declaration fits in one visual
row and reads more like a type alias than a class.
- **Indented block** for anything with methods, hooks, computed
getters, `@ensure` refinements, or more than ~5 fields. Column
alignment makes large field lists scannable in a way one-liners
can't.
Rip doesn't enforce a choice; it just makes both cheap.
---
## 6. The runtime API
Every instantiable kind (`:input`, `:shape`, `:enum`, `:model`) exposes
the same three entry points. Different signatures, same contract.
### `.parse(data)`
Validates `data`. Returns a cleaned value. Throws `SchemaError` on
failure.
```coffee
user = SignupInput.parse raw
# on failure:
# throw new SchemaError([...issues], schemaName, schemaKind)
```
### `.safe(data)`
Validates `data`. Returns a structured result — never throws.
```coffee
result = SignupInput.safe raw
# Success:
# {ok: true, value: , errors: null}
# Failure:
# {ok: false, value: null, errors: [{field, error, message}, ...]}
```
`value` on success has the same shape as `.parse()` would return. `errors`
on failure is always a non-empty array.
### `.ok(data)`
Validates `data`. Returns a boolean. Allocates no error arrays — this is
the fast path for filter-style checks.
```coffee
process raw if User.ok raw
```
### `.parseAsync(data)` / `.safeAsync(data)` / `.okAsync(data)`
Async validation entry points. They exist on **every** schema (sync-only
schemas just resolve immediately) and are **required** when the schema
has `@ensure!` async refinements — the sync trio throws on those schemas
rather than sometimes-returning a promise. The dammit operator gives the
idiomatic call:
```coffee
user = Signup.parseAsync! raw
r = Signup.safeAsync! raw
ok = Signup.okAsync! raw
```
### The dammit operator and the ORM
For `:model`, the ORM methods are all genuinely async and `!` is the
canonical form:
```coffee
user = User.find! 1
user.save!
users = User.where(active: true).all!
```
---
## 7. What `.parse()` returns by kind
| Kind | `.parse(data)` returns | `.safe(data).value` is |
| ---------- | ---------------------- | ---------------------- |
| `:input` | Plain object — validated, defaults applied | same |
| `:shape` | Instance of a generated class — fields as enumerable own properties, methods and getters on the prototype | same |
| `:enum` | The member value (or the name string, for bare enums) | same |
| `:model` | **Unpersisted** instance — same structure as `:shape`, but the class also has `save()`, `destroy()`, relation methods, and `_persisted` state | same |
| `:union` | The matching constituent's `.parse()` result — dispatched O(1) on the discriminator | same |
| `:mixin` | **Not instantiable** — `.parse()` throws | N/A |
For `:shape` and `:model`:
- Declared fields are enumerable own properties. `Object.keys(instance)`
lists them.
- Methods are non-enumerable on the prototype (so they don't pollute JSON
serialization or `for…in` iteration).
- Computed getters (`~>`) are non-enumerable prototype getters. They
evaluate on read, never persist.
- For `:model`, internal state (`_dirty`, `_persisted`) is non-enumerable.
---
## 8. `:model` — the ORM
`:model` is where everything comes together. A model declaration gives
you:
- field validation (from `:shape`)
- class instances with methods and computed getters (from `:shape`)
- lifecycle hooks bound by name (12 of them, including transaction-aware
`afterCommit` / `afterRollback`)
- an async ORM — `find`, `where`, `create`, `save`, `destroy`
- relation accessors driven by `@belongs_to` / `@has_many` / `@has_one`
- automatic registration in a process-global registry for cross-module
relation resolution
This section covers the core read/write surface. The rest of the data
layer has its own sections: transactions and constraint translation
([§9](#9-transactions--data-integrity)), eager loading / scopes / batch
writes / soft deletes ([§10](#10-query-economics)), adapters
([§11](#11-adapters)), DDL and migrations
([§12](#12-ddl--schema-evolution)).
### Static ORM methods
```coffee
User.find! id # → User | null
User.findMany! [1, 2, 3] # → User[] (one IN query)
User.where(active: true).all! # → User[]
User.where(active: true).first! # → User | null
User.where(active: true).count! # → number
User.includes(:orders).all! # → eager-loaded (see below)
User.all! # → User[]
User.first! # → User | null
User.count! # → number
User.create! name: "Alice", email: "a@b.c"
User.upsert! {email: "a@b.c", name: "Al"}, on: :email # INSERT … ON CONFLICT
User.insertMany! rows # validate all, one multi-VALUES INSERT
User.toSQL() # → DDL string (no DB call)
```
### Query builder
```coffee
User
.where(active: true) # object → AND equalities
.where(id: [1, 2, 3]) # array value → IN (…)
.where("created_at > ?", since) # raw SQL + params
.includes(:orders) # eager-load relations (see below)
.order("last_name, first_name") # or .orderBy — same thing
.limit(10)
.offset(20)
.all!
```
- `.where`, `.includes`, `.limit`, `.offset`, `.order` / `.orderBy`,
`.withDeleted`, `.onlyDeleted` return the query builder (sync).
- `.all`, `.first`, `.count`, `.updateAll`, `.deleteAll` terminate with
a promise.
### Instance methods
Every `:model` instance carries:
```coffee
user.save! # validate, run hooks, INSERT or UPDATE
user.destroy! # run hooks, DELETE (or UPDATE deleted_at for @softDelete)
user.destroy! hard: true # force a real DELETE on a @softDelete model
user.restore! # @softDelete only — UPDATE deleted_at = NULL
user.ok() # boolean — current fields validate
user.errors() # SchemaIssue[] — current fields' errors
user.toJSON() # plain object of own enumerable properties
# (id, declared fields, @timestamps columns, @softDelete
# deletedAt, @belongs_to FKs, !> eager-derived — but NOT
# methods, ~> computed getters, or internal state)
user.savedChanges # Map from the most
# recent save() — empty Map when nothing was written
user.markDirty 'name' # force a column into the next UPDATE; escape hatch
# for in-place mutations of object-valued fields
# that === can't see (json, Date, etc.)
```
Plus any methods, computed getters, and relation accessors you declared
on the schema. Naming tip: methods that produce a fresh projection
(e.g. `user.toPublic()`, `order.toCard()`) follow Rip's
`to` / `as` / `from` / `parse` conversion convention — see
[RIP-LANG.md §15 "Conversion Method Naming"](./RIP-LANG.md#conversion-method-naming).
### What `save()` actually writes
The runtime tracks a snapshot of declared-field and `@belongs_to` FK
column values at hydrate / INSERT / UPDATE time. On `.save()` it
compares current values against the snapshot and emits a column-
targeted UPDATE that touches **only the columns whose values changed**.
If nothing changed, no SQL is issued at all.
Two practical consequences:
1. **No-op saves are free.** Calling `.save()` on an unchanged row is
a no-op — no DB round-trip, no row touched. Mirrors Active Record
with `partial_writes`.
2. **`@timestamps` `updated_at` is bumped only on real writes.**
Calling `.save()` with no actual changes does NOT bump
`updated_at` (which would defeat the no-op-save optimization
entirely). Bumped on every UPDATE that does write something.
A third practical consequence on DuckDB specifically: column-targeted
UPDATEs sidestep DuckDB's foreign-key restriction on indexed-column
updates of referenced parent rows. See
[`docs/RIP-DUCKDB.md`](./RIP-DUCKDB.md) for the full rule, what works,
what doesn't, and how to design around it.
The diff is observable as `inst.savedChanges` after the save returns
(or inside `afterCreate` / `afterUpdate` / `afterSave` hooks). Same
shape as Active Record's `saved_changes`:
```coffee
order = Order.find! 1 # snapshot captured
order.notes = "expedited"
order.userId = 9 # @belongs_to User FK
order.save!
order.savedChanges # Map(2) {"notes" => [null, "expedited"], "userId" => [7, 9]}
order.savedChanges.size # 2
order.savedChanges.has 'notes' # true
```
INSERT records `[null, newValue]` for every field/FK that was written;
UPDATE records `[oldValue, newValue]` for every field/FK whose value
actually changed. `@timestamps` columns appear with the new ISO
timestamp on real INSERTs and UPDATEs.
Hook firing matches Active Record exactly: `before*` and `after*` hooks
fire on every successful `.save()`, regardless of whether SQL was
emitted. Hooks differentiate real writes from no-ops by checking
`@savedChanges.size` or specific keys.
### In-place mutation of object-valued fields
The dirty check uses value identity (`===` with NaN handling). Setter
assignments are detected:
```coffee
user.settings = {theme: "light", notifications: true} # new reference; detected
user.save!
```
In-place mutations are **not**:
```coffee
user.settings.theme = "light" # same reference; invisible
user.save! # nothing written
```
This matches Active Record's behavior with serialized attributes —
"Active Record by default does not detect changes inside mutable
serialized attributes." The escape hatch is `markDirty`:
```coffee
user.settings.theme = "light"
user.markDirty 'settings' # AR's `settings_will_change!`
user.save! # writes settings = '{"theme":"light",...}'
```
`markDirty` accepts both camelCase and snake_case names, validates
against declared fields and `@belongs_to` FK column names, and throws
on unknown names or non-persisted instances (INSERT writes every set
field, so `markDirty` there would be a silent no-op).
Same caveat applies to `Date` fields:
```coffee
order.collectedAt.setHours 5 # in-place; invisible
order.markDirty 'collectedAt'
order.save!
```
If you find yourself reaching for `markDirty` often, prefer immutable
updates instead — they're cleaner and the dirty check sees them
automatically:
```coffee
user.settings = { ...user.settings, theme: "light" }
order.collectedAt = new Date order.collectedAt.getTime() + 3600000
```
### Re-entry guard
`.save()` cannot be re-entered on the same instance while a save is
already in flight. Calling `@save!` from inside this instance's
`beforeSave` / `beforeUpdate` / `afterSave` hook throws — that's
almost always a recursion bug, and silent infinite-loop debugging is
worse than a clear error. The guard is per-instance: independent
instances saving in parallel are unaffected, and sequential saves on
the same instance work fine.
### Lifecycle hooks
Hooks are methods whose name matches one of the [twelve recognized hook
names](#24-hook-reference). On `:model` they bind into the lifecycle; on
other kinds they're just regular methods.
**Save flow:**
```text
beforeValidation
↓
validate
↓
afterValidation
↓
beforeSave
↓
beforeCreate (for inserts) beforeUpdate (for updates)
↓ ↓
INSERT UPDATE
↓ ↓
afterCreate afterUpdate
↓
afterSave
```
**Destroy flow:**
```text
beforeDestroy
↓
DELETE (or UPDATE deleted_at if @softDelete)
↓
afterDestroy
```
Throwing from any hook aborts the operation and propagates the error.
Validation happens **after** `beforeValidation` (so that hook is the
right place to normalize input) and **before** `beforeSave` (so `beforeSave`
only runs on already-valid data).
`afterCommit` and `afterRollback` sit outside both flows: when a
`schema.transaction!` is open they queue on it and fire after the
outermost COMMIT / ROLLBACK; with no transaction open, `afterCommit`
fires immediately after a successful save/destroy and `afterRollback`
never fires. See the Transactions section above.
### Relations
```coffee
User = schema :model
name! string
@has_many Order
@has_one Profile
Order = schema :model
total! integer
@belongs_to User
@belongs_to Organization? # ? = nullable FK
```
Relation accessors are **async methods** on the instance prototype:
```coffee
user = User.find! 1
orders = user.orders! # → Order[]
profile = user.profile! # → Profile | null
order = Order.find! 42
owner = order.user! # → User | null
```
Accessor names:
- `@belongs_to User` → `user()` (target's name, lower-first-letter)
- `@has_one Profile` → `profile()`
- `@has_many Order` → `orders()` (pluralized)
Targets resolve lazily through a process-global registry keyed by name.
Circular and cross-module references work — import the file that defines
the target, and relation calls succeed.
See [§26 Relations](#26-relations) for the full table of directive →
accessor → return type.
### Snake / camel dual access on instances
Database columns are typically snake_case (`user_id`, `created_at`) while
field names are camelCase (`userId`, `createdAt`). A hydrated `:model`
instance exposes both — `order.user_id` and `order.userId` read the same
slot. The camelCase form is the canonical own property; the snake_case
form is a non-enumerable accessor that forwards.
```coffee
order = Order.find! 42
order.userId # 7 (camelCase: canonical)
order.user_id # 7 (snake_case: alias)
order.createdAt # Date (camelCase: canonical)
order.created_at # Date (snake_case: alias)
```
`.create(data)` also accepts either style:
```coffee
Order.create! user_id: 7, total: 100
Order.create! userId: 7, total: 100 # same result
```
Use whichever reads better alongside nearby raw SQL or JSON payloads.
### Field-name conventions
The snake_case ↔ camelCase bijection only works for identifiers
that round-trip cleanly. The schema runtime enforces canonical
camelCase at definition time:
```coffee
User = schema :model
mdmId? string # OK — canonical
mdmID? string # error — acronym style;
# round-trips to mdm_i_d / mdmID
# ambiguously
```
Rules:
- Lowercase-first
- Alphanumeric body
- No two consecutive uppercase letters anywhere
Same convention as Java Beans, Swift's "Acronyms in API names"
guidance, and what most JS/TS codebases follow in practice.
The runtime also reserves the names of its instance API
(`save`, `destroy`, `ok`, `errors`, `toJSON`, `savedChanges`,
`markDirty`, `_dirty` / `_persisted` / `_snapshot` / `_saving`)
and the implicit timestamp / soft-delete columns
(`createdAt`, `updatedAt`, `deletedAt`). Declaring any of those
as a user field on a `:model` raises a `'reserved ORM name'`
collision error at definition time. (Mixins are exempt — they
can declare `createdAt` / `updatedAt` for explicit control,
which is the alternative to the `@timestamps` directive.)
### Relation target names
`@belongs_to TargetName` derives the FK column from the target's
PascalCase name via `__schemaSnake(target) + '_id'`. The same
camelCase / snake_case bijection rule applies in reverse: target
names should be canonical PascalCase (`User`, `UserOrg`, not
`MDMUser`) so the derived FK column round-trips cleanly. Acronym-
style target names aren't currently rejected at definition time,
but writing `@belongs_to MDMUser` produces FK column `m_d_m_user_id`
— almost certainly not what you want. Stick to PascalCase.
### SQL reserved words
The runtime always quotes column names in generated SQL — every
INSERT, UPDATE, and SELECT it emits surrounds column identifiers
with `"..."`. So a field named `order` works fine through the
ORM:
```coffee
Trade = schema :model
order! integer # works through the ORM
```
The compiled SQL is `UPDATE "trades" SET "order" = ? WHERE "id" = ?`.
The catch is **raw SQL** that you write yourself via the adapter's
`query()` method or via `query!`. There the reserved-word collision
becomes your problem:
```coffee
result = query! "SELECT order FROM trades" # syntax error
result = query! "SELECT \"order\" FROM trades" # works
```
This matches Active Record's behavior — the ORM-generated SQL is
always quoted, and raw SQL is always the user's responsibility.
---
## 9. Transactions & data integrity
Atomic multi-statement writes, transaction-aware lifecycle hooks, and
DB constraint violations that fail the same structured way validation
does.
### Transactions (`schema.transaction!`)
Atomic multi-statement writes. The block's value becomes the
transaction's value; a throw rolls everything back and propagates:
```coffee
result = schema.transaction! ->
user = User.create! name: "Alice", email: "a@b.c"
Order.create! userId: user.id, total: 100
user # block value = transaction!'s value
```
- **Propagation is ambient** (AsyncLocalStorage): every ORM call inside
the block — `create!`, `save!`, `destroy!`, relation accessors,
queries — automatically routes through the transaction's pinned
connection. Model code is unchanged inside the block.
- Block throws → `ROLLBACK`, the exception propagates. Block returns →
`COMMIT`, the value is returned.
- **Nested `transaction!` joins the outer transaction** (Active
Record's default). DuckDB has no `SAVEPOINT`, so independent nested
units aren't expressible on the primary backend; joining is the
honest semantics.
- Don't parallelize ORM calls *inside* one transaction — they share one
pinned DB connection, exactly like one connection in any ORM.
Parallel `transaction!` blocks are fine; each gets its own connection
and its own ambient context.
- Two transaction-aware hooks: `afterCommit` fires after the outermost
transaction commits (or immediately after save/destroy when no
transaction is open) — this is where emails, webhooks, and cache
invalidation belong. `afterRollback` fires after a rollback for each
instance saved/destroyed inside the rolled-back transaction. A row
saved twice in one transaction gets one callback. Exceptions in
`afterCommit` propagate but cannot roll back — the COMMIT already
happened.
- Against rip-db / duckdb-harbor, the transaction rides harbor's
session protocol (`POST /sql/sessions/new` pins a connection; the
session is destroyed after COMMIT/ROLLBACK; harbor's idle TTL
auto-rolls-back abandoned transactions server-side). Note: harbor
gates session creation behind the `__HARBOR_ADMIN__:sessions:create`
authz policy — transactions need an authz rule allowing it (or
`harbor_allow_admin_without_authz = true` on trusted deployments),
and an authenticated principal (harbor's unauthenticated `token :=
NULL` mode cannot create owned sessions).
- Adapters without `begin()` throw a clear "does not support
transactions" error — never a silent non-transactional fallback.
### Constraint violations are SchemaErrors
The ORM wraps every adapter call. DB errors recognized as constraint
violations are translated into `SchemaError` so a `save!` that trips a
UNIQUE index fails the same way a `save!` that trips a validator does:
| DB condition | Issue emitted |
| --- | --- |
| UNIQUE violation | `{field: "email", error: "unique", message: "email already taken"}` |
| NOT NULL violation | `{field, error: "required", …}` |
| FK violation | `{field, error: "reference", …}` |
| CHECK violation | `{field: "", error: "check", …}` |
The original adapter error is preserved as `err.cause`. Unrecognized
errors propagate untouched. Uniqueness pre-checks
(`validates_uniqueness_of`-style) are deliberately **not** offered —
they race; the DB constraint is the check, translation makes it
ergonomic.
## 10. Query economics
The features that keep list views at a constant query count and bulk
operations at one statement: eager loading, composable scopes, batch
writes, and soft deletes.
### Eager loading (`.includes`)
Relations are lazy by default — `user.orders!` issues a query on demand,
which makes N+1 the default behavior of every list view. `.includes`
fixes the economics with **batched second queries** (`WHERE fk IN (…)`),
never JOINs — no row duplication, uniform across `belongs_to` /
`has_one` / `has_many`:
```coffee
users = User.includes(:orders).where(active: true).all!
posts = Post.includes(:author, comments: :author).limit(20).all!
```
- Accepts `:symbols`, strings, and nested `{relation: nested}` maps to
any depth. One query per relation per nesting level, regardless of
row count.
- Preloaded relations fill the accessor's **memo**: `user.orders!`
resolves from cache with no query. The accessor API is unchanged
(uniform async) — preloading is purely a performance fact, invisible
to call sites.
- `.includes` never changes the root result set — same rows with or
without it.
Relation accessors memoize independently of `.includes`: the second
`user.orders!` call on the same instance is free. Pass
`user.orders! reload: true` to bust the memo and re-query.
### Query scopes (`@scope`, `@defaultScope`)
Named, composable query fragments declared on the model:
```coffee
User = schema :model
name! string
active? boolean
role? string
@scope :active, -> @where(active: true)
@scope :since, (d) -> @where("created_at > ?", d)
@defaultScope -> @where(banned: false)
User.active().since(monday).order("name").all!
User.where(role: "admin").active().all! # chains in any order
User.unscoped().all! # skip the @defaultScope
```
- `this` inside a scope body is the query builder; scopes return the
builder, so they compose with each other and with `.where` /
`.order` / `.limit` in any order. Parameterized scopes declare their
args: `(d) -> @where("created_at > ?", d)`.
- Scopes live in the **static** namespace (model + builder), so a field
`active` and a scope `:active` coexist. Scope names may not collide
with the query API (`where`, `find`, `order`, …) or with each other —
checked at first use with a `collision` SchemaError.
- `@defaultScope` (at most one per model) applies to every read and
bulk write — `where`/`all`/`first`/`count`/`find`/`findMany`/
`updateAll`/`deleteAll` — unless `.unscoped()` appears anywhere in
the chain. It composes with `@softDelete`'s implicit filter; both
apply. (Use sparingly — Active Record's caveats about default
scopes apply verbatim.)
- Scopes appear in shadow TS: typed statics on the model const plus a
per-model `UserQuery` alias so scope-first chains typecheck.
### Batch writes
```coffee
User.upsert! {email: "a@b.c", name: "Alice"}, on: :email
# INSERT … ON CONFLICT (email) DO UPDATE SET …; validates;
# beforeSave/afterSave fire; beforeCreate/beforeUpdate do NOT
# (the runtime can't know which branch the DB took).
# DuckDB caveat: ON CONFLICT updates on rows referenced by another
# table's FK trip DuckDB's indexed-column restriction — see
# docs/RIP-DUCKDB.md.
User.insertMany! rows
# validates every row first (ALL failures collected into one
# SchemaError with `[i].field` issue paths, before any SQL), then one
# multi-VALUES INSERT … RETURNING *. Per-instance hooks deliberately
# skipped — this is the bulk path; use create! in a loop for hooks.
User.where(active: false).deleteAll! # one statement (soft-delete aware)
User.where(plan: "trial").updateAll! expired: true
# one UPDATE; bypasses validation and hooks — the name says "all",
# the docs say "raw". Bumps updated_at on @timestamps models.
```
### Soft deletes
`@softDelete` adds a `deleted_at` column and turns `.destroy()` into an
UPDATE. Every read (`find`, `where`, `all`, `first`, `count`) and bulk
write implicitly filters `deleted_at IS NULL`. The escape hatches:
```coffee
User.withDeleted().all! # no filter — live and deleted rows
User.onlyDeleted().all! # inverted filter — deleted rows only
user.restore! # UPDATE … SET deleted_at = NULL
user.destroy! hard: true # real DELETE; hooks still fire
```
Relation accessors on other models respect the target's soft-delete
filter — an `order.user!` of a soft-deleted user resolves `null`,
consistent with `find`.
## 11. Adapters
### The adapter seam (Contract v2)
All ORM methods route through a single adapter funnel.
`query(sql, params)` is the only **required** method; v2 adds optional
capabilities the runtime feature-detects:
```coffee
globalThis.__ripSchema.__schemaSetAdapter
# required — returns {columns: [{name, type}, …], data: [[…]], rowCount: N}
query: (sql, params) ->
db.run sql, params
# optional — transactions (schema.transaction!). Returns a TxHandle.
begin: (options) ->
conn = db.pin()
conn.run 'BEGIN'
{
query: (sql, params) -> conn.run sql, params
commit: -> conn.run 'COMMIT' and conn.release()
rollback: -> conn.run 'ROLLBACK' and conn.release()
}
capabilities: { tx: true } # truthful self-report
```
Calling a feature whose method is absent throws a clear error
(`schema.transaction()` on an adapter without `begin()` says so by
name) — never a silent fallback.
The default adapter talks to a duckdb-harbor instance: `RIP_DB_URL`
(default `http://127.0.0.1:9494`; legacy `DB_URL` honored) and
`RIP_DB_TOKEN` for bearer auth. Its `begin()` rides harbor's session
protocol — `POST /sql/sessions/new` pins a connection, statements carry
the `sessionId`, and the session is destroyed after COMMIT/ROLLBACK.
`@rip-lang/db`'s `connect(url)` installs the same contract (query +
begin) with its richer error handling and timeouts.
### Per-schema adapters (`on:`)
Multi-database setups pin individual models to their own adapter:
```coffee
analytics = schema.connect url: env.ANALYTICS_URL, token: env.ANALYTICS_TOKEN
Event = schema :model, on: analytics
name! string
@timestamps
```
- `schema.connect {url, token?}` builds a NEW harbor adapter value
without installing it globally; any Contract-v2 adapter object works
in the `on:` slot. The default remains the global adapter.
- Every ORM call resolves the model's adapter; `schema.transaction!`
takes `on: analytics` to pin the ambient transaction to one adapter.
ORM calls against a *different* adapter inside that block run
**outside** the transaction — each adapter has its own ambient slot,
and cross-adapter atomicity is impossible, so the runtime never
pretends otherwise.
- `@belongs_to` / `@has_many` across adapters: the accessor works (it's
just a second query), but FK DDL emission is suppressed with a note —
the constraint can't exist cross-database.
## 12. DDL & schema evolution
Greenfield CREATE comes from `toSQL()`; everything after the first
deploy comes from the migration system — diffing the declared models
against the live database.
### DDL (`.toSQL()`)
`.toSQL()` returns `CREATE SEQUENCE` + `CREATE TABLE` + index `CREATE`
statements for a model. It does not touch the database — you run the
output through whatever migration plumbing you prefer.
```coffee
User.toSQL()
# CREATE SEQUENCE users_seq START 1;
#
# CREATE TABLE users (
# id INTEGER PRIMARY KEY DEFAULT nextval('users_seq'),
# name VARCHAR(100) NOT NULL,
# email VARCHAR NOT NULL,
# created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
# updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
# );
#
# CREATE UNIQUE INDEX idx_users_email ON users ("email");
#
# CREATE UNIQUE INDEX idx_users_email ON users ("email");
```
`.toSQL()` works independently of the ORM. A migration script that never
calls `.find()` or `.create()` can still emit full DDL.
#### Sequence start value
The auto-id sequence seeds at `1` by default. Override per-model with the
`@idStart N` directive, or per-call with the `idStart` option (the option
wins):
```coffee
User = schema :model
name! string
@idStart 10001 # customer-facing IDs start at 10001
User.toSQL() # → CREATE SEQUENCE users_seq START 10001;
User.toSQL(idStart: 50000) # → CREATE SEQUENCE users_seq START 50000;
```
Required because DuckDB (as of 1.5.2) does not implement
`ALTER SEQUENCE … RESTART WITH N` — so the seed has to be baked into the
initial `CREATE SEQUENCE` rather than bumped in a follow-up migration.
To emit a whole application's schema, call `.toSQL()` per model and join.
Order by FK dependency (models referenced via `@belongs_to` come first):
```coffee
ddl = [
User.toSQL()
Category.toSQL()
Order.toSQL() # references User
OrderItem.toSQL() # references Order
].join('\n\n')
```
### Schema evolution (`rip schema status / make / migrate`)
`.toSQL()` covers greenfield CREATE. Evolution — diffing the declared
models against the deployed database and emitting ALTER migrations —
is built in:
```text
rip schema status [models.rip] # applied / pending / drift + the current plan
rip schema plan [models.rip] # just the classified diff
rip schema make [models.rip] # write migrations/NNNN_.sql from the diff
rip schema migrate [models.rip] # apply pending migration files in order
```
The same verbs are callable from code: `schema.plan!`, `schema.status!`,
`schema.make! "name", opts`, `schema.migrate! opts`, and
`schema.introspect!` (the deployed schema as canonical table specs).
**How it works.** The DDL emitter's internal model is exposed as a
canonical table spec (`Model._tableSpec()`); introspection builds the
same structure from the live database (`information_schema` +
`duckdb_*()` catalogs, or the adapter's own `introspect()` capability).
The differ operates on two values of the same type and emits classified
steps:
| Class | Examples | Gate |
| --- | --- | --- |
| `safe` | ADD COLUMN (nullable / defaulted), CREATE TABLE, CREATE INDEX, RENAME | none |
| `lossy` | type change, SET NOT NULL, new UNIQUE on existing data | `--allow-lossy` |
| `destructive` | DROP COLUMN, DROP TABLE | `--allow-destructive` |
| `blocked` | any ALTER DuckDB refuses on an FK-referenced table | no flag — manual rebuild |
**Migration files are plain SQL** — numbered, hand-editable, checked
into git (default `./migrations`). `make` writes them; humans may amend
them; `migrate` applies pending files in order (each inside a
transaction when the adapter supports one) and records
`(version, name, checksum, applied_at)` in `_rip_migrations`. A
checksum mismatch on an applied file aborts — someone edited history —
unless `--repair` re-records.
**Renames are resolved in the declaration**, since a diff cannot
distinguish rename from drop + add:
```coffee
User = schema :model
givenName! ..50, {was: "first_name"} # column rename
@tableWas legacy_users # table rename
```
The differ consumes the annotation and emits `RENAME` instead of
`DROP + ADD`; once the migration lands, the annotation is dead weight
and can be removed.
**DuckDB caveats the differ understands:**
- `ADD COLUMN` cannot carry `NOT NULL` / `UNIQUE` / `REFERENCES` —
required adds become add → (backfill TODO when no default) →
`SET NOT NULL`; unique adds get a separate `CREATE UNIQUE INDEX`;
FK constraints cannot be added to an existing table at all (a note is
emitted).
- A table referenced by another table's FOREIGN KEY is frozen for
everything except `ADD COLUMN` and index DDL ("Dependency Error") —
such steps classify `blocked`. The plan orders ALTERs before
CREATEs so a new child table never freezes its parent mid-migration.
- `VARCHAR(n)` length hints are not persisted by DuckDB, so they never
produce drift; sequence start values cannot be altered after
creation, so `@idStart` drift is reported as a note, not a step.
## 13. Wire contracts — JSON Schema & OpenAPI
### JSON Schema & OpenAPI (`toJSONSchema`)
Every schema exports a JSON Schema (draft 2020-12):
```coffee
SignupInput.toJSONSchema()
# { $schema: "…/2020-12/schema", title: "SignupInput", type: "object",
# properties: { email: {type: "string", format: "email"}, … },
# required: ["email", "password"] }
```
- Field types map per [§22](#22-field-types); ranges become
`minLength`/`maxLength`/`minimum`/`maximum`/`minItems`/`maxItems`,
regexes become `pattern`, defaults become `default`, literal unions
become `enum` (single literal → `const`).
- Nested registry schemas become `$ref`s collected under `$defs`
(cycle-safe — recursive shapes work); `:enum` maps to `enum`,
`:union` to `oneOf` + a `discriminator`.
- `:model` shapes include the DB-managed columns `toJSON()` carries
(`id`, FKs, `@timestamps`, `@softDelete`).
- Transforms and refinements have no executable JSON Schema equivalent
— they export as `description` annotations rather than being
silently dropped or approximated.
**The payoff is rip-server integration.** A route that validates with
a schema contributes to a generated `GET /openapi.json` automatically:
```coffee
import { post, openapi } from '@rip-lang/server'
post '/signup', input: SignupInput, ->
# @input is the parsed (defaulted, coerced) value;
# 400 with structured {field, error, message} issues is automatic
createUser @input
openapi title: 'Trust Health API', version: '1.4.0' # optional info block
```
The `input:` option validates through `safeAsync` (so `@ensure!`
schemas work), never re-reads the body stream, and registers
`/openapi.json` on first use — declaration → DB → server contract →
client codegen (any OpenAPI generator), with zero additional
authoring.
## 14. Mixins
`:mixin` schemas exist to share field groups across multiple models or
shapes. They're non-instantiable — you declare them, then other schemas
pull them in with `@mixin Name`.
```coffee
Timestamps = schema :mixin
createdAt! datetime
updatedAt! datetime
Auditable = schema :mixin
createdBy? string
@mixin Timestamps # mixins can chain into mixins
User = schema :model
name! string
@mixin Auditable # transitively pulls in Timestamps
Order = schema :model
total! integer
@mixin Auditable
@belongs_to User
```
### Behavior
- Fields are expanded at Layer 2 normalization, once per host schema, and
cached.
- Expansion is depth-first. A mixin that `@mixin`s another mixin
transitively contributes its base's fields.
- Diamond inclusion dedupes: if two mixins both include a common base,
the base's fields appear once per host.
- Cycles produce a compile error with the full path (`A -> B -> A`).
- Duplicate fields across mixins (or between a mixin and the host) are a
compile error — no silent overwrite.
- Mixins are fields-only. Methods, computed, hooks, and non-`@mixin`
directives inside a mixin body are compile errors.
### `@mixin` is allowed on any fielded kind
```coffee
Base = schema :mixin
id! uuid
X = schema :input
@mixin Base
name! string
Y = schema :shape
@mixin Base
full: ~> @name
Z = schema :model
@mixin Base
@timestamps
```
The reason: mixins add *fields*, not *behavior*. Field sharing is
orthogonal to the capability axis that distinguishes the kinds.
---
## 15. Schema algebra
Algebra operators derive new schemas from existing ones:
| Operator | Result |
| ----------------------- | -------------------------------------------------------------- |
| `.pick(...keys)` | new shape with only the listed fields |
| `.omit(...keys)` | new shape without the listed fields |
| `.partial()` | every field becomes optional |
| `.required(...keys)` | the listed fields become required (others unchanged) |
| `.extend(other)` | merge another schema's fields; collisions throw |
### Three invariants to remember
> **Algebra always returns `:shape`**, never `:model` or `:input`. On a
> model, the ORM surface is stripped — `UserPublic.find()` throws.
> **Field semantics survive; instance behavior does not.** What carries
> through to the derived shape: type (including literal unions),
> modifiers, constraints (range, regex, default, attrs), and **inline
> transforms** (`name, -> fn(it)`). What gets dropped: methods (`->`),
> computed getters (`~>`), eager-derived fields (`!>`), hooks, ORM
> methods, and `@ensure` refinements. The transform is "how this
> field's value is obtained from raw input" — a property of the field,
> not of the instance — so it travels with the field through algebra.
> Refinements, by contrast, are schema-level invariants that reference
> field names — there's no static guarantee those names survive a
> `.pick` or `.omit`, so refinements drop unconditionally.
> **Transforms-survive has a subtle consequence**: a derived schema may
> still read raw-input keys that don't appear in its declared output
> shape. `User.pick 'slug'` where `slug` is declared as
> `slug! -> "#{it.FirstName}-#{it.LastName}".toLowerCase()` continues
> to read `FirstName` and `LastName` from the input even though neither
> is in the output. This is deliberate and documented; it makes
> PascalCase-remap transforms composable with `.pick`.
```coffee
User = schema :model
name! string
email! email @unique, -> it.email.toLowerCase()
password! string
full: ~> "#{@name} <#{@email}>"
tagline: !> "#{@name} (active)"
UserPublic = User.omit "password"
UserPublic.kind # 'shape'
typeof UserPublic.find # 'function' — but throws when called
UserPublic.find(1) # throws: :model-only
u = UserPublic.parse {name: "A", email: "X@B.C"}
u.email # 'x@b.c' — transform survived
typeof u.full # 'undefined' — ~> dropped
typeof u.tagline # 'undefined' — !> dropped
```
`.extend(other)` is the exception to "algebra only drops" — it adds
fields from another schema. Collisions still throw.
```coffee
AdminUser = User.extend (schema :shape
permissions! string[])
```
`.sourceModel` is preserved through chained algebra, so tooling can trace
derived shapes back to their origin:
```coffee
A = User.pick "name"
B = A.partial()
B._sourceModel is User # true
```
### The `_sourceModel` metadata
Algebra operations preserve a non-enumerable `_sourceModel` pointer on
the derived schema. Downstream tooling (migration analyzers, form
generators, query projectors) can walk this back to the originating
`:model` without stringly-typed guesses.
---
## 16. Shadow TypeScript
Every named schema emits virtual TypeScript declarations that the
language service picks up. The VS Code extension and `rip check` both
consume these — autocomplete, hover, and type checking all work out of
the box.
### What gets emitted
The schema's **bare name** is the type you reference everywhere — the parsed
value (for `:input`/`:shape`) or the hydrated instance (for `:model`) — exactly
the way a class names both its value and its instance type. A separate
`Data` (fields-only) type is emitted only when it differs from the bare
name: behavior-bearing `:shape`s and every `:model`.
For `:input` (no behavior — the bare name is the whole shape):
```ts
type SignupInput = { email: string; password: string };
declare const SignupInput: Schema;
```
For `:shape` (with behavior — `Data` = fields, bare `` = instance):
```ts
type AddressData = { street: string; city: string };
type Address = AddressData & {
readonly full: unknown;
normalize: (...args: any[]) => unknown;
};
declare const Address: Schema;
```
For `:model`:
```ts
type UserData = { name: string; email: string };
type UserCreate = { name: string; email: string }; // create()'s input — required-no-default fields
type User = UserData & {
readonly identifier: unknown;
greet: (...args: any[]) => unknown;
save(): Promise;
destroy(): Promise;
ok(): boolean;
errors(): SchemaIssue[];
toJSON(): UserData;
organization(): Promise;
orders(): Promise;
};
declare const User: ModelSchema;
```
> **Computed/derived inference.** The `unknown` on a `~>`/`!>` member above is
> the *published* `.d.ts` form. In the editor and `rip check`, the type emitter
> infers each computed/derived member from its body — `full` above resolves to
> `string`, and a `status: ~> if @done then 'Completed' else 'Pending'` resolves
> to `"Completed" | "Pending"` — so they're usable as their real types (e.g.
> `order.name.toUpperCase()`). A plain `.d.ts` has no runtime body to infer
> from, so it keeps `unknown`. Methods stay `(...args: any[]) => unknown` either
> way (their params aren't typed).
For `:enum`:
```ts
type Role = "admin" | "user" | "guest";
declare const Role: {
parse(data: unknown): Role;
safe(data: unknown): SchemaSafeResult;
ok(data: unknown): data is Role; // sound type predicate!
};
```
For `:mixin`: type-only alias, no runtime declaration (mixins aren't
user-facing runtime values).
```ts
type Timestamps = { createdAt: Date; updatedAt: Date };
```
### Algebra types follow runtime semantics
Because algebra operates on `Data` (the plain field shape, not the
`Instance`), derived types correctly omit behavior:
```ts
// User.omit("email") has type:
Schema, Omit>
// User.partial() has type:
Schema, Partial>
```
A **named** derived schema additionally gets a bare type of its own, so a
projection can be annotated or re-exported under a clean name:
```coffee
UserPublic = User.pick("id", "name") # also emits a bare `type UserPublic`
```
It's emitted as `type UserPublic = ReturnType<(typeof UserPublic)['parse']>`,
which resolves to the exact projection (`Pick`) —
covering every operator and chain for free.
### Same-file targets type relation accessors
Relation accessors get precise return types when the target is declared
in the same file:
```coffee
User = schema :model
name! string
Order = schema :model
@belongs_to User # → order.user(): Promise
```
Cross-file relation targets degrade to `unknown` rather than emit
unresolved names. This keeps the TypeScript diagnostics clean without
requiring virtual-module imports.
### Intrinsic declarations
Five base interfaces get injected into every schema-using file's type
view:
```ts
interface SchemaIssue { field: string; error: string; message: string; }
type SchemaSafeResult =
| { ok: true; value: T; errors: null }
| { ok: false; value: null; errors: SchemaIssue[] };
interface Schema {
parse(data: unknown): Out;
safe(data: unknown): SchemaSafeResult;
ok(data: unknown): boolean;
pick(...keys: K[]): Schema, Pick>;
omit(...keys: K[]): Schema, Omit>;
partial(): Schema, Partial>;
required(...keys: K[]): Schema<
Omit & Required>,
Omit & Required>
>;
extend(other: Schema): Schema;
}
interface SchemaQuery {
all(): Promise;
first(): Promise;
count(): Promise;
limit(n: number): SchemaQuery;
offset(n: number): SchemaQuery;
order(spec: string): SchemaQuery;
}
interface ModelSchema> extends Schema {
find(id: Id): Promise;
findMany(ids: Id[]): Promise;
where(cond: Record | string, ...params: unknown[]): SchemaQuery;
includes(...specs: unknown[]): SchemaQuery;
withDeleted(): SchemaQuery;
onlyDeleted(): SchemaQuery;
unscoped(): SchemaQuery;
all(limit?: number): Promise;
first(): Promise;
count(cond?: Record): Promise;
create(data: Create): Promise;
upsert(data: Create, opts: { on: unknown }): Promise;
insertMany(rows: Create[]): Promise;
toSQL(options?: { dropFirst?: boolean; header?: string; idStart?: number }): string;
}
declare const schema: {
transaction(fn: () => T | Promise): Promise;
transaction(opts: Record, fn: () => T | Promise): Promise;
};
```
You don't import these — they're injected automatically when the file
contains any schema declaration.
---
## 17. SchemaError and diagnostics
### `SchemaError`
Thrown by `.parse()` and `.save()` on validation failure. Carries
structured diagnostic information:
```coffee
try
User.parse badInput
catch err
err.name # 'SchemaError'
err.schemaName # 'User'
err.schemaKind # 'model'
err.issues # [{field, error, message}, ...]
err.message # 'User: name is required; email must be email'
```
Each issue has three fields:
```ts
{
field: string // field name, or '' for schema-wide issues
error: string // 'required' | 'type' | 'min' | 'max' | 'pattern' | 'enum' | 'collision' | 'mixin-cycle' | 'mixin-collision' | 'mixin-missing' | ...
message: string // human-readable explanation
}
```
### Schema-mode-aware compile diagnostics
The schema sub-parser reports errors with context that makes mistakes
mechanical to fix:
```
Schema fields use 'name type' (space, no colon). For methods or computed
use 'name: -> body' or 'name: ~> body'.
Enum member must be a :symbol. Use ':admin' for a bare member or
':admin value' for a valued one.
:mixin schemas are fields-only. 'greet' is a method; move it to a :shape
or :model.
:shape schemas only accept '@mixin Name'. '@timestamps' is :model-only.
mixin cycle: A -> B -> A
Inline schema body does not support '->' (method/hook/transform).
Use the indented form.
Inline schema body is empty. Either add '; field; …' entries after
'schema :shape;' or switch to the indented form.
Schema pragma 'schema.defaultMaxString' must be declared at file top
level. It was found inside a nested block (function / class / if /
loop body), where it would leak into later top-level schemas.
Field 'n' would have impossible constraints min=1 > max=0 after sugar
is applied (implicit min=1 from `!` vs range max 0). Write an explicit
range or drop the conflicting pragma.
```
---
## 18. Common mistakes
These forms look right but don't work — the parser catches all of them
with specific diagnostics.
### `name: type` instead of `name type`
```coffee
# wrong — fields use a space, not a colon, between name and type
X = schema
name: string
# right
X = schema
name! string
```
### Bare identifier enum members
```coffee
# wrong — enum members are :symbol
R = schema :enum
admin
user
# right
R = schema
:admin
:user
```
### `name: value` as an enum member
```coffee
# wrong — use :name value
R = schema :enum
pending: 0
# right
R = schema
:pending 0
```
### Methods in `:input` or `:mixin`
```coffee
# wrong — :input is fields-only
X = schema :input
name! string
greet: -> "hi"
# right — use :shape (or :model) for behavior
X = schema :shape
name! string
greet: -> "hi"
```
### ORM directives on `:shape`
```coffee
# wrong — @timestamps is :model-only
A = schema :shape
street! string
@timestamps
# right
A = schema :model
street! string
@timestamps
```
### Calling ORM methods on a derived shape
```coffee
UserPublic = User.pick "id", "name", "email"
# wrong — algebra returns :shape; :shape has no .find()
user = UserPublic.find! 1
# right — query the source model and project
user = User.find! 1
view = UserPublic.parse user.toJSON()
```
### Treating `.ok()` as a type predicate for shapes/models
```coffee
# wrong — .ok() doesn't produce a parsed value
if User.ok raw
raw.name # raw is still untyped — .ok is boolean only
# right
result = User.safe raw
if result.ok
result.value.name # typed
# or
user = User.parse raw # throws on failure, returns typed value
```
Only `:enum` exposes `.ok(data): data is EnumType` as a sound type
predicate.
---
## 19. Recipes
### Validating HTTP input
The `input:` route option validates the JSON body before the handler
runs — a 400 with structured issues goes out automatically, the parsed
(defaulted, coerced) value is `@input`, and the route contributes to
the generated `GET /openapi.json`:
```coffee
import { post } from '@rip-lang/server'
SignupInput = schema
email! email
password! string, 8..100
age? ~integer, 18..120 # "21" on the wire → 21
post '/signup', input: SignupInput, ->
db.users.insert @input
{ ok: true }
```
Validating by hand works too — `SignupInput.safe raw` returns
`{ok, value, errors}` for cases where you need custom failure handling.
### An atomic checkout
`schema.transaction!` makes the multi-write atomic; the UNIQUE
constraint is the duplicate check (no racy pre-query — the DB error
arrives as a structured SchemaError); `afterCommit` is where effects
that must never observe uncommitted state belong:
```coffee
Order = schema :model
total! integer
reference! # string
@belongs_to User
@timestamps
afterCommit: -> queueEmail 'receipt', @id # fires after COMMIT only
placeOrder = (user, items, reference) ->
try
schema.transaction! ->
order = Order.create! userId: user.id, total: totalOf(items), reference: reference
Invoice.create! orderId: order.id, amount: order.total
order
catch err
if err.issues?[0]?.error is 'unique'
error! 'That order reference already exists', 409
throw err
```
A throw anywhere in the block rolls everything back — the order and
invoice land together or not at all.
### A list view without N+1
`.includes` batches relations (`WHERE fk IN (…)` — one query per
relation per level), scopes name the filters, and `@defaultScope`
keeps cancelled rows out of every query unless `.unscoped()` opts in:
```coffee
Patient = schema :model
name! string
active? boolean
@has_many Visit
@scope :active, -> @where(active: true)
@defaultScope -> @where(archived: false)
Visit = schema :model
reason? string
@belongs_to Patient
@belongs_to Provider
# 3 queries total, regardless of row count:
patients = Patient.active().includes(visits: :provider).all!
for patient in patients
visits = patient.visits! # memo hit — no query
```
### A DB-backed model with relations
```coffee
User = schema :model
name! string, 1..100
email! email @unique
@timestamps
@has_many Order
beforeValidation: -> @email = @email.toLowerCase()
Order = schema :model
total! integer
status string, [:pending]
@belongs_to User
@timestamps
# Use:
user = User.create! name: "Alice", email: "ALICE@EXAMPLE.COM"
Order.create! user_id: user.id, total: 100
orders = user.orders! # [{total: 100, ...}]
owner = orders[0].user! # the same user
```
### A shape with computed values
```coffee
Money = schema :shape
amount! integer
currency! string, 3..3
formatted: ~>
symbol = {USD: "$", EUR: "€", JPY: "¥"}[@currency] ?? @currency
"#{symbol}#{(@amount / 100).toFixed(2)}"
add: (other) ->
throw new Error "currency mismatch" unless @currency is other.currency
Money.parse amount: @amount + other.amount, currency: @currency
a = Money.parse amount: 12345, currency: "USD"
a.formatted # "$123.45"
b = a.add Money.parse amount: 99, currency: "USD"
b.formatted # "$124.44"
```
### Sharing fields with a mixin
```coffee
Timestamps = schema :mixin
createdAt! datetime
updatedAt! datetime
User = schema :model
name! string
email! email
@mixin Timestamps
Post = schema :model
title! string
body! text
@mixin Timestamps
@belongs_to User
```
### Projecting a model to a wire view
```coffee
User = schema :model
name! string
email! email @unique
password! string
role? string, [:user]
@timestamps
# Wire projection — the shape clients receive, derived from the one model.
# Use `pick` (an allowlist): a field added to the model later can't leak to
# clients by default — `password` is simply never selected. Prefer this over
# `omit` for anything crossing a trust boundary; `omit` fails open.
UserPublic = User.pick "id", "name", "email", "role"
get '/users/:id' ->
id = read 'id', 'id!'
user = User.find! id
return error! 404 unless user
{ user: UserPublic.parse user.toJSON() }
```
### Evolving the schema (week 2 and beyond)
Change the models, then let the differ write the migration. A rename is
declared, not guessed:
```coffee
# models.rip — week 2: rename a column, add a field, add a table
User = schema :model
fullName! string, 1..100, {was: "name"} # rename annotation
email! email @unique
phone? ~:phone # new column
@timestamps
AuditLog = schema :model # new table
action! string
@belongs_to User
@timestamps
```
```text
rip schema status models.rip # review the classified plan
rip schema make week2 models.rip # writes migrations/0002_week2.sql
rip schema migrate models.rip # applies it, records the checksum
```
Lossy steps (type changes, SET NOT NULL) need `--allow-lossy`;
DROPs need `--allow-destructive`; steps DuckDB refuses on
FK-referenced tables are marked `blocked` and never auto-applied.
Once the migration lands, delete the `{was: …}` annotation.
For greenfield scripts, `.toSQL()` still works standalone — it never
calls the adapter, so DDL can be emitted before the database exists.
### The wire vocabulary (`~:name`)
Every `read()` validator doubles as a schema coercer — one
normalization vocabulary for route params and schema fields. It lives
in `@rip-lang/validate` (browser-safe), loads with `@rip-lang/server`
on the server, and a side-effect `import '@rip-lang/validate'` brings
it to client-side parsing:
```coffee
Patient = schema :model
chart! ~:id, 1..99999 # "42" → 42, range-checked
ssn? ~:ssn # "123-45-6789" → "123456789"
phone? ~:phone # "8016542000" → "(801) 654-2000"
state? ~:state # "ut" → "UT"
dob? ~:date # normalized "YYYY-MM-DD"
amount? ~:money # "$1,234.50" → 123450 (dollars in → integer cents)
```
Custom validators registered with `registerValidator` (from
`@rip-lang/validate`) or `schema.registerCoercer` (anywhere) join the
vocabulary automatically.
### Composing nested shapes
Small sub-shapes referenced by name compose into a larger contract.
Validation recurses into each referenced schema; errors carry
path-prefixed `field` entries so callers can pinpoint the failing
sub-field:
```coffee
Address = schema :shape
street! ..200
city! ..100
state? ..2
zip? ..10
Customer = schema :shape
id? integer
name! ..100
address! Address
OrderRequest = schema :shape
customer! Customer
notes? ..500
r = OrderRequest.safe body
if r.ok
process r.value
else
for e in r.errors
# e.g. field: "customer.address.street" error: "required"
console.log e.field, e.error, e.message
```
Registered `:shape` / `:input` / `:model` names can all be referenced
as field types — see §22 for resolution rules.
---
## 20. What's not here yet
Rip Schema covers a large surface area with one keyword, but it deliberately
does not yet cover every feature you might find across the union of Zod,
Prisma, Drizzle, and the rest. These are intentional omissions — each one
has an open design question that hasn't been resolved in a way that fits the
language.
### Validator features not yet in
- **Union algebra** — `.pick`/`.omit`/etc. on a `:union` throws; the
semantics (distribute? intersect?) have no obviously-right answer.
Derive from a constituent instead.
- **Untagged unions** — deliberate non-goal: O(n) dispatch and
incoherent error messages. Use `@on :field`.
### ORM features not yet in
- **FK-cluster rebuilds** — DuckDB freezes FK-referenced tables for most
ALTERs; the differ classifies those steps `blocked` and leaves the
rebuild (recreate referencing tables around the change) to the human.
Generating the rebuild automatically is the next migration item.
- **Polymorphic associations** — `@belongs_to :commentable, polymorphic: true`.
- **Non-SQL adapters** — Mongo, Redis, Elasticsearch. The adapter contract
is `query(sql, params)`, which assumes SQL.
- **Savepoint-backed nested transactions** — nested `transaction!`
joins the outer transaction. DuckDB has no `SAVEPOINT`, so
independent nested units aren't expressible on the primary backend.
### Type features not yet in
- **Generic schemas** — `Paginated = schema :shape ...` parameterized
by another schema. Today you define a concrete `PaginatedUser` per type.
- **Branded / nominal types** — `UserId = schema :input` whose parsed
value is nominally distinct from `number`.
None of these are architectural impossibilities. Each is a conscious pause
while the core shape of the feature settles. If one of these is blocking
you, file a proposal — the sidecar design makes most of them additive.
---
# Part II — Reference
## 21. Capability matrix
What each kind's body can contain:
| Feature | `:input` | `:shape` | `:enum` | `:mixin` | `:model` |
| --------------------------------------- | -------- | -------- | -------- | -------- | -------- |
| Fields (`name` with optional type) | ✓ | ✓ | — | ✓ | ✓ |
| Literal-union type (`"a" \| "b"`, single = constant) | ✓ | ✓ | — | ✓ | ✓ |
| Coercion (`~integer`, `~:ssn`) | ✓ | ✓ | — | ✓ | ✓ |
| Range / regex / default / attrs | ✓ | ✓ | — | ✓ | ✓ |
| Inline transforms (`name, -> fn(it)`) | ✓ | ✓ | — | — | ✓ |
| `@mixin` directive | ✓ | ✓ | — | ✓ | ✓ |
| `@ensure` / `@ensure!` refinement | ✓ | ✓ | — | — | ✓ |
| Other directives (`@timestamps`, `@scope`, …) | — | — | — | — | ✓ |
| Methods (`name: (params) -> body`) | — | ✓ | — | — | ✓ |
| Computed getter (`name: ~> body`) | — | ✓ | — | — | ✓ |
| Eager-derived field (`name: !> body`) | ✓ | ✓ | — | — | ✓ |
| Hooks (by known name, 12 total) | — | methods | — | — | ✓ |
| Enum members (`:symbol`) | — | — | ✓ | — | — |
| Algebra (`.pick` etc.) | ✓ → shape | ✓ → shape | — | — | ✓ → shape |
| ORM (`.find`, `.create`, transactions, scopes) | — | — | — | — | ✓ |
| `.parse` / `.safe` / `.ok` (+ async trio) | ✓ | ✓ | ✓ | — | ✓ |
| `.toJSONSchema()` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `.toSQL()` / `rip schema make` | — | — | — | — | ✓ |
"methods" in the `:shape` / Hooks row means: hook-named functions are
accepted, but they're just methods with no lifecycle binding.
`:union` is body-incompatible with everything above — its body is
exactly one `@on :field` plus 2+ bare constituent names. It exposes
`.parse` / `.safe` / `.ok` (and the async trio) plus `.toJSONSchema()`
(`oneOf` + discriminator) by delegating to the matched constituent;
algebra and ORM surface throw.
---
## 22. Field types
Built-in type names and their runtime / SQL / TypeScript mappings:
| Rip type | Validator | SQL | TypeScript |
| ---------- | ------------------------------------ | ----------- | ---------- |
| `string` | `typeof v === 'string'` | `VARCHAR` | `string` |
| `text` | `typeof v === 'string'` | `TEXT` | `string` |
| `email` | string + `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | `VARCHAR` | `string` |
| `url` | string + `/^https?:\/\/.+/` | `VARCHAR` | `string` |
| `uuid` | string + UUID regex | `UUID` | `string` |
| `phone` | string + `/^[\d\s\-+()]+$/` | `VARCHAR` | `string` |
| `zip` | string + `/^\d{5}(-\d{4})?$/` (US) | `VARCHAR` | `string` |
| `number` | `typeof v === 'number'` and not NaN | `DOUBLE` | `number` |
| `integer` | `Number.isInteger(v)` | `INTEGER` | `number` |
| `boolean` | `typeof v === 'boolean'` | `BOOLEAN` | `boolean` |
| `date` | `Date` instance | `DATE` | `Date` |
| `datetime` | `Date` instance | `TIMESTAMP` | `Date` |
| `json` | not undefined | `JSON` | `unknown` |
| `any` | always true | `JSON` | `any` |
Arrays: `type[]`. SQL stores as `JSON` (DuckDB native), TS is `T[]`.
**Nested-schema identifiers.** A field's type name may be another
schema declared with `:shape`, `:input`, or `:model`. When the name
resolves to one of those in the process-global `__SchemaRegistry`,
the validator recurses into the referenced schema:
```coffee
Address = schema :shape
street? ..200
city? ..100
User = schema :shape
name! ..50
address! Address # per-field validation recurses into Address
mailing? Address # optional; skipped if missing, validated if present
Order = schema :shape
items! OrderItem[] # arrays of schema-typed values validate each element
```
Errors from the nested validator surface with path-prefixed `field`
entries on the parent's issue list (`address.street`,
`items[0].name`, `items[3].price`, etc.). Validation recurses as
deep as the registry resolution allows — three-level nesting is
tested, deeper nesting is bounded only by the data itself.
The resolver is lazy — it runs at `.parse()` time, not at
declaration, so forward references between modules resolve as long
as both are loaded before the first validation call.
**Unknown identifiers.** If a type name isn't a built-in *and* isn't
in the registry at validation time, the field is accepted without a
runtime check (SQL defaults to `JSON`, TS uses the identifier
as-is). This keeps forward references from hard-failing and lets
user-defined enums or shapes compose incrementally.
---
## 23. Directives
### For any fielded kind
| Directive | Effect |
| --------------- | ----------------------------------------------------------------- |
| `@mixin Name` | Pull in the fields of mixin `Name` at Layer 2 normalization |
| `@ensure "msg"[, :field], (x) -> pred` | Cross-field refinement, optionally attributed to a field — see [§5](#refinement-ensure). Allowed on `:input` / `:shape` / `:model`; rejected on `:enum` / `:mixin`. |
| `@ensure! "msg"[, :field], (x) -> pred` | ASYNC refinement — the schema becomes async-validating (`parseAsync!` / `safeAsync!` / `okAsync!`) |
### `:union`-only
| Directive | Effect |
| ----------- | --------------------------------------------------------------- |
| `@on :field` | Names the discriminator field (required, exactly once). Constituents follow as bare schema names, one per line |
### `:model`-only
| Directive | Effect |
| ----------------------------- | ------------------------------------------------------------------- |
| `@timestamps` | Adds `created_at` + `updated_at` columns with `CURRENT_TIMESTAMP` defaults |
| `@softDelete` | Adds `deleted_at` column; `.destroy()` sets `deleted_at = now()` instead of DELETE. Queries (`find`, `where`, `all`, `first`, `count`) implicitly filter `deleted_at IS NULL`; escape hatches: `.withDeleted()`, `.onlyDeleted()`, `inst.restore!`, `inst.destroy! hard: true` |
| `@index [a, b, c]` | Composite index on the listed columns |
| `@index column` | Single-column index (same as `@index [column]`) |
| `@unique [a, b]` | Composite unique constraint on the listed columns |
| `@unique :column` | Single-column unique (or inline on the field: `column! type @unique`) |
| `@idStart N` | Seed value for the auto-id sequence in `.toSQL()` output (default `1`). Overridden per-call by `toSQL(idStart: N)`. |
| `@scope :name, -> body` | Named composable query scope — `this` is the builder; also `@scope :name, (args) -> body`. Installed on the model and the builder |
| `@defaultScope -> body` | Applied to every query unless `.unscoped()` is called. At most one per model |
| `@tableWas old_name` | Table-rename annotation for the schema differ — `rip schema make` emits `RENAME TO` instead of `DROP + CREATE`. Removable once the migration lands |
| `@belongs_to Target` | FK column `target_id` referencing `targets.id`, NOT NULL |
| `@belongs_to Target?` | Same, nullable |
| `@has_one Target` | Accessor `target()` returning one |
| `@has_many Target` | Accessor `targets()` returning array |
---
## 24. Hook reference
Twelve recognized hook names. On `:model` they bind into the lifecycle;
on other kinds they're plain methods.
| Hook name | When it runs |
| ------------------ | -------------------------------------------------------- |
| `beforeValidation` | Before field validation |
| `afterValidation` | After successful validation |
| `beforeSave` | Before INSERT or UPDATE (only on valid data) |
| `beforeCreate` | Before INSERT only |
| `afterCreate` | After successful INSERT |
| `beforeUpdate` | Before UPDATE only |
| `afterUpdate` | After successful UPDATE |
| `afterSave` | After INSERT or UPDATE |
| `beforeDestroy` | Before DELETE (or soft-delete UPDATE) |
| `afterDestroy` | After DELETE |
| `afterCommit` | After the outermost transaction commits — or immediately after save/destroy when no transaction is open |
| `afterRollback` | After rollback, for each instance saved/destroyed inside the rolled-back transaction |
Throwing from any hook aborts the operation and the exception propagates
to the caller.
---
## 25. Constraints
Each constraint on a field line is self-identifying by its token
shape. Multiple constraints combine on one field, separated by commas:
```coffee
name[!|?|#] [type] [constraint] [constraint] …
```
### The forms
The **type** slot accepts an identifier (`string`, `email`, etc.) or
a string-literal union; the **constraint** forms live after the type:
| Form | Slot | Meaning |
| -------------------- | ---------- | ------------------------------------------------------ |
| `"a" \| "b" \| …` | type | String-literal union (value must be one of the listed members) |
| `min..max` | constraint | Size (string/array length) or value range (numeric) |
| `[value]` | constraint | Default value (single literal in brackets) |
| `/regex/` | constraint | Pattern constraint (bare regex literal) |
| `{key: value}` | constraint | Attrs. Known keys: `{was: "old_name"}` — column-rename annotation for the schema differ |
```coffee
Example = schema
password! string, 8..100 # length range
age? integer, 0..120 # value range
role? string, ["guest"] # default
zip! string, /^\d{5}$/ # regex pattern
status? string, 3..20, ["pending"] # range AND default
sex? "M" | "F" | "U" # literal union
phase? "draft" | "active" | "done", [:draft] # union + default
```
### Range semantics by field type
| Field type | `min..max` means |
| -------------------------- | ----------------- |
| `string` / `text` / formatted-string types | string length |
| `integer` / `number` | numeric value |
| `array` (`T[]`) | array length |
| `date` / `datetime` / `boolean` | compile error — ranges don't apply |
| literal union (`"a" \| "b"`) | compile error — membership is the bound |
### Exactly-N
Use `n..n` for "exactly N":
```coffee
Fixed = schema
sex? 1..1 # single-character sex code
npi! 10..10 # NPI is exactly 10 digits
code! 6..6 # fixed-length code
```
Reads as "between N and N" which collapses to "exactly N."
### Open-ended ranges
Either endpoint may be omitted. The implicit meaning depends on the
modifier:
| Form | Modifier | Meaning |
| ---------- | -------- | ------------------------------------------------------ |
| `..N` | `?` | at most N, no minimum (empty string / negative OK) |
| `..N` | `!` | at most N, **implicit `min=1`** — required AND non-empty |
| `N..` | any | at least N, no maximum (the file-level `schema.defaultMaxString` pragma fills it if set) |
| `..` | — | rejected — at least one endpoint must be present |
The `!` + `..N` rule exists because required fields with `..N`
almost universally want `1..N` in practice (100% of required ranges
in production code today). Writing `..N` instead of `1..N` drops the
redundant `1` that the `!` modifier already implies:
```coffee
# These pairs mean the same thing:
Explicit = schema
firstName! 1..50
name! 1..100
email! 1..320
Sugar = schema
firstName! ..50
name! ..100
email! ..320
# But an explicit min always wins:
Zeroes = schema
admin! 0..50 # explicit min=0 stays (rare: required but empty allowed)
age! 0..120 # explicit min=0 stays (newborns are zero)
score! 0..100 # explicit min=0 stays (test score can be zero)
```
If the sugar would produce an impossible constraint (`! ..0` →
`{min:1, max:0}`), the compiler rejects it at parse time with an
error naming the conflicting sources.
Optional (`?`) fields with `..N` are a mirror-image rule: `..20`
means "no minimum" rather than implicit-zero, so the `?` case stays
open for integers (allows negatives) and strings (allows empty).
When an optional field must also be non-empty when present, write
the min explicitly: `phone? 1..20`.
### Literal values in the default bracket
The bracket `[…]` now holds a single value — the default. Values are
evaluated at compile time and must be literals:
- Numbers (including negative: `-10`)
- Strings (`"text"`)
- Booleans (`true`, `false`)
- `null`, `undefined`
- `:symbol` (compiles to the symbol's name as a string — useful for
enum defaults: `[:draft]` ≡ `["draft"]`)
Arbitrary expressions, identifier references, and function calls are
rejected at parse time with a clear error.
### Multi-line constraint lists
Trailing comma + indent continues the line:
```coffee
Account = schema
password! string,
8..100,
/[A-Z]/
```
This is the same rule Rip applies to any trailing-comma continuation.
### Migration from v1
Three bracket forms are retired in v2 in favor of shape-identifying
constraint forms. The compiler emits a migration diagnostic pointing
at the exact replacement:
```
name! string, [8, 100] → name! string, 8..100
name! string, [8, 100, 42] → name! string, 8..100, [42]
zip! string, [/^\d{5}$/] → zip! string, /^\d{5}$/
```
The single-value form `[a]` (default) is unchanged.
### File-level pragma: `schema.defaultMaxString`
A defensive ceiling on every VARCHAR-like field in the file. Fills
in `max` for fields that the user otherwise left unbounded:
```coffee
schema.defaultMaxString = 500
User = schema :model
name! # → {min: 1, max: 500} (sugar + pragma)
email! email @unique # → {max: 500}
code? # → {max: 500}
password! 8..200 # → {min: 8, max: 200} (explicit wins)
bio? text # → no constraint (text opts out)
zip! /^\d{5}$/ # → {regex: /^\d{5}$/} (regex opts out)
status? "on" | "off" # → literal union (union opts out)
```
**Scope and semantics:**
- **Top-level only.** Declaring the pragma inside a function / class
/ `if` / loop body is a compile error — the rule has to be
syntactically anchored to the file so it can't leak between
scopes.
- **Per-declaration snapshot.** Each schema captures the pragma
value in effect at *its* declaration. Later pragma writes don't
retroactively alter earlier schemas.
- **Applies only to VARCHAR-like primitives** — `string`, `email`,
`url`, `phone`, `zip` (and bare fields, which default to
`string`). `text` stays uncapped by design (it's the opt-out for
long-form content). `integer`, `number`, `boolean`, `date`,
`datetime`, `uuid`, `json`, `any` are all untouched.
- **User's explicit constraints always win.** An explicit range,
regex, or literal-union on the field suppresses the pragma's
max. Open-ended `N..` fields are the one composition case — the
user's min is preserved and the pragma fills the open max.
- **`0` resets the pragma.** Useful for turning it off again mid-file.
**Valid values:** non-negative integer literals. Decimals, strings,
negatives, and unknown keys are all hard-fail compile errors with
specific diagnostics.
The pragma is the first of a family — the scanner accepts `schema.`
generally and errors on unknown keys, so future ceilings (a
`defaultMaxInt`, an `defaultStringType`, etc.) can land additively
without changing the scanner shape.
---
## 26. Relations
### Directive → accessor → return type
| Directive | Accessor name | Returns |
| --------------------------- | --------------------- | ---------------------------------------- |
| `@belongs_to User` | `user()` | `Promise` |
| `@belongs_to User?` | `user()` | `Promise` + nullable FK |
| `@has_one Profile` | `profile()` | `Promise` |
| `@has_many Order` | `orders()` | `Promise` |
Accessor names:
- `belongs_to` / `has_one` use the target's name with a lowercase first
letter (`User` → `user`, `UserProfile` → `userProfile`).
- `has_many` pluralizes the lowercase-first-letter form (`Order` →
`orders`, `Category` → `categories`).
### FK columns
- `@belongs_to User` emits `user_id INTEGER NOT NULL REFERENCES users(id)`
- `@belongs_to User?` emits `user_id INTEGER REFERENCES users(id)` (nullable)
### Resolution
Targets resolve lazily through `__SchemaRegistry`. A target is looked up
by bare name when the accessor is first called — imports into the module
that declares the target (or the model file itself) are enough to make
resolution succeed. Unresolved targets throw a runtime error with the
name and the caller's schema name included.
### Memoization
Accessor results memoize per instance: the second `user.orders!` call
resolves from cache with no query. Eager loading (`.includes`) fills
the same memo, which is why preloaded relations are free. Pass
`{reload: true}` to bust the memo and re-query.
---
## 27. Design invariants
Sixteen rules that define how Rip Schema behaves. Worth keeping in mind
when debugging or extending:
1. **Default kind is `:input`.** `schema` with no marker and a
field-shaped body gets the most common validation case with no
ceremony.
2. **Fields use `name type`, not `name: type`.** The colon is reserved
for methods, computed, and eager-derived. Using the colon form
produces a compile error pointing at the right syntax.
3. **`:shape` has no lifecycle.** Hook names on `:shape` are methods —
no binding. Lifecycle is a `:model` concern because it's coupled to
persistence.
4. **Algebra on `:model` returns `:shape`.** ORM methods are stripped.
Invariant 1 of the algebra section.
5. **Algebra drops instance behavior but preserves field semantics.**
Methods, computed getters (`~>`), eager-derived fields (`!>`),
hooks, and `@ensure` refinements are dropped by
`.pick/.omit/.partial/.required/.extend`. Fields and their metadata
— including **inline transforms** — carry through. The transform
describes how a field's value is obtained from raw input; it's a
property of the field, not the instance.
6. **`:mixin` is non-instantiable.** Mixins declare fields for reuse —
they don't have a runtime identity of their own.
7. **Schema names are global, and collisions fail loudly.** Relations
and `@mixin` references resolve by bare name through a
process-global registry. Registering a name that already exists
with a *different* definition throws at registration time;
structurally identical re-registration (the same module arriving
twice) rebinds silently. `__SchemaRegistry.replace = true` restores
last-loaded-wins for dev/HMR reload; `__SchemaRegistry.scope(fn)`
runs `fn` against a fresh registry and restores the parent (test
isolation).
8. **Default field type is `string`.** Omitting the type slot is
legal; `name!` means "required string". Explicit types
(`integer`, `email`, `"M" | "F"`, etc.) are needed only when
string isn't what you want.
9. **Transforms are terminal on the field line.** `-> body` must be
the last element; nothing follows it. The comma before `->` is
required whenever anything precedes it (type, range, regex,
default, attrs) — only the bare form `name! -> body` is
comma-less, because there's nothing to elide.
10. **Transforms run on `.parse()` only, never on hydrate.** DB rows
arrive canonical; re-running a transform on hydrate would
double-coerce. Eager-derived (`!>`) is the opposite — it runs on
parse AND hydrate so instances loaded from the DB have the same
shape as parsed ones.
11. **Eager-derived fields are materialized once, not reactive.** `!>`
fires at construction time (parse or hydrate) and stores the
result as an own enumerable property. Mutating a dependency
afterward does **not** update the derived value — it stays stale
by design. Use `~>` for always-current derivations.
12. **Refinements are schema-level, not field-level by default.**
`@ensure` predicates run after per-field validation succeeds, once
per parse, against the whole defaulted and typed object. An
optional `:field` symbol attributes the failure to one input;
without it the issue is schema-wide (`field: ''`). They fail with
a declared message that ships verbatim to the caller; thrown
exceptions inside a predicate count as failure, not error.
Refinements are skipped on DB hydrate (trusted data) and dropped
by every algebra op (structural derivation never carries
non-structural invariants).
13. **Coercion is field semantics.** `~type` / `~:name` converts the
wire value in pipeline step 1, before defaults and validation —
constraints always see the coerced value. Like transforms,
coercion survives algebra and is skipped on hydrate. A failed
coercion is `{error: 'coerce'}`, distinct from `{error: 'type'}`;
an unregistered `~:name` is a loud config error, never a
validation failure.
14. **Async-validating schemas refuse the sync API.** One `@ensure!`
makes the whole schema async-validating: `.parse`/`.safe`/`.ok`
throw immediately ("use parseAsync!/safeAsync!/okAsync!") rather
than sometimes-returning a promise. Sync refinements run first;
async ones run concurrently; issues collect in declaration order.
15. **Unions dispatch, never merge.** `:union` resolves the
discriminator to exactly one constituent and delegates — the
result is that constituent's instance. Discriminator values must
be disjoint string literals (checked at first parse, lazily).
Algebra on a union throws; distribute-vs-intersect has no
obviously-right answer, so v1 declines to guess.
16. **The default scope applies at terminal time.** `@defaultScope`
composes into the query when a terminal (`all`/`first`/`count`/
`updateAll`/`deleteAll`) runs — so `.unscoped()` works anywhere in
the chain and the default's clauses never double-apply. `find`
routes through the builder, so the default scope and the
`@softDelete` filter apply to it uniformly.
---
# Part III — Architecture
## 28. Runtime architecture
Each schema goes through four layers. Each layer is built lazily on first
need, and the caches are independent.
### The canonical field parse pipeline
`.parse()` applies each declared field's value through a fixed sequence.
Knowing the order makes the difference between transform-before-default
(correct) and transform-after-default (surprising) predictable:
```text
For each declared field, in order:
1. Obtain raw candidate
— transform(raw) if declared, else raw[fieldName]
2. Apply default if the candidate is missing/undefined
3. Required / optional / nullability check
4. Validate per declared type
— literal-union membership, primitive type, array
5. Apply range / regex / attrs constraints
6. Assign as own enumerable property on the instance
After all declared fields:
7. Run `@ensure` refinements in declaration order
— reads the fully-typed, defaulted working object; every
refinement runs (no short-circuit); failures collect as
{field: '', error: 'ensure', message} issues; if any
fail, .parse() throws SchemaError, .safe() returns
{ok: false, errors}, and .ok() returns false
(steps 8+ do not run)
8. Run `!>` eager-derived entries in declaration order
— reads the now-populated instance; results land as own
enumerable properties; earlier `!>` values are readable
by later ones, forward references are not
```
The `_hydrate` path (used by `.find`, `.where`, etc.) **skips step 1's
transform, step 2's default, steps 3–5, and step 7's refinements** —
DB rows are trusted. It still runs step 8 so eager-derived fields
appear on hydrated instances just as they do on parsed ones.
### Value mutation after parse
Mutating a field after parse **does not re-run `!>` entries** — they
were materialized at parse time. Lazy computed (`~>`) values do reflect
the current state on every access. This distinction is the key
difference between the two arrows; see §5 for the side-by-side
comparison.
### Layer 1 — Descriptor
The object passed to `__schema({...})` at module load. Pure metadata plus
real inlined functions for methods, computed, and hooks. Cheap to build
— no validation, no registry lookups, no class generation.
```js
__schema({
kind: "model",
name: "User",
entries: [
{ tag: "field", name: "email", modifiers: ["!", "#"], typeName: "email", array: false },
{ tag: "directive", name: "timestamps" },
{ tag: "computed", name: "identifier", fn: function() { return `${this.name} <${this.email}>`; } },
{ tag: "hook", name: "beforeSave", fn: function() { this.email = this.email.toLowerCase(); } },
],
});
```
### Layer 2 — Normalized metadata
Built once per schema on first downstream need. Produces:
- a `fields` Map (field name → {required, unique, typeName, array, constraints})
- a `methods` Map, `computed` Map, `hooks` Map
- expanded mixin fields (depth-first, diamond-deduped)
- resolved relations with accessor names and FK column names
- table name (for `:model`)
- namespace-collision enforcement across fields, methods, computed,
hooks, relation accessors, and reserved ORM names
This is the shared pre-stage for the three downstream plans.
### Layer 3 — Validator plan
Built on first `.parse/.safe/.ok` call. Compiled validator tree plus (for
`:shape` / `:model`) the generated class constructor. Type-check
functions, constraint-check functions, required-field checks, array-item
walks, and enum-membership checks are all bound into tight closures at
this layer.
### Layer 4a — ORM plan
Built on first `.find/.create/.save/.destroy/.where` call on a `:model`.
Wires:
- the query builder (with scope methods, default-scope composition,
soft-delete filtering, and `.includes` eager-load resolution)
- save / destroy / restore flows (including the 12-hook lifecycle and
constraint-violation translation)
- relation accessors on the generated class (with per-instance
memoization)
- instance methods (`save`, `destroy`, `restore`, `ok`, `errors`,
`toJSON`)
- transaction routing — every statement checks the AsyncLocalStorage
slot for the schema's adapter and joins an ambient
`schema.transaction!` automatically
Requires a configured adapter before first use.
### Layer 4b — DDL plan
Built on first `.toSQL()` call. A canonical `_tableSpec` (columns,
indexes, FKs, sequence) renders to `CREATE SEQUENCE` / `CREATE TABLE` /
indexes + foreign keys for one model. The same spec is what the
migration differ (`rip schema status/make/migrate`) compares against
the introspected live database — DDL emission and diffing can't drift
apart because they share one source. Independent of Layer 4a — a
migration script that never touches the ORM builds this layer only.
### Lazy is the point
Module load does Layer 1 only. An `:input` schema used just for
`.parse()` never builds Layer 4. A migration script that only calls
`.toSQL()` never builds Layer 3 or 4a. A `:model` used only from the
API layer never builds Layer 4b. The four caches never share work they
don't have to.
### The registry
`__SchemaRegistry` holds every named `:model` and `:mixin` by bare name.
Registration happens in the `__SchemaDef` constructor — *importing a
file that declares named schemas activates them*. Re-declaring a name
with an identical structure is an idempotent no-op (double-import
safe); a *different* structure throws unless `replace` mode is on (HMR
and test runners opt in). Tests can also use `__SchemaRegistry.scope()`
for isolated registries or `.reset()` between runs.
### The adapter
Contract v2: `query(sql, params) → {columns, data, rowCount}` is the
one required method; `begin(options) → TxHandle` and a truthful
`capabilities` object are optional and feature-detected (§11). The
default adapter talks to a duckdb-harbor instance via `fetch`; named
adapters from `schema.connect(...)` bind individual models elsewhere
(`on:`). Custom adapters (for tests, in-memory mocks, alternate SQL
backends) install with `globalThis.__ripSchema.__schemaSetAdapter`.
Every ORM method funnels through this interface.
---
## 29. Compiler integration
The schema keyword is implemented as a compiler sidecar in
`src/schema/schema.js`, alongside the existing type and component sidecars.
This isolates the feature from the rest of the compiler: the main Rip
grammar has two productions for the schema keyword (not hundreds), and
the schema-specific body syntax never reaches the main parser.
### Lexer path
`installSchemaSupport(Lexer, CodeEmitter)` adds `rewriteSchema()` to the
lexer prototype. It runs between `rewriteRender()` and `rewriteTypes()`
in the rewriter pipeline.
`rewriteSchema()` recognizes a contextual `schema` identifier at
expression-start positions followed by either a `:kind` SYMBOL or a
direct INDENT. The matching `INDENT ... OUTDENT` range is parsed by a
schema-specific sub-parser and collapsed into a single `SCHEMA_BODY`
token whose `.data` carries a structured descriptor (kind, entries,
per-entry `.loc`).
### Grammar
The main grammar has one production for the feature:
```
Schema: SCHEMA SCHEMA_BODY
```
…under `Expression`. Schema body syntax (`name! type`, `@directive`,
`name: ~> body`, `name: -> body`) never reaches the main parser. The
state table stays lean.
### Body-token reparse
Bodies of methods, computed getters, and hooks are captured as token
slices by the sub-parser. At codegen time those slices:
1. run through the tail rewriter passes (implicit braces, tagged
templates, implicit call commas, etc.)
2. feed into the main `parser.parse()` via a temporary lex adapter
3. emerge as a normal Rip AST
4. wrap in a thin-arrow AST (`['->', [], body]`)
5. emit via the existing `emitThinArrow` path
Rip `->` already emits `function() {...}` (not JS arrow), so `this`
binds to the instance correctly without special codegen.
### Where things live
| File | Role |
| -------------------- | ------------------------------------------------------------------ |
| `src/schema/schema.js` | Sub-parser, `emitSchema`, Layer 1-4 runtime, shadow TS walker, `installSchemaSupport` |
| `src/lexer.js` | Hook point — calls `rewriteSchema()`; comment-token fix for `#` modifier |
| `src/grammar/grammar.rip` | The one `Schema` production |
| `src/compiler.js` | Dispatch for the `schema` s-expression head; preamble injection |
| `src/types.js` | Calls `emitSchemaTypes()` during `.d.ts` emission |
| `src/typecheck.js` | `hasSchemas()` probe so schema-only files aren't `@ts-nocheck`d |
| `test/rip/schema.rip` | The test suite |
The total wiring in the core compiler (outside `src/schema/schema.js`) is under
100 lines. That's the sidecar pattern working — the feature is big, but
its footprint in the main compiler is small.
---
## 30. FAQ
**Why not just use Zod?**
Zod gives you the validator. It doesn't give you the ORM, the
transactions, the migrations, the class, the computed getters, the
derived DTOs, or the OpenAPI document. Rip Schema is all of that from
one declaration. The validator alone now covers Zod's daily surface
too: strict coercion (`~integer`, `~:phone`), discriminated unions
(`schema :union`), sync and async refinements (`@ensure` /
`@ensure!`), and `safeParse`-style structured results — with shadow TS
indistinguishable from `z.infer<>` and no codegen step.
**Is this a full ORM replacement for Prisma / Drizzle?**
For the production shape — yes. `find`, `where`, `create`, `save`,
`destroy`, relations, hooks, validations, transactions
(`schema.transaction!`), eager loading (`.includes`), query scopes
(`@scope` / `@defaultScope`), soft deletes, upsert/batch writes,
structured constraint-violation errors, DDL, **and migration diffing**
(`rip schema status / make / migrate` — declared models vs the live
database, with checksummed history and rename annotations). The
remaining gaps are listed honestly in §20.
**How do transactions propagate without passing a handle around?**
AsyncLocalStorage. `schema.transaction! ->` pins a connection and binds
it to the async context; every ORM call inside the block routes through
it automatically — model code is unchanged. Nested calls join the outer
transaction; parallel transactions get independent contexts; per-schema
adapters get independent slots. See §9.
**What's the difference between `"a" | "b"` and `schema :union`?**
The literal union is a FIELD type — one value constrained to a set of
strings. `:union` is a SCHEMA kind — whole objects dispatched to
different constituent schemas by a discriminator field (`@on :kind`).
A single literal (`kind! "click"`) is the constant field that tags a
constituent.
**Does the runtime belong to `schema.js` or is it loaded separately?**
It's inlined. When a file uses `schema`, the compiler injects a small
preamble (under `SCHEMA_RUNTIME` in `src/schema/schema.js`) that defines
`SchemaError`, `__SchemaDef`, `__SchemaRegistry`, `Query`, and the
helpers. No import statement, no package dependency, no bootstrap call.
**How big is the runtime?**
It includes the validator plan, registry, hydration logic, ORM
support, and DDL emission. In multi-bundle processes, Rip binds
`schema` to a shared `globalThis.__ripSchema` singleton, so bundles
share one registry and one adapter per process.
**Is `.parse()` strict or permissive with extra keys?**
Permissive with stripping. Unknown keys are silently dropped — they
don't appear on the returned value or instance, and they don't cause a
validation error. This matches the invariant that `.parse()` returns
clean data shaped only by the declared fields. If you need hard
rejection of unexpected keys, check `Object.keys(input)` against
`Object.keys(Schema.parse(input))` yourself.
**Can I use a schema from TypeScript?**
Not yet directly — Rip emits shadow `.d.ts` for editor support, but a
separate `.ts` consumer doesn't see those. Exporting from `.rip` and
importing the result into `.ts` works: you get the runtime object; you
lose the algebra-level generic inference. This is on the roadmap.
**What happens when the adapter isn't configured?**
ORM methods throw a `SchemaError` with a clear "no adapter configured"
message. Validation (`.parse`, `.safe`, `.ok`) and DDL (`.toSQL`) work
without an adapter.
**Does `:model` require a database?**
No. `:model` works as a standalone class-with-validation. If you never
call an ORM method, no adapter is invoked. DDL emission is a pure
function of the schema definition.
**What's the relationship between `enum` and `schema :enum`?**
The keyword `enum` is a compile-time-only declaration — it exists in the
type system and disappears from JS. `schema :enum` exists at runtime —
you can call `.parse()` on it, iterate its members, and use it as a
field type. Use `enum` when you only need the static type; use
`schema :enum` when runtime membership matters.
**Can algebra operations (`.pick` / `.omit`) be chained?**
Yes. They compose: `User.omit("password").pick("name", "email").partial()`
produces a `:shape` with the intersection of the three operations.
**How do I express cross-field rules — "passwords must match", "end after start"?**
Use `@ensure`. See [§5](#refinement-ensure) and the summary in [§27](#27-design-invariants)
invariant 12. Messages are required, predicates are plain Rip fns,
thrown exceptions count as failure, and all refinements run every time
(no short-circuit between refinements).
**Can I put `@ensure` on a `:mixin` so it travels with the mixin's fields?**
No. `:mixin` is fields-only, by design. Refinements attach to the host
schema because they describe invariants on the whole parsed object,
and a mixin doesn't have a "whole object" of its own — it's a pile of
fields that get merged into the host. Put the refinement on the host
where the invariant has meaning.
**What does `:shape` have that a plain JS class doesn't?**
Runtime validation on construction. Computed getters automatically
typed in shadow TS. Fields are enumerable own properties (so `JSON.stringify`
works cleanly). Methods and computed getters live on the prototype (so
they don't pollute iteration). Algebra methods (`.pick`, `.omit`, etc.)
that derive new schemas. And the whole thing is one declaration.
**If I find a bug, what's authoritative — the docs or the compiler?**
The compiler. This document describes current behavior; when they
diverge, the compiler wins and the docs get fixed. File a diagnostic.
---
Schemas live at the core of almost every program. In Rip, one keyword
handles that core. Write the shape once, and the language does the rest.