sdxc
SearchCtrlK

[ The sdxc command ]

Make a model

Write a model with a column per field typed by its name, the migration that creates its table and a factory for its tests, with sdxc make model, registered as ctx.models.

Last updated 2026-10-19

A model owns one table: its columns, and the rules that keep a row consistent. sdxc make model writes it in app/models/ with a column per field, the migration that creates its table and a factory for its tests, and registers it in app/models/index.ts, so every request, job and command reads it as ctx.models.<name>:

sdxc make model post title body published_at author_id
Wrote app/models/post.ts
Wrote app/models/post.test.ts
Wrote database/migrations/0001_CreatePosts.sql
Wrote database/factories/post.ts
Edited app/models/index.ts
Added @sdxc/uuid@2026.10.13 to package.json; run bun install
database/factories/post.ts leaves author_id out, since only a test knows which row it points at; pass it when creating one

Apply the migration and the table is ready:

sdxc run db migrate

Reach for it for every table the app stores. A whole resource, with its pages, validator and policy around the model, is one sdxc scaffold.

Flags

Argument or flagDefaultWhat it does
[field[:type][?]...]a column per field, after the name
--chain <chain>defaultthe chain in make.migrations the migration goes in
--skip <file,...>leave out migration, factory, or both; repeat it or list them
--test, --no-testmake.testswrite the model's test, or skip it
--dry-runprint the files and the diffs, and change nothing
sdxc make model post title --skip migration,factory

Every file is planned before any is written, so a model, migration or factory that already exists, or a table the chain already creates, stops the run with the project as it was.

Fields

A field is name, name:type or name?. A bare name takes its type from its name, an explicit type always wins, and a trailing ? on either part makes the column nullable:

sdxc make model post title 'subtitle?' views:integer

Quote a field with ? in zsh, which reads a bare ? as a pattern.

A bare nameTypeColumn
ends in _id: author_idreferencesc.text().references(…)
ends in _at: published_attimestampc.integer(), milliseconds since epoch
ends in _on or _date, or birthday, birthdatedatec.text(), YYYY-MM-DD
starts with is_, has_, can_, should_, was_, allow_, or names a state such as published, archived, active, draftbooleanc.boolean()
ends in _count or _cents, or names a count such as age, position, rank, score, yearintegerc.integer()
ends in emailemailc.text()
ends in url, or is homepage, websiteurlc.text()
ends in long text such as body, description, content, summary, notes, biotextc.text()
anything elsestringc.text()

The rules apply in that order, so published_at is a timestamp and published a box. sdxc make model --help lists them too.

A reference points at the table its model declares. author_id, or author:references, reads app/models/author.ts for its table and its id type, and falls back to the plural, authors, with a text id when the project has no such model. The foreign key is named <table>_<column>_fkey:

author_id: c.text().references("authors", "posts_author_id_fkey"),

Some names are refused, each with the reason:

  • id, created_at and updated_at, which every table already has.

  • A name listed twice, or one that does not start with a letter.

  • A nullable box, is_public?: a box is ticked or not.

  • A column holding a password as typed; name it password_hash or password_digest.

The model

app/models/post.ts
import type { TableRow } from "remix/data-table";

import { column as c, table } from "remix/data-table";
import { createModel } from "@sdxc/data-model";
import { generateUUID } from "@sdxc/uuid/v7";

/** The `posts` table, as its migration creates it. */
export const postTable = table({
	name: "posts",
	columns: {
		id: c.text().primaryKey(),
		title: c.text(),
		body: c.text(),
		published_at: c.integer(),
		author_id: c.text().references("authors", "posts_author_id_fkey"),
		created_at: c.integer(),
		updated_at: c.integer(),
	},
	/** `create` and `update` stamp `created_at` and `updated_at` with the database's clock. */
	timestamps: true,
});

/** One Post row as it reads back from the database. */
export type PostRow = TableRow<typeof postTable>;

export const Post = createModel(postTable, { generateId: generateUUID });

export default Post;

The model is named in the singular and its table in the plural snake_case: post declares posts, blog-post declares blog_posts, category declares categories. Each row gets a UUIDv7 id and the two timestamps. With no fields, the columns hold a reminder to add them:

		id: c.text().primaryKey(),
		// TODO: add the columns the migration creates
		created_at: c.integer(),

The migration

The migration is create-<table>, written as sdxc make migration create-posts … writes it, from the same fields, next in the chain:

database/migrations/0001_CreatePosts.sql
-- CreatePosts: creates the posts table.

CREATE TABLE "posts" (
  "id" TEXT PRIMARY KEY NOT NULL,
  "title" TEXT NOT NULL,
  "body" TEXT NOT NULL,
  "published_at" INTEGER NOT NULL,
  "author_id" TEXT NOT NULL,
  "created_at" INTEGER NOT NULL,
  "updated_at" INTEGER NOT NULL,
  CONSTRAINT "posts_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "authors" ("id")
);

The factory

The factory gives each field a sample value, so a test sets only the fields it is about:

database/factories/post.ts
import { defineFactory } from "@sdxc/data-model/testing";

import { Post } from "~/app/models/post";

/** A complete Post row; a test overrides only the fields it is about. */
export const PostFactory = defineFactory(Post, {
	title: ({ sequence }) => `Title ${sequence}`,
	body: ({ sequence }) => `Body ${sequence}`,
	published_at: Date.UTC(2026, 0, 1),
});

Text, string and email fields count up with the row's sequence, integers take the sequence, a box is false, a date "2026-01-01" and a URL https://example.com/<sequence>. A nullable reference is null. A required reference is left out, with the note shown above, since only the test knows which row it points at: pass it when creating one.

What it edits

The model is registered under its key, imported the first time a handler reads it:

--- a/app/models/index.ts
+++ b/app/models/index.ts
-export default createModels({});
+export default createModels({
+	post: () => import("./post"),
+});

The key is the file's last segment in camelCase: blog-post is ctx.models.blogPost, and a model in a folder, billing/invoice, is ctx.models.invoice.

The test

app/models/post.test.ts
import { describe, expect, test } from "vitest";

import { Post } from "~/app/models/post";

describe("Post", () => {
	test("is defined over its table", () => {
		expect(Post).toBeDefined();
	});

	test.todo("keeps a Post row consistent");
});
bun run test app/models/post.test.ts

Turn the test.todo into a test of each rule the model adds, against an in-memory database and rows from the factory.

Your own stubs

A model stub of your own needs name: "{{table}}" for the table and {{columns}} inside the table's columns, and a factory stub needs {{{attributes}}} as defineFactory's second argument; given fields and a stub without its placeholder, the command stops and says so. Generate files covers copying the stubs.

Fill it in

  1. Apply the migration with sdxc run db migrate.

  2. Add the rules about one row as methods on the model: scopes, callbacks, finders such as findBySlug.

  3. Read and write it from a handler through ctx.models.post:

let post = await ctx.models.post.create({
	title,
	body,
	published_at: Date.now(),
	author_id,
});

A column you add to the model later reaches the table through sdxc make migration <name> --from-model post. Data models covers scopes, callbacks, meta fields and transactions, and @sdxc/data-model the whole API.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.