-
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.
v1.1.0 As of 2026-08. Every command and every output on this page was executed against a create-ultimate@1.1.0 --no-example app.
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/actions/create-todo.ts + …/create-todo.test.ts
+ apps/web/app/todo/errors.ts
+ apps/web/app/todo/actions/archive-todo.ts + …/archive-todo.test.ts
+ apps/web/app/todo/live/todo-list.ts + …/todo-list.test.ts
+ apps/web/app/todo/jobs/reindex-todo.ts + …/reindex-todo.test.ts
+ apps/web/app/todo/service.ts + …/service.test.ts
+ apps/web/app/todo/ui.tsx ui.module.scss ui/todo-card.tsx ui/todo-form.tsx
+ packages/i18n/catalogs/en.json
+ apps/web/app/todos/page.tsx page.module.scss page.test.ts
✓ wrote 24 file(s) for resource todo
Drop --dry-run to write them. x g never clobbers — an existing file is X_GENERATE_CONFLICT, and the i18n catalog is merged key-by-key rather than overwritten.
| Generator | Emits |
|---|---|
x g resource <n> |
the whole slice above — 24 files |
x g entity / policy / action / mutator / query / job / task
|
that primitive plus its test |
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.
Full model: Policies and authz.
export const createTodo = action({
input: t.object({ id: t.uuid, orgId: t.uuid }),
output: t.object({ id: t.uuid, title: t.string }),
policy: canTodoWrite,
cache: { invalidates: [todoTag] },
mcp: { expose: true, description: 'create-todo — generated, edit the description' },
async handle({ input }) { … },
});orgId is in the input because the policy decides on it — authz reads the declaration, never the database.
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 |
create_todo — and tool().policy === createTodo.policy is true, 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 yes
createTodo create todos /api/todos/create todo:write yes
health invoke healths /api/healths/invoke public yes
Rename orgId in the declaration and every consumer fails typecheck. One rename, N errors, all real work. Every field: Actions.
x new writes no apps/web/api/index.ts. Without one, jobs and tasks register as anonymous-job-2 and anonymous-task-1, because export names are what name a primitive.
// apps/web/api/index.ts — importing this module IS the boot
import { defineApi } from '@ultimat3/action';
import * as archiveTodo from '../app/todo/actions/archive-todo';
import * as createTodo from '../app/todo/actions/create-todo';
import * as reindexTodo from '../app/todo/jobs/reindex-todo';
import * as todoList from '../app/todo/live/todo-list';
import * as health from './health';
export const api = defineApi({
actions: [health, createTodo, archiveTodo],
queries: [todoList],
jobs: [reindexTodo],
});
export type Api = typeof api;Import each primitive file, not the actions/ directory — the generator writes no index.ts in it. Two features exporting one name collide here 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 what the migration generator reads:
export { todo } from '@myapp/web/app/todo/entity';Now x verify sees drift, which is the point:
✗ drift 2ms
X_DB_DRIFT (packages/db/src)
cause: schema hashes to 92b6e21a9f3acc81, newest migration 0000_initial.hash recorded 164f6d3add24dcd0
fix: x db gen "describe the change"
2. x db gen does not work at 1.1.0. It shells out to drizzle-kit, which a scaffolded app neither installs nor configures:
X_DB_GEN_FAILED
cause: bunx drizzle-kit generate --name add_todos exited 1: No config path provided, using
default 'drizzle.config.json' … file does not exist
x db migrate fails identically. @ultimat3/db exports the framework's own generator, so a twenty-line script does the job — this one passes x verify:
// scripts/db-gen.ts — bun run scripts/db-gen.ts 0001 "add todos"
import { join } from 'node:path';
import { writeSchemaHash } from '@ultimat3/cli';
import { generateMigration, slugify } from '@ultimat3/db';
import * as schema from '../packages/db/src/schema';
const root = join(import.meta.dir, '..');
const ordinal = Bun.argv[2] ?? '0001';
const name = Bun.argv[3] ?? 'change';
const entities = Object.values(schema).map((entity) => entity.$describe());
const migration = generateMigration({ entities, name });
const id = `${ordinal}_${slugify(name)}`;
await Bun.write(
join(root, 'packages/db/migrations', `${id}.sql`),
`${migration.up}\n\n-- down\n${migration.down}\n`,
);
const hash = await writeSchemaHash(root, id);
await Bun.stdout.write(`${JSON.stringify({ ok: true, id, hash })}\n`);{"ok":true,"id":"0001_add_todos","hash":"92b6e21a9f3acc81"}
Two edits to the emitted SQL, both mechanical:
| Emitted | Why it fails | Fix |
|---|---|---|
create index "todos_org_id_created_at_idx" on "todos" ("org_id_created_at"); |
the composite index column list round-trips as one mangled name | spell the columns: ("org_id", "created_at")
|
create table …; and create index …; in one file |
the driver runs a migration's up as one prepared statement — cannot insert multiple commands into a prepared statement
|
one statement per migration file; split into 0001_… and 0002_…, each with its own .hash
|
Apply them the way production does — same code path, no toolchain:
ROLE=migrate bun apps/web/server.ts{"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
✓ all 17 steps passed in 11153ms
Four steps that were dashes in tutorial 1 are now ticks, from renaming four files.
bunx x mcp serve --transport http --port 922913 framework tools, one catalog, the same on stdio and http — 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. 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 — v1.1.0 As of 2026-08. Stable API, semver from here. MIT licensed.
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
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
Cross-cutting
Reference