-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial 02 First Feature
Scaffold the slice, register it, migrate it, name the tests. One action declaration becomes an HTTP route, an OpenAPI operation, a typed client method, a job handle, an MCP tool and three contract assertions — with no second file to keep in step.
As of 2026-08. Every command and every output on this page was executed against a create-ultimate@1.1.0 --no-example app, and has not been re-run on main. Step 2's migration workaround was removed on 2026-09-23: x db gen has worked since 2.0.0.
Series: 1 — first app · 2 · 3 — auth and admin · 4 — jobs and realtime · 5 — deploy free · 6 — growing up
bunx x g resource todo --dry-run + apps/web/app/todo/entity.ts
+ apps/web/app/todo/entity.test.ts
+ apps/web/app/todo/repo.ts
+ apps/web/app/todo/policy.ts
+ apps/web/app/todo/policy.test.ts
+ apps/web/app/todo/errors.ts
+ apps/web/app/todo/actions/create-todo.ts
+ apps/web/app/todo/actions/create-todo.test.ts
+ apps/web/app/todo/actions/create-todo.contract.test.ts
+ apps/web/app/todo/actions/archive-todo.ts
+ apps/web/app/todo/actions/archive-todo.test.ts
+ apps/web/app/todo/actions/archive-todo.contract.test.ts
+ apps/web/app/todo/live/todo-list.ts
+ apps/web/app/todo/live/todo-list.live.test.ts
+ apps/web/app/todo/jobs/reindex-todo.ts
+ apps/web/app/todo/jobs/reindex-todo.job.test.ts
+ apps/web/app/todo/service.ts
+ apps/web/app/todo/service.test.ts
+ apps/web/app/todo/ui.tsx
+ apps/web/app/todo/ui.module.scss
+ apps/web/app/todo/ui/todo-card.tsx
+ apps/web/app/todo/todo-form.island.tsx
+ apps/web/app/todo/todo-form.island.test.ts
+ packages/i18n/catalogs/en.json
+ apps/web/app/todos/page.tsx
+ apps/web/app/todos/page.module.scss
+ apps/web/app/todos/page.test.ts
+ apps/web/app/todos/page.e2e.test.ts
✓ would write 29 file(s) for resource todo — nothing written
The form is an island, not a ui/ component — todo-form.island.tsx beside the slice rather than under ui/. A createSignal needs the browser, .island.tsx is what puts a module in the client bundle graph, and the route's budget.js is what bounds it. Everything under ui/ renders on the server and ships no JS.
Drop --dry-run to write them. x g never clobbers — an existing file is X_GENERATE_CONFLICT; the i18n catalog is merged key-by-key rather than overwritten; and a slice module (entity.ts, repo.ts, policy.ts, errors.ts) the slice already has is skipped, --force included, because it belongs to the slice rather than to the generator that needed it. A resource run whose catalog merge gains no key writes 27: a merge that changes nothing is skipped rather than counted, and the catalog is the one mergeable file in the list.
No migration is in that list. x db gen is the only writer of packages/db/migrations, so a new slice is x g resource todo and then the two steps below.
| Generator | Emits |
|---|---|
x g resource <n> |
the whole slice above — 29 files, 31 with --admin
|
x g entity / policy / action / mutator / query / job / task
|
that primitive plus its test — and the slice modules its own source imports, when the slice has none: x g job is 5 files into a bare slice, x g action 8. Which ones differ per generator, so a job plants no policy.ts (CLI reference § x g) |
x g route <path> --surface site|app |
page.tsx, its stylesheet, its test, its catalog keys |
--surface site on a resource is refused: a slice ships a live query and a form with a signal, and site/ is the never-hydrated surface. Full flag table: CLI reference § x g.
The slice enforces one call direction, and x verify's boundaries step is what makes it real.
| Layer | File | May call |
|---|---|---|
| route | apps/web/app/todos/page.tsx |
actions, queries |
| action / query |
actions/*.ts, live/*.ts
|
services |
| service | service.ts |
the repo |
| repo | repo.ts |
db() — the only module that may touch the table |
export const todo = entity('todos', {
// Naming the tenant column is what turns tenancy on: a read with no org predicate then fails
// with X_TENANCY_UNSCOPED instead of leaking another org's rows.
tenant: 'orgId',
columns: {
id: uuid().primaryKey(),
orgId: uuid(),
title: text({ max: 200 }),
price: money(), // two physical columns: price_minor bigint + price_currency char(3)
createdAt: timestamp().defaultNow(),
},
invariants: (c) => [
invariant('todo_title_not_blank', c.title.trimmed().minLength(1)),
invariant('todo_price_non_negative', c.price.minor.atLeast(0)),
],
indexes: [{ on: ['orgId', 'createdAt'] }],
});c is typed from the columns above it, so c.titel is a compile error naming title — see tutorial 1.
Each invariant runs twice from one declaration — in the app on every write, and as a Postgres CHECK in the migration. Details: Entities and migrations.
export const canTodoWrite = can<TodoScope>(
'todo:write',
({ actor, input }) => actor !== null && actor.orgId === input.orgId,
);can() checks the grant first and the predicate second, so a denial distinguishes you may never do this from you may, but not in that org. The generated policy.test.ts pins the second gate with an actor who holds the grant and is still denied — delete the predicate and that test fails.
canTodoCreate is the same grant with the one tenancy rule a create can have — the actor has an org — because the row is written under the actor's org, never one named in the body.
The generator also grants what it declares, in apps/web/shared/roles.ts: todo:read to member, todo:write to admin (the dev actor's role). A permission an action requires and no role grants is X_PERMISSION_UNGRANTED on x verify's policy step — every request to it would answer 403.
Full model: Policies and authz.
export const CreateTodoInput = todo.$view(['title', 'price']);
export const createTodo = action({
input: CreateTodoInput,
output: TodoView,
policy: canTodoCreate,
cache: { invalidates: [todoTag] },
// No `mcp` until you write a real description: an agent reads it to decide to call the tool.
async handle({ input, ctx }) { … return service.create({ ...input, orgId }); },
});A real insert. The input is the entity's own view of the columns a caller supplies, so a column added to entity.ts is one edit to that list; orgId comes from ctx.actor, never from the body.
curl -X POST localhost:3000/api/todos/create -H 'content-type: application/json' \
-H 'sec-fetch-site: same-origin' -d '{"title":"Ship it","price":{"minor":1250,"currency":"USD"}}'Five artifacts, read off the real registry with createTodo.describe(), .openapi(), .tool(), .job() and .contract():
| Projection | Value, verbatim |
|---|---|
| HTTP route |
POST /api/todos/create, capability todo:write
|
| OpenAPI operation |
operationId: "createTodo", summary from mcp.description
|
| MCP tool | none until mcp: { expose: true, description } is written; then createTodo — the export name verbatim — and tool().policy === createTodo.policy, one authz object, not a copy |
| Job handle |
action:createTodo — the same handler, run through the queue |
| Contract tests | 3 generated assertions: garbage input rejected, anonymous denied, operation present in the spec |
| Typed client |
.client({ baseUrl }) derives the path by string math, so the browser imports no server code |
bunx x actions list name verb resource path capability mcp
archiveTodo archive todos /api/todos/archive todo:write no
createTodo create todos /api/todos/create todo:write no
health invoke healths /api/healths/invoke public yes
Rename a column in the entity and every consumer fails typecheck. One rename, N errors, all real work. Every field: Actions.
x new writes apps/web/api/index.ts, and x g resource, x g job and x g task add their jobs and tasks to it. A job no defineApi lists keeps the positional name job() gave it — anonymous-job-2 — and x verify's manifest step refuses that as X_JOB_UNREGISTERED. After x g resource todo:
// apps/web/api/index.ts, in an app made with `x new --no-example` — importing it IS the boot
import { defineApi } from '@ultimat3/action';
import * as reindexTodo from '../app/todo/jobs/reindex-todo';
import * as health from './health';
export const api = defineApi({
actions: [health],
jobs: [reindexTodo],
});Only jobs and tasks are added: the module scan registers actions and queries by export name on its own, and registers no job. Listing an action here too is allowed — x new's example slice does — and each primitive is imported as a file, never a directory. Two features exporting one name collide with X_ACTION_DUPLICATE rather than merging in silence.
x g resource writes no migration and does not touch the entity export list. Two steps.
1. Export the entity. packages/db/src/schema.ts is the db package's public surface — what the
rest of the app imports a todo from:
export { todo } from '@myapp/web/app/todo/entity';It is not what the migration generator reads. x db gen diffs the entity registry
(describeEntities()), and x verify's drift step hashes that same registry alongside the text
under packages/db/src/** — so the entity is already in the diff the moment its own file exists.
loadApp imports every module under apps/*/{site,app,api,shared}/** and packages/*/src/**, and
entity() registers on import. examples/dummy is the proof: it has entities, migrations and a
green drift step, and no packages/db/src/schema.ts at all.
Adding the export line moves the text half of the hash too, which is why the drift below is the same
either way:
✗ drift 2ms
X_DB_DRIFT (packages/db/src)
cause: schema hashes to 92b6e21a9f3acc81, newest migration 20260817120000_initial.hash recorded 164f6d3add24dcd0
fix: x db gen "describe the change"
A migration id is <stamp>_<slug> — the stamp is x db gen's own clock, so yours differs. The one it names here is the initial from tutorial 1; x new writes no migration, so on main that generate has already happened by the time you read this.
2. Generate the migration. x db gen diffs the entity registry against the newest
migration's snapshot and writes three files — <id>.sql, <id>.snapshot.json and <id>.hash:
bunx x db gen "add todos"The 1.1.0 workaround this step used to carry (a hand-written scripts/db-gen.ts, and two manual
edits to the emitted SQL) is gone: all three defects it worked around were fixed in 2.0.0, and
scaffold-smoke in CI runs x db gen then x db migrate on a fresh scaffold on every push.
Apply them the way production does — same code path, no toolchain:
ROLE=migrate bun apps/web/server.tsOn main, bunx x db migrate is that same code path with a --json report and a drift check
after it. Both read packages/db/migrations and write one x_migrations ledger.
{"ts":"2026-08-11T17:09:15.790Z","level":"info","msg":"ultimate migrate applied","applied":3,"available":3,"appVersion":"dev"}
The .hash sidecar beside each migration is what drift compares against, so a fresh clone detects drift with no database and no local state.
A test's type is its filename suffix, not the helper it calls. contractTest() inside create-todo.test.ts runs under unit, and x test contract reports X_TEST_NO_FILES.
| Rename | Moves into step |
|---|---|
create-todo.test.ts → create-todo.contract.test.ts
|
contract |
live/todo-list.test.ts → live/todo-list.live.test.ts
|
live |
jobs/reindex-todo.test.ts → jobs/reindex-todo.job.test.ts
|
job |
*.e2e.test.ts, anything under e2e/
|
e2e |
*.eval.test.ts |
eval |
bunx x verify ✓ typecheck 10026ms ✓ contract 234ms
✓ lint 179ms ✓ live 167ms
✓ boundaries 12ms ✓ job 128ms
✓ filesize 10ms - e2e 0ms
✓ package-shape 2ms ✓ eval 97ms
✓ errors 18ms ✓ drift 2ms
✓ unit 277ms - contract-diff 0ms
✓ budgets 0ms
✓ manifest 1ms
- roadmap 0ms
✓ 17 of 20 steps passed in 11153ms — 3 skipped: e2e, contract-diff, roadmap
Three steps that were dashes in tutorial 1 are now ticks, from renaming three files — and the summary names the three that still have nothing to run.
bunx x mcp serve --transport http --port 922918 tools, one catalog, the same on stdio and http — 13 framework tools (routes.list, schema.describe, policies.list, actions.describe, jobs.inspect, queue.depth, manifest.read, errors.explain, db.query, db.migrate, tests.run, verify.run, logs.tail) and five that look at the UI (ui.shot, ui.island, ui.inspect, ui.interact, ui.diff). bunx x mcp tools prints them with their scopes.
createTodo reaches an agent's tool list through the app's own surface in packages/mcp/src/index.ts (defineAppMcp({ include: 'exposed' })), carrying mcp: { expose: true } and the action's own policy as its authorization. Full model: MCP and AI.
Tutorial 3 — auth and admin: who the actor is, which roles grant which permissions, and the admin surface over the actions you just declared.
Related: Actions · Entities and migrations · Testing · Known gaps
Ultimate — v22.8.2 As of 2026-09. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Client data
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- Client navigation
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
- Static assets
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
- CI: the gate across parallel jobs
Reference