Models

Reading and writing rows, with hidden and guarded columns, scopes, soft deletes, computed fields and hooks.

A model wraps a table. Rows stay plain objects (there's no note.save()); the model is where you read and write them, and where the rules for a table live, so every query follows them.

src/models/note.ts
import { defineModel } from '@caldera/db'
import { notes } from '../db/tables/note'

export const Note = defineModel(notes, {
  name: 'note',
})

caldera make:model Note writes this and the table. Everything below is optional, added to that second argument.

Reading

The reads are Drizzle's relational queries: the same where, orderBy, limit, columns and with, fully typed.

const notes = await Note.findMany({
  where: { userId: user.id },
  orderBy: { createdAt: 'desc' },
  limit: 20,
})

const first = await Note.findFirst({ where: { title: 'Groceries' } })
const note = await Note.find(id) // by primary key, or undefined
const sure = await Note.findOrFail(id) // or a 404

findOrFail throws ModelNotFoundError, which a route answers with a 404. Route model binding does the same lookup for you.

Filters take operators as well as values:

await Note.findMany({
  where: {
    title: { in: ['Groceries', 'Errands'] },
    OR: [{ pinned: true }, { body: { like: '%milk%' } }],
  },
})

Pick columns to get only those back:

await Note.findMany({ columns: { id: true, title: true } }) // { id, title }[]

Pagination

const page = await Note.paginate({
  where: { userId },
  orderBy: { createdAt: 'desc' },
  page: 2,
  perPage: 20,
})
// { data, total, page, perPage, lastPage }

perPage defaults to 15, and is capped at 500.

Writing

const note = await Note.create({ userId: user.id, title: 'Groceries' })
await Note.createMany([{ userId, title: 'One' }, { userId, title: 'Two' }])

await Note.update(note, { body: 'Milk, eggs' })
await Note.delete(note)

create and update return the row as it's stored, with defaults and updatedAt filled in. update and delete take the row (from binding, say) or its id. An update with nothing to change does nothing.

Hidden columns

Columns that must never reach a browser, like a token, are hidden:

src/models/user.ts
export const User = defineModel(users, {
  name: 'user',
  hidden: ['apiToken'],
})

Every read leaves them out, including users loaded through a relation. They're missing from the types too, so user.apiToken doesn't compile. When you do need one, ask for it:

const user = await User.withHidden().find(id)
user!.apiToken

Guarded columns

Columns people mustn't set themselves, like a role, are guarded:

export const User = defineModel(users, {
  name: 'user',
  hidden: ['apiToken'],
  guarded: ['role'],
})

create and update don't accept them (role isn't in their types), and drop them if they arrive anyway, say in a request body spread into the call. Trusted code sets them with forceCreate:

await User.forceCreate({ name: 'Ada', email: 'ada@example.test', role: 'admin' })

Scopes

A scope is a named filter:

export const Note = defineModel(notes, {
  name: 'note',
  scopes: {
    pinned: () => ({ pinned: true }),
    ownedBy: (userId: string) => ({ userId }),
  },
})

Scopes chain, and combine with the query's own where:

await Note.ownedBy(user.id).pinned().findMany()
await Note.ownedBy(user.id).findMany({ where: { pinned: false } })

Soft deletes

With softDeletes, deleting a row sets a timestamp instead of removing it:

export const Note = defineModel(notes, {
  name: 'note',
  softDeletes: 'deletedAt',
})

The column must be in the table (deletedAt: integer({ mode: 'timestamp_ms' }), nullable). Then:

await Note.delete(note) // sets deletedAt
await Note.find(note.id) // undefined: reads leave deleted rows out
await Note.withTrashed().find(note.id) // found
await Note.onlyTrashed().findMany() // only deleted ones
await Note.restore(note) // back
await Note.forceDelete(note) // really gone

Deleted rows are left out of loaded has-many relations as well (with: { notes: true }).

Computed fields

appends are fields worked out from the row, added when you serialize it, which is what you do before sending rows to a page or as JSON:

export const Note = defineModel(notes, {
  name: 'note',
  appends: {
    excerpt: (note) => (note.body.length > 80 ? `${note.body.slice(0, 80)}…` : note.body),
  },
})
const notes = Note.serialize(await Note.findMany())
notes[0]!.excerpt

serialize takes one row or a list, and adds the appends of related models' rows too.

Hooks

Hooks run around writes:

export const Note = defineModel(notes, {
  name: 'note',
  hooks: {
    creating: (values) => ({ ...values, title: values.title.trim() }),
    deleted: (note, { soft }) => console.log(soft ? 'trashed' : 'deleted', note.id),
  },
})
HookWhen
creatingBefore the insert.Returns the values to insert.
createdAfter.Gets the row.
updatingBefore the update.Gets { before, patch }, returns the patch.
updatedAfter.Gets { before, patch, after }.
deletingBefore a delete.Gets the row.
deletedAfter.Gets the row, and { soft }.

The "-ing" hooks run inside the write, so throwing in one stops it. The "-ed" hooks run once the transaction commits, so a job they queue never sees data that was rolled back. Raw Drizzle writes skip hooks.

Route keys

Binding finds rows by primary key. To use another column in URLs, like a slug, set routeKey:

export const Post = defineModel(posts, { name: 'post', routeKey: 'slug' })

Seeing a model

pnpm exec caldera model:show Note

prints its columns, relations, hidden and guarded columns, scopes, appends and hooks.

On this page