@samveys/orm

Version
1.0.3Nov 5, 2026
Install
pnpm add @samveys/orm

From packages/orm/README.md in the framework repository.

Schema-first models for Samveys (design §8 to). It is built on @samveys/database and works on SQLite, PostgreSQL and MySQL (tested).

1. Define models

ts
import { Model, col } from '@samveys/orm'

export class User extends Model.define('users', {
  id: col.increments(),
  name: col.string(80),
  email: col.string().unique(),
  isAdmin: col.boolean().default(false),
  active: col.boolean().default(true),
  createdAt: col.timestamp().autoCreate(),
  updatedAt: col.timestamp().autoUpdate(),
}) {
  posts = this.hasMany(() => Post)

  static active = this.scope((query) => query.where('active', true))
}

export class Post extends Model.define('posts', {
  id: col.uuid().primary(),
  userId: col.integer().references('users', 'id', 'cascade'),
  title: col.string(120),
  publishedOn: col.date().nullable(),
}) {
  author = this.belongsTo(() => User, { foreignKey: 'userId' })
}
  • Types come from the columns. user.createdAt is a Temporal.Instant, and post.publishedOn is a Temporal.PlainDate | null. A wrong attribute name in where(), orderBy() or with() is a tsc error (tested).
  • Primary keys. col.uuid().primary() is a UUIDv7 generated on insert, so it sorts by creation time. col.increments() is an auto-increment integer.
  • Column names. Attribute names become snake_case column names (createdAt → created_at). Override one with .column('name').
  • Relations. These are hasMany, hasOne, belongsTo and belongsToMany.
    • hasMany/hasOne default to the foreign key <owner>Id.
    • belongsTo defaults to <relation>Id.
    • belongsToMany defaults to a pivot table role_user with columns role_id and user_id.

2. Query and load relations

ts
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createDatabaseManager, defineDatabaseConfig, sqlite } from '@samveys/database'
import { diffSchema, renderMigration, useDatabase } from '@samveys/orm'
import { Post, User } from './models.ts'

test('models', async () => {
  const db = createDatabaseManager(defineDatabaseConfig({ default: 'main', connections: { main: sqlite({ file: ':memory:' }) } }))
  useDatabase(db)
  // Build the tables straight from the models: the same path as `svs make:migration --diff`.
  const diff = await diffSchema(db, [User, Post])
  assert.equal(diff.createTables.length, 2)
  assert.match(renderMigration(diff), /schema\.createTable\("users"/)
  for (const { table, columns } of diff.createTables) {
    db.schema.createTable(table, (t) => {
      for (const column of columns) {
        const built = column.type === 'string' ? t.string(column.name, column.length) : t[column.type](column.name)
        if (column.nullable) built.nullable()
        if (column.unique) built.unique()
        if (column.default !== undefined) built.default(column.default)
        if (column.primary && column.type !== 'increments') built.primary()
      }
    })
  }
  for (const statement of db.schema.toSQL()) await db.rawQuery(statement)

  const ann = await User.create({ name: 'ann', email: 'ann@example.test' })
  await ann.related('posts').create({ title: 'Hello' })

  const users = await User.query().apply(User.active).with('posts').orderBy('name').get()
  assert.deepEqual(users[0]?.posts.map((post) => post.title), ['Hello'])

  const post = await Post.query().with('author').firstOrFail()
  assert.equal(post.author?.name, 'ann')
  await db.close()
})

Lazy loading is forbidden in local and test. Reading a relation that was not loaded with with() throws SVS_E080, and the message names the model and the relation, e.g. User.posts was read but not loaded. In production the access is logged and the relation reads as empty. Load explicitly with await model.load('posts').

3. Write safely

Mass assignment. create() accepts validator output, an object written in code, or the result of Model.fill(input, allowList). Passing any or unknown is a type error. fill() keeps only the listed attributes, so an isAdmin field from a request body is dropped (tested).

ts
const user = await User.create(User.fill(ctx.body, ['name', 'email']))

Constraint errors are validation errors.

  • A unique violation becomes SVS_E083.
  • A foreign key violation becomes SVS_E084.

Both are HTTP 422 with the field in errors, e.g. { "pointer": "#/body/email", "detail": "already taken" }, instead of a 500.

save() only writes changed attributes. Bulk query().update() and query().delete() skip timestamps.

4. Factories and generators

ts
import { defineFactory } from '@samveys/orm'

export const users = defineFactory(User, ({ sequence }) => ({ name: `user ${sequence}`, email: `user${sequence}@example.test` }))
await users.createMany(3)
await users.state({ isAdmin: true }).create()

Register orm() after database() in samveys.config.ts. It connects the models to the 'db' binding and adds two commands:

sh
svs make:model Invoice --migration     # app/models/invoice.ts + a create_invoices migration
svs make:migration add_paid_on --diff  # compares app/models with the database, writes what is missing
svs migrate

--diff writes one migration with the missing tables and columns. Columns that exist in the database but in no model are listed as warnings and never dropped.

5. Error codes

CodeWhen
SVS_E073findOrFail() / firstOrFail() found nothing (404)
SVS_E080An unloaded relation was read (local and test)
SVS_E081create(), make() or merge() got something that is not an object
SVS_E082An unknown attribute or relation name, a reserved attribute name, or a missing primary key
SVS_E083Duplicate value for a unique column (422)
SVS_E084Foreign key violation (422)