Models & Fields
The field vocabulary, relations, and console metadata for `defineModel()`.
A model is defined once, with defineModel(), and drives everything else: the Postgres table, the Zod validators, the REST routes, and the console.
import { defineModel, field } from '@egig/ratchet/core';
export const Customer = defineModel('customers', {
fields: {
name: field.string({ required: true, maxLength: 255 }),
email: field.string({ required: true, unique: true, indexed: true, maxLength: 320 }),
},
console: { displayField: 'name' },
});The first argument ('customers') is both the table name and the /api/:model route segment — there's no auto-pluralization.
Field types
All fields accept the common options below, plus any type-specific ones.
| Field | Type-specific options |
|---|---|
field.string(opts) | maxLength?: number |
field.text(opts) | — |
field.integer(opts) | — |
field.decimal(opts) | precision: number, scale: number (required) |
field.boolean(opts) | — |
field.datetime(opts) | — |
field.enum(values, opts) | values is a non-empty array of string literals |
field.json(opts) | schema?: ZodTypeAny — validates the JSON payload |
field.reference(targetModel, opts) | targetModel: string — see Relations |
field.tree(opts) | self-referencing parent pointer — see Tree / hierarchy fields |
field.file(opts) | accept?: string, preview?: 'image', maxSize?: number — see Files & images |
Common options
interface FieldCommonOptions<T> {
required?: boolean;
default?: T;
unique?: boolean;
indexed?: boolean;
sensitive?: boolean;
writeAs?: string;
displayText?: string;
}requiredanddefaultare mutually exclusive — a field with a default is never absent, so declaring both throws at definition time.indexedgates whether a field can appear in?filter=or?sort=on the REST API (see REST API) — and, for astring/textfield, whether the console's list-view search box can find it (see Console).sensitivemarks a field as stored but stripped from every HTTP response — e.g. a password hash.writeAsis for a field written under a different, undeclared input key. For example, apasswordHashcolumn declareswriteAs: 'password'because a pipeline function (hashPassword) synthesizes the real column from a plaintextpasswordkey that never appears infields. The console form reads this to know which key to submit under.displayTextis the label shown for this field in console list-view column headers and form labels; when omitted it defaults to the field key humanized (e.g.roleId-> "Role Id").
Files & images
avatar: field.file({ preview: 'image' }), // accept defaults to 'image/*'
resume: field.file({ accept: 'application/pdf', maxSize: 5 * 1024 * 1024 }),A file field stores a reference ({ key, filename, mimeType, size }, jsonb), not the bytes — the actual blob lives in whatever flystorage FileStorage the app passes to createApiRouter(registry, db, storage). Under ratchet serve this is built automatically from an optional storage key in ratchet.config.ts:
export default defineConfig({
db: { connectionString: process.env.DATABASE_URL! },
storage: { driver: 's3', bucket: process.env.S3_BUCKET!, region: 'us-east-1' },
// driver: 'local' (default — no config needed) | 's3' | 'gcs' | 'azure'
});driver: 's3''s endpoint/forcePathStyle options also cover S3-compatible services (R2's S3 API, MinIO, DigitalOcean Spaces, Backblaze B2, ...), so one driver reaches most providers. Each cloud driver's adapter + SDK is a peer dependency behind its own subpath (ratchet/storage/s3, /gcs, /azure) — installing one doesn't pull in the others. Omitting storage entirely keeps today's zero-config default: local fs under <generatedDir>/storage. A deploy target that can't resolve credentials from plain config at load time (Cloudflare's R2 binding only exists inside a Worker's fetch handler) builds and injects its own FileStorage instead, the same way it builds its own console asset source — see example/deploy/cloudflare/worker.ts. Uploading is two steps:
POST /api/:model/:field/upload(multipart, form fieldfile) stores the blob and returns the reference.- That reference is sent as the field's own value on the normal
POST/PATCH /api/:modelcall.
accept (a comma-separated list of mime types/type/* wildcards) is checked against the upload's sniffed bytes, never the client-declared Content-Type. preview: 'image' turns on thumbnail rendering in the console and defaults accept to 'image/*' when accept is omitted. A record's own API response never exposes the raw storage key — it's rewritten to a url pointing at GET /api/:model/:id/:field, which streams the blob back after the same lookup GET /api/:model/:id does (so a soft-deleted record's file 404s too). Replacing a field's value deletes the old blob from storage after the write commits; a soft-removed record's files are left alone, matching how a soft-deleted row keeps its other data.
Only the is filter operator applies to a file field (?filter=[["avatar","is",null]] — has/doesn't have a file); there's no sort or equality, since the value is an object.
Relations
A field.reference(targetModel, opts) column must have a key ending in Id (e.g. customerId) — that suffix lets ?include= derive the relation name by stripping it:
export const Invoice = defineModel('invoices', {
fields: {
customerId: field.reference('customers', { required: true, indexed: true }),
// ...
},
});GET /api/invoices?include=customerNested/dot-chained includes (include=customer.company) are rejected, not silently truncated.
Tree / hierarchy fields
field.tree(opts) declares a parent-pointer hierarchy on the model itself — a Category whose
parentId points at another Category, a Chart-of-Accounts Account nested under a parent
Account, an org chart's Employee.managerId. It's a self-referencing field.reference() in
storage terms (a nullable uuid FK, key ending in Id), but declared as its own kind so the
console renders a tree-aware picker instead of a flat dropdown, and so writes get cycle protection
that a plain reference doesn't:
export const Category = defineModel('categories', {
fields: {
name: field.string({ required: true }),
parentId: field.tree({ indexed: true }),
},
console: { displayField: 'name' },
});A model may declare at most one field.tree() — defineModel() throws if a second one is added.
Unlike field.reference(), its target model is never passed explicitly (it's always the declaring
model itself) and it's never required/unique: a root node's parent is simply null.
GET /api/categories?include=parent
PATCH /api/categories/:id { "parentId": null } // promote to a root node?include= and ?filter=/?sort= (when indexed: true) work exactly like reference's. Every
write is checked for cycles before it commits — reparenting a node under itself or under one of its
own descendants is rejected with a TREE_CYCLE error rather than silently corrupting the
hierarchy. There's no built-in "fetch the whole tree" or "list descendants" endpoint; a consumer
app that needs one can fetch the full row set (GET /api/categories?limit=...) and assemble it
client-side from each row's parentId — which is exactly what the console's own tree picker does.
Like reference, this only guards a real hard delete (persist.hardRemove) — the normal
DELETE /api/:model/:id is a soft delete (it just sets deletedAt), so it never touches the FK
either way.
Many-to-many relations
field.manyToMany(targetModel, opts) declares a many-to-many relation, backed by an
auto-generated junction table rather than a column on either model:
export const Post = defineModel('posts', {
fields: {
title: field.string({ required: true }),
tags: field.manyToMany('tags'),
},
});Declare it once, on either side — both directions are queryable with no matching declaration
needed on Tag:
GET /api/posts/:id?include=tags # tags: Tag[]
GET /api/tags/:id?include=posts # posts: Post[] — reverse direction, `Tag` declared nothingUnlike reference, the relation is invisible on a bare GET — it only appears once
?include=d — and it's never in ?filter=/?sort= the normal way (there's no column). Instead
it gets one dedicated filter operator, has, which reuses the normal ?filter= group syntax for
AND/OR across multiple tags:
GET /api/posts?filter=[["tags","has","<tagId>"]]
GET /api/posts?filter=[["or",[["tags","has","<id1>"],["tags","has","<id2>"]]]] # either tagWriting is always "replace the whole set," never a per-tag patch — POST/PATCH /api/posts
accepts tags: string[] (the full desired list of target-row ids) and diffs it against the
current junction rows transactionally, alongside the rest of the write:
PATCH /api/posts/:id
{ "tags": ["<tagId1>", "<tagId2>"] }Soft-removing either side cascades: soft-removing a Post or a Tag soft-removes the junction
rows between them.
The junction table itself (named <sourceModel>_<fieldKey>, e.g. posts_tags) has no API or
console page of its own — POST /api/posts_tags doesn't exist. It's a real model internally (so
its shape is fully generated, migrated, and inspectable in schema.ts), but access to the
relation is governed entirely by Post's own create/update/read permission grants, the same
as any other field.
Not yet supported: a self-referential relation (targetModel equal to the declaring model),
explicit ordering on the relation, extra columns on the junction row beyond the two FK columns,
and creating a new target row inline from the console's tag-picker (it only picks from existing
rows) — an app that needs any of these can hand-write its own join model with two reference
columns instead of using field.manyToMany().
Operations
Every model gets create, update, and remove operations, each a pipeline. If you don't supply your own, the defaults are:
{
create: pipe(validate, persist),
update: pipe(validate, persist),
remove: pipe(persist.remove),
}Override any subset:
import { z } from 'zod';
import { defineModel, field, pipe, validate, persist } from '@egig/ratchet/core';
import { checkStock, applyDiscount, notify } from '../logic/invoice.js';
export const Invoice = defineModel('invoices', {
fields: {
customerId: field.reference('customers', { required: true, indexed: true }),
amount: field.decimal({ precision: 10, scale: 2, required: true }),
status: field.enum(['draft', 'sent', 'paid'], { default: 'draft', indexed: true }),
notes: field.text({ required: false }),
metadata: field.json({ schema: z.object({ source: z.string() }).optional() }),
},
operations: {
create: pipe(validate, checkStock, applyDiscount, persist, notify),
update: pipe(validate, checkStock, persist),
remove: pipe(persist.remove),
},
});Console options
interface ConsoleModelOptions {
hidden?: boolean; // excluded from the console sidebar and metadata endpoint entirely
label?: string; // sidebar/heading text; defaults to a capitalized `name`
displayField?: string; // field shown in reference dropdowns and list titles; defaults to the first string field, or 'id'
}See Console for how these are consumed — including how a model's folder location under modelsDir groups it into a Domain in the console sidebar.