sdxc
SearchCtrlK

[ 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:

FileWhat it is
app/models/post.tsthe Post model, a column per field, as ctx.models.post
database/migrations/<number>_CreatePosts.sqlthe CREATE TABLE for posts, numbered like the chain
database/factories/post.tsa factory for tests
app/http/validators/post.tsthe form's validator
app/policies/post.tswho may list, show, create, update and delete
resources/views/posts/form.tsxthe form the new and edit pages share
app/http/controllers/posts/*.tsxindex, new, show, edit and destroy
app/test/database.tsthe 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 likeTypeStored asEdited with
author_id, ending in _idreferencesa foreign keyrefused by scaffold, see below
published_at, ending in _attimestampan integer, epoch msa date-and-time field, read as UTC
born_on, due_date, birthdaydateYYYY-MM-DD as texta date field
is_public, has_paid, starting can_, should_, was_, allow_; published, active, enabled, archived, featured…boolean0 or 1a checkbox
view_count, price_cents; position, quantity, age, rank, priority, score, year…integeran integera number field
email, contact_emailemailtextan email field
website_url, website, homepageurltexta URL field, checked with URL.canParse
body, description, content, notes, bio, summary, excerpt, message…texttexta text area
anything elsestringtexta 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_at and updated_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 in hash or digest: 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 as author_id:string, or add it afterwards with sdxc 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:

  • --mcp writes the MCP tools list_posts, get_post, create_post, update_post and delete_post in app/mcp/tools/posts/, each with its input schema, declared in app/mcp/tools/index.ts and mapped in the MCP initializer.

  • --command writes the app commands posts list, posts show <id>, posts create with a flag per field, and posts delete <id> in app/commands/posts/, so sdxc 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:

app/policies/post.ts
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

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