RatchetRatchet

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.

FieldType-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;
}
  • required and default are mutually exclusive — a field with a default is never absent, so declaring both throws at definition time.
  • indexed gates whether a field can appear in ?filter= or ?sort= on the REST API (see REST API) — and, for a string/text field, whether the console's list-view search box can find it (see Console).
  • sensitive marks a field as stored but stripped from every HTTP response — e.g. a password hash.
  • writeAs is for a field written under a different, undeclared input key. For example, a passwordHash column declares writeAs: 'password' because a pipeline function (hashPassword) synthesizes the real column from a plaintext password key that never appears in fields. The console form reads this to know which key to submit under.
  • displayText is 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:

  1. POST /api/:model/:field/upload (multipart, form field file) stores the blob and returns the reference.
  2. That reference is sent as the field's own value on the normal POST/PATCH /api/:model call.

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=customer

Nested/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 nothing

Unlike 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 tag

Writing 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.

On this page