-
Notifications
You must be signed in to change notification settings - Fork 0
The Eight Primitives
Everything in the framework is one of these. x g resource <name> scaffolds a whole feature slice — an entity + repo, a policy, two actions (create-*, archive-*), a live query, a job, an app route, plus service, UI and i18n files — each with a test that passes on the first run. Not one of each: the slice emits no mutator and no task. Those have their own generators.
entity — a table + its domain type + invariants
policy — an authz rule, evaluated in every surface
action — a mutation or command (server-authoritative)
mutator — an action with an optimistic local twin (offline/realtime)
query — a read; optionally live (subscribable)
job — durable background work, optionally multi-step
route — a URL + render mode + metadata + offline strategy
task — a scheduled trigger (cron) that enqueues jobs
| Primitive | Declared in | Generator | Deep page |
|---|---|---|---|
entity |
<feature>/entity.ts |
x g entity <name> |
Entities and migrations |
policy |
<feature>/policy.ts |
x g policy <name> |
Policies and authz |
action |
api/ or <feature>/actions.ts
|
x g action <name> |
Actions |
mutator |
<feature>/actions.ts |
x g mutator <name> |
Realtime |
query |
<feature>/live.ts |
x g query <name> |
Queries and live queries |
job |
<feature>/jobs.ts |
x g job <name> |
Jobs and workflows |
route |
a route folder's config export |
x g route <path> |
Routes and render modes |
task |
<feature>/jobs.ts |
x g task <name> |
Scheduled tasks |
A primitive's surfaces are methods on the primitive, never free functions taking it. publishPost.tool(), not toMcpTool(publishPost). Every declared field is lifted onto it, and the declaration object — the handle, the sql, the server — is not reachable from app code at all.
| Primitive | Its surface |
|---|---|
entity |
$-sigil members: posts.$view([...]), posts.$parse(row), posts.$assert(row), posts.$migration(). An entity is its columns, so the sigil keeps the namespace clear — posts.name is a column, posts.$name is the entity |
policy |
one signature, ({ actor, input, row, ctx }), identical on every surface. row is required and nullable, never smuggled through input
|
action |
.as() .tool() .openapi() .client() .job() .contract() .describe(), plus .input .output .policy .mcp. handle is not among them |
mutator |
everything action has, plus .local() .server() .conflict .describeMutator()
|
query |
.as() .live() .tool() .client() .describe(), plus .input .policy .cache .mcp .isLive. sql is not among them |
job |
.enqueue() .as() .describe(), plus .parse() and .idempotencyKeyFor()
|
route |
a normalized descriptor rather than methods — meta() always awaits, budget is always an object. A route declares no behaviour to project; describeRoutes() is the one route list |
task |
.entries() .enqueue() .describe()
|
The declaration lives in a private store inside the package that runs it, and nothing exports a reader for it. So there is one execution path and one authz path structurally, not by convention: a hand-rolled look-alike carrying the right kind is X_ACTION_FOREIGN / X_QUERY_FOREIGN, never a registered primitive. Registration stamps the export name in place, so import { publishPost } is the object that projects once the app has booted — there is no second, differently-named twin to remember.
Each primitive's deep page carries the full member table.
t is re-exported from every package that hosts a declaration factory. Import it from the package you declare in; never reach past that package to @ultimat3/schema. It is the same object, not a copy — configureSchemaProvider() still swaps the provider under it — and each package pins that with an identity assertion in its index.test.ts.
Import t from |
When the file declares |
|---|---|
@ultimat3/entity |
an entity
|
@ultimat3/action |
an action or a mutator
|
@ultimat3/query |
a query
|
@ultimat3/jobs |
a job or a task
|
@ultimat3/mail |
a defineMail template |
@ultimat3/mcp |
a defineAppMcp hand-written tool |
@ultimat3/ai |
an llm — a factory over action, so it carries t too |
@ultimat3/schema |
nothing — a plain view-schema module, where schema is the one import |
policy and route declare no schema, so neither package re-exports t: a policy decides from ({ actor, input, row, ctx }) and a route from its path.
A table + its domain type + invariants. The single source of the DB schema, the TS type, and the parse boundary.
| Aspect | Rule |
|---|---|
| Projects to | Postgres table, domain type, migration, repo (postgresRepo), admin screen, seed factory |
| Owns | column types, defaults, invariants, tenant column |
| Never | business logic, I/O, HTTP awareness, policy decisions |
An authz rule, evaluated in every surface.
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId))| Aspect | Rule |
|---|---|
| Projects to | HTTP guard, live-query row filter, job actor check, MCP tool gate, admin visibility |
| Owns | the yes/no and the denial reason |
| Never | mutate, query outside the declared repos, or return partial data (filter in the query) |
A mutation or command, server-authoritative. Declared once, projected six ways.
export const publishPost = action({
input: t.object({ postId: t.uuid, notify: t.boolean.default(true) }),
output: PostView,
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
cache: { invalidates: [tag.post, tag.feed] },
mcp: { expose: true, description: 'Publish a draft post' },
async handle({ input, ctx }) {
const post = await ctx.posts.publish(input.postId);
if (input.notify) await notifySubscribers.enqueue({ postId: post.id });
return post;
},
});| Aspect | Rule |
|---|---|
| Projects to | HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, contract test |
| Owns | input/output contract, its policy, what it invalidates, MCP exposure |
| Never | read headers or cookies, render, authorize inside handle, do slow work inline |
An action with an optimistic local twin. local runs client-side against the local store; server is authoritative. Same input, same name, both halves in one file.
export const likePost = mutator({
// Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
// equal applying it once — `likedByMe` is what makes the second application a no-op.
local(tx, { postId }) {
tx.posts.update(postId, (p) =>
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
},
async server(ctx, { postId }) { return ctx.posts.like(postId); },
conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
});| Aspect | Rule |
|---|---|
| Projects to | everything action does, plus a local-store transaction and a rebase entry |
| Owns | conflict strategy |
| Never | let local do I/O, randomness, or Date.now() — it must be replayable |
A read; optionally live (subscribable).
export const liveFeed = query({
input: t.object({ orgId: t.uuid }),
policy: can('feed:read'),
live: true,
sql: ({ orgId }) => db.posts.where({ orgId }).orderBy('createdAt').limit(50),
});| Aspect | Rule |
|---|---|
| Projects to | HTTP GET, typed client hook, live subscription (live: true), cache entry with tags, MCP read tool |
| Owns | shape + row-level filtering |
| Never | write, enqueue, or send mail. live: true requires a deterministic, bounded sql
|
Durable background work, optionally multi-step. idempotencyKey is required by the type.
export const onboardOrg = job({
input: t.object({ orgId: t.uuid }),
idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
retry: { attempts: 5, backoff: 'exponential' },
async run({ input, step, ctx }) {
const org = await step.run('provision', () => ctx.orgs.provision(input.orgId));
await step.run('welcome-email', () => ctx.mail.send(welcomeEmail, org));
await step.sleep('3d');
await step.run('nudge', () => ctx.mail.send(nudgeEmail, org));
},
});| Aspect | Rule |
|---|---|
| Projects to | queue row, per-step persistence, retry schedule, dashboard entry, MCP jobs.inspect tool |
| Owns | retries, steps, concurrency class |
| Never | assume it runs once — assume at-least-once. Durable business state lives in your tables, never only in the payload |
A URL + render mode + metadata + offline strategy.
export const config = defineRoute({
render: 'isr', // static | isr | ssr | stream | spa
revalidate: { tags: [tag.post] },
prerender: () => db.posts.slugs(),
offline: 'precache', // precache | runtime | network-only
hydrate: 'visible', // idle | visible | interaction | never
budget: { js: '40kb', lcp: 2000 },
meta: ({ post }) => ({ title: post.title, description: post.excerpt,
og: { image: post.cover }, ld: ld.Article(post) }),
});| Aspect | Rule |
|---|---|
| Projects to | router entry, prerender list, sw.js precache/runtime rule, sitemap + RSS row, <head> + JSON-LD, per-route budget check |
| Owns | render mode, hydration timing, metadata, offline strategy |
| Never | touch the DB directly, hold business logic, or omit meta.description in site/ — that is a build error (X_SEO_NO_DESCRIPTION; a missing title is X_SEO_NO_TITLE) |
Render modes: static (built once), isr (static + background regen), ssr (per-request), stream (shell flushed instantly, holes streamed — default for app pages), spa (shell only). Table in Routes and render modes.
A scheduled trigger. Enqueues jobs; never does work itself.
export const nightlyDigest = task({
cron: '0 3 * * *',
tz: 'UTC',
enqueue: () => [[sendDigest, {}]],
});| Aspect | Rule |
|---|---|
| Projects to | scheduler entry (advisory-lock leader), next-run introspection, a tasks row in x.manifest.json
|
| Owns | cron expression + explicit tz
|
| Never | contain a handler body. If it does work, it is a job
|
entity ──> policy ──> action ──> job ──> task
│ │ │
│ └──> query (live) ──> route
└──> mutator (action + local twin)
Feature slicing puts a feature's primitives in one folder, not one layer per app:
apps/web/app/<feature>/{entity,repo,service,actions,live,jobs,policy,ui}.ts
Every primitive emits its test beside it, and that test passes on the first run — a generator that emits a TODO has moved the work, not done it. What it pins is the distant invariant the primitive owns: a policy denial, an idempotency key, a budget. Extend it as the feature grows (Testing). Every primitive appears in x.manifest.json, so x manifest --json and the MCP manifest.read tool describe the whole app as data.
If a feature doesn't fit a primitive, it doesn't ship. No ninth primitive, no escape-hatch mode: option, no plugin API in 1.0. Removing an alternative is a feature — ambiguity is the tax agents pay. Source: docs/idea/02-primitives.md.
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