[ The sdxc command ]
Scaffold a resource
Write the model, migration, pages, validator, policy and tests a resource needs to be listed, read, created, changed and deleted, with one sdxc scaffold command.
Last updated 2026-10-19
sdxc scaffold <name> [field ...] writes everything a resource needs to be listed, read,
created, changed and deleted through pages, each piece where
sdxc make would put it, with its test, declared and mapped:
sdxc scaffold post title body:text views:integer
Apply the migration and the resource is live:
sdxc run db migrate
sdxc routes -c posts
Then open /posts in bun dev.
What it writes
For sdxc scaffold post:
| File | What it is |
|---|---|
app/models/post.ts | the Post model, a column per field, as ctx.models.post |
database/migrations/<number>_CreatePosts.sql | the CREATE TABLE for posts, numbered like the chain |
database/factories/post.ts | a factory for tests |
app/http/validators/post.ts | the form's validator |
app/policies/post.ts | who may list, show, create, update and delete |
resources/views/posts/form.tsx | the form the new and edit pages share |
app/http/controllers/posts/*.tsx | index, new, show, edit and destroy |
app/test/database.ts | the in-memory database the tests use, when the project lacks one |
Every table also gets id, created_at and updated_at, so a field may not use those names:
-- Creates the posts table the Post model reads and writes.
CREATE TABLE "posts" (
"id" TEXT PRIMARY KEY NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"views" INTEGER NOT NULL,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL
);
It edits the files that make the pages answer: the model registry in app/models/index.ts, the
routes in routes/web.ts, their mapping in the router initializer, the sitemap's decisions, and
every resources/locales/*.json, which take each label and title under posts:
posts: {
index: get("/posts"),
new: form("/posts/new"),
show: get("/posts/:id"),
edit: form("/posts/:id/edit"),
destroy: post("/posts/:id/destroy"),
},
When the generated code imports an @sdxc package package.json lacks, such as @sdxc/uuid
for the ids or @sdxc/validate for the form, the scaffold adds it at your release and prints
the install command to run.
Fields
Each field is name or name:type, either with a trailing ? to make it nullable. Quote a
? field in zsh, which reads ? as a pattern:
sdxc scaffold event name starts_at 'ends_at?' contact_email website
A bare name takes its type from its name, by the first of these rules that matches, and an
explicit type always wins, as in email:string:
| A bare name like | Type | Stored as | Edited with |
|---|---|---|---|
author_id, ending in _id | references | a foreign key | refused by scaffold, see below |
published_at, ending in _at | timestamp | an integer, epoch ms | a date-and-time field, read as UTC |
born_on, due_date, birthday | date | YYYY-MM-DD as text | a date field |
is_public, has_paid, starting can_, should_, was_, allow_; published, active, enabled, archived, featured… | boolean | 0 or 1 | a checkbox |
view_count, price_cents; position, quantity, age, rank, priority, score, year… | integer | an integer | a number field |
email, contact_email | email | text | an email field |
website_url, website, homepage | url | text | a URL field, checked with URL.canParse |
body, description, content, notes, bio, summary, excerpt, message… | text | text | a text area |
| anything else | string | text | a text field |
The rules read the name's words, so published_at is a timestamp and published a box, and a
camelCase name such as publishedOn becomes the column published_on. An email field gets no
unique index; add one in the migration when the column must be unique.
A nullable field is nullable everywhere it appears: the column is .nullable(), the form field
is optional and a blank answer is stored as null, the MCP tool's input takes null, and the
command's flag is optional. A required field is required in all of them.
Some fields are refused, naming the spec, before anything is written:
id,created_atandupdated_at, which every table already has, and a name given twice.A nullable
boolean: a box is ticked or not, so drop the?.A scaffold with no field, or whose fields are all nullable: a form must have one field to fill.
Any name holding
password, unless it ends inhashordigest: a scaffolded form would store what the visitor typed.references, and a bare*_id: the scaffold writes no pages for a reference yet. Write it asauthor_id:string, or add it afterwards withsdxc make model, which writes a foreign key from the same field.
Names and plurals
The name is singular, such as post or blog-post, and holds no folders. The table, URLs,
folders and locale key take the plural: post becomes posts and category becomes
categories. Pass --plural for any other plural:
sdxc scaffold person name email --plural people
MCP tools and app commands
Two flags add more ways in:
--mcpwrites the MCP toolslist_posts,get_post,create_post,update_postanddelete_postinapp/mcp/tools/posts/, each with its input schema, declared inapp/mcp/tools/index.tsand mapped in the MCP initializer.--commandwrites the app commandsposts list,posts show <id>,posts createwith a flag per field, andposts delete <id>inapp/commands/posts/, sosdxc run posts create --title Hello …works.
Who may do what
The policy the pages ask is yours to tighten. As written, it lets an anonymous visitor take every action and a signed-in actor take every one, so the pages work the moment the migration is applied:
const ANONYMOUS_ACTIONS: ReadonlySet<PostAction> = new Set([
"list",
"show",
"create",
"update",
"delete",
]);
Remove the actions an anonymous visitor should not take before you deploy. A page the policy refuses answers 403.
Tests
The generated tests pass as written: they apply your migrations to the in-memory database and
drive the pages through the router, the tools through /mcp and the commands through the app's
command line. --no-test skips them for one run, and make.tests: false skips them
everywhere.
Taken names and dry runs
Everything is planned in a staged copy of the project before anything is written, so the declarations build on one another and a refusal anywhere leaves the project as it was. A file that already exists, or a locale that already has the plural's key, stops the command:
app/models/post.ts already exists; sdxc scaffold leaves it as it is
--dry-run prints every file in full and every edit as a diff, and writes nothing. An edit that
cannot be placed, such as a router laid out differently, is printed for you to make, as
sdxc make does. Once it writes, the format command from your configuration runs over every
file it wrote or edited.
Change what it writes
The scaffold's stubs are named scaffold.*.stub, one per file, such as
scaffold.controller.index.stub and scaffold.model.stub. Copy them into make.stubs and edit
the ones you want to change:
sdxc make stubs scaffold
Where to go next
Generate files — add one more file to a scaffolded resource, or a migration for a changed model with
--from-model.Run your app's commands —
sdxc run db migrateand the commands--commandadds.Model your tables — the model the scaffold writes.