Entities And Migrations - developerz-ai/ultimate GitHub Wiki

Entities and migrations

An entity is a table + its domain type + its invariants. The single source of the DB schema, the TS type, and the parse boundary.

Aspect Rule
Projects to SQL DDL, domain type (typeof posts.$row), migration, repo type, admin screen, seed factory
Owns column types, defaults, invariants, tenant column
Never business logic, I/O, HTTP awareness, policy decisions

One entity() call per table, in packages/db/src/schema/<name>.ts; a feature's own entity.ts holds only that feature's view schemas. Migrations sit beside the entities in packages/db/migrations/ as plain SQL. Neither holds any business logic. Reads and writes go through @ultimat3/entity's own postgresDriver(), which compiles a query plan to parameterised SQL โ€” no ORM in the request path (pg-driver.ts).

Six projections

Projection Where it lands Consumed by
SQL DDL the generated migration โ€” columns, CHECKs, indexes Postgres
Domain type export type Post = typeof posts.$row actions, queries, Solid component props
Migration packages/db/migrations/*.sql x db migrate, ROLE=migrate
Repo type the feature's repo.ts signature ctx.<service> inside handle
Admin screen apps/admin/ operators, and the admin app's MCP surface
Seed factory seed(name) fixtures all six test types

One inferred chain, no hand-typed link:

entity('posts', { columns })  โ†’  typeof posts.$row + invariants  โ†’  action input/output  โ†’  typed client + MCP tool  โ†’  component props

Rename a column and the entity type changes, the action's output stops matching, and the component prop errors โ€” all at typecheck, before a test runs.

Shape

As of 2026-08:

export const posts = entity('posts', {
  columns: {
    id: uuid().primaryKey(),
    orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }).tenant(),
    title: text({ max: TITLE_MAX }),
    body: text(),
    status: enumerated(POST_STATUSES).default('draft'),
    likeCount: integer().default(0),
    publishedAt: timestamp().nullable(),
    createdAt: timestamp().defaultNow(),
    updatedAt: timestamp().defaultNow().onUpdateNow(),
  },
  tenant: 'orgId',   // said out loud; inferred from `.tenant()` or an `orgId` column if omitted
  invariants: (c) => [
    invariant('post_title_present', c.title.trimmed().minLength(1)),
    invariant('post_like_count_non_negative', c.likeCount.atLeast(0)),
  ],
  indexes: [{ on: ['orgId', 'publishedAt'], order: 'desc' }],
});

export type Post = typeof posts.$row;

The entity name is the first argument. Everything else is the init object:

Field Meaning
columns types + defaults + FKs. Money is bigint minor units + char(3) currency, never a float; timestamps are timestamptz, stored UTC
table the physical table, when it is not the entity's own name. For a schema this framework did not generate โ€” see Adopting an existing database
tenant the tenant column. Omitted, it is inferred from .tenant() or a column named orgId โ€” silence never means unscoped
invariants (c) => [invariant(name, expr), โ€ฆ] โ€” named predicates enforced on write, projected to a CHECK or UNIQUE constraint where expressible. c is typed from columns, so a typo is a compile error
indexes composite and partial indexes; a single unique or indexed column declares it on the column instead
primaryKey composite keys only โ€” a single key is .primaryKey() on the column
tags extra cache tags this entity participates in, beyond its own entity:<name>

Presence of a deletedAt column is what makes an entity soft-deletable โ€” not a flag.

Which invariants reach the database

"Where expressible" is a rule, not a hedge. An invariant reports a sql: string or sql: null, and $describe() tells you which โ€” a rule the database does not know is a rule a bulk UPDATE from a migration or a psql session can violate.

Written as Reaches SQL Note
c.title.trimmed().minLength(1) ยท c.likeCount.atLeast(0) ยท c.email.contains('@') ยท c.a.eq(c.b) ยท c.flag.isTrue() ยท c.publishedAt.isNull() / .isNotNull() yes CHECK. trimmed() strips leading and trailing spaces only, as one-argument btrim does โ€” a tab is a character on both sides. atLeast / eq(<number>) compare the number the column holds, so a bigint() or decimal() row (a decimal string) is judged by its digits
iff(c.status.eq('published'), c.publishedAt.isNotNull()) yes (a) = (b) โ€” a rule that holds exactly when another does. The one combinator: and/or/not are deliberately absent
c.unique(['orgId', 'userId']) yes UNIQUE, not a CHECK โ€” a single row cannot see a duplicate
c.slug.matches(SLUG_PATTERN) โ€” a RegExp yes slug ~ 'โ€ฆ'
c.slug.matches(isValidSlug) โ€” a function no sql: null
c.satisfies(fn, ['status', 'publishedAt']) no sql: null, by construction โ€” a JS function over several columns

iff renders =, not IS NOT DISTINCT FROM, and that is not an oversight. Every operator in this language is false on a null operand in TypeScript, and = between two booleans where either is NULL yields NULL โ€” which a CHECK passes. The total form fixes that row and breaks a worse one: with a partial operand it makes Postgres refuse a row TypeScript accepted, which reaches the caller as a raw 23514 instead of X_INVARIANT_VIOLATED. = keeps the disagreement in the direction where the app refuses first, so no write reaches a CHECK that would have refused it. Where both operands are total โ€” a non-nullable column, an IS NOT NULL โ€” the two spellings are the same predicate, measured.

Pass the pattern, not a function wrapping it. matches(isValidSlug) and matches(SLUG_PATTERN) read almost identically and only one of them reaches a constraint.

Nothing is translated. pattern.source is the string .test() runs and the string spliced into the CHECK โ€” one string, two engines, so they cannot drift. That is only safe because the constructs where the two engines disagree are refused at declaration:

Refused Because Write instead
\b Postgres reads it as BACKSPACE โ€” 'foo' ~ '\bfoo' is false a predicate; the rule stays app-only
. matches \n in Postgres, not in JS [^\n\r]
\w \W locale alphanumeric โ€” 'รฉ' ~ '^\w$' is true [A-Za-z0-9_]
\s \S '\u00a0' is whitespace to JS, not to Postgres [ \t\n\r\f\v]
\A \Z anchors in Postgres, letters in JS ^ $
\1โ€“\9, (?<name>โ€ฆ), (?i), [[:alpha:]], a non-ASCII range endpoint backreference, named group, embedded flag, POSIX class, collation-ordered range a predicate

The refusal is X_INVARIANT_VIOLATED at entity() time โ€” before a migration exists โ€” and names the construct, its index, both readings, and the portable spelling where there is one. The subset is measured against a live server and re-measured by its own tests, so a future Postgres that closes a gap turns those tests red rather than leaving a stale exclusion in place.

The fluent surface

Every projection is a method on the entity โ€” posts.$view(['id', 'title']), never view(posts, ['id', 'title']) โ€” and every declared field is lifted onto it under a $. An entity has no .def.

Member Is Rule
posts.title a column reference the columns are the entity's own properties, which is what forces the $ sigil onto every framework member
$row the phantom that carries the row type export type Post = typeof posts.$row. Reading it as a value throws X_INVARIANT_VIOLATED โ€” a type was meant
$schema the Standard Schema the columns already describe forms and actions hand input to it; the row shape is never declared a second time
$parse(value) the parse boundary fills declared defaults, then validates every column through the column that owns it. Throws on a bad value
$view(keys) the row projection const PostView = posts.$view(['id', 'title']) โ€” what an action names as its output. An unknown key is a compile error, and X_INVARIANT_VIOLATED at declaration for a JS caller
$assert(row) every invariant, run called by the repo on insert and on update; reports every failing invariant at once, so one round trip fixes all
$describe() the manifest row name, table, primary key, physical columns, invariants, indexes (name and column list, uniqueness, predicate, direction โ€” a name cannot be parsed back into columns), tags, cacheTag, softDelete, orgScoped
$cacheTag entity:<name> the string @ultimat3/cache invalidates by
$tagFor(id) entity:<name>:<id> row-level invalidation for live queries
$tenantColumn the tenant column's property key, or null presence is what turns tenancy on โ€” resolution order under Tenant column rule below
$name $table $columns $primaryKey $indexes $invariants $tags $softDelete the declaration, lifted readable. $table is $name verbatim โ€” the first argument to entity() is the physical table name, never pluralised or snake-cased for you

entity is the one primitive whose surface is $-prefixed, and the type says why: type Entity<Row, C> = EntityCore<Row, C> & C โ€” the entity is its columns. posts.title is a column reference, so an unprefixed member would make view, name or tenant an illegal column name; with the sigil, a column called name and the entity's own name coexist as posts.name and posts.$name. action, query and job carry no sigil because nothing is merged into them. Reaching for posts.view([โ€ฆ]) after reading Actions does not typecheck โ€” there is no such member, only $view, and there is no free view(entity, keys) to import either. Nothing is hidden the way an action's handle is: an entity has no body to protect, so the whole declaration reads back, and the sigil protects the column namespace instead. Registration is not a second step โ€” entity() registers itself as it is declared, which is why a duplicate name is X_ENTITY_DUPLICATE at import rather than a silent last-one-wins.

$view() is what closes the type chain. output: posts.$view(['id', 'title', 'excerpt']) returns a Standard Schema over Pick<Row, K>, so renaming a column stops the key list itself from compiling, and the action's output type and every consumer of it fail in the same pass โ€” not at runtime as a missing field. It is also introspectable, As of 2026-09-05: the view carries the same schema IR a t.object does, so that output: reaches openapi.json, the MCP tool's outputSchema and the typed client with each column's own constraints (text({ max: 2000 }) publishes maxLength: 2000). Until then a view was refused at registration with X_SCHEMA_UNSUPPORTED and every app kept a hand-copied t.object beside its entity โ€” a copy that had already drifted in the reference app. What it catches is the key list and the projected type; a value is still checked at runtime, by the column that owns it rather than by a second copy of its rule. A view never invents a default: it projects a row that already exists, so an absent required column is missing data.

Tenant column rule

Every multi-tenant entity declares tenant. Every query against it is tenant-scoped or it fails:

X_TENANCY_UNSCOPED: query is not scoped to a tenant
  cause: select on "posts" has no predicate on tenant column "orgId"
  fix:   query through posts repo (ctx.posts.*) โ€” it applies the tenant scope from ctx.actor

Which column that is, in order โ€” first match wins:

Order Source Detail
1 tenant: 'workspaceId' the declaration, said out loud. It need not be named orgId and need not carry .tenant(). Declared, it wins outright โ€” a .tenant() mark elsewhere is not a conflict, just not the winner
2 .tenant() on a column the first column marked, in declaration order
3 a column named orgId literal property name, no mark needed
4 none of those $tenantColumn is null and the entity is not tenant-scoped

Omitting tenant keeps steps 2โ€“4, so silence never means unscoped. A tenant key naming no column is a declaration error โ€” because the alternative is a silently unscoped table. The error lists the columns to pick from and both edits that resolve it, so the declaration is repaired without opening the entity file:

X_INVARIANT_VIOLATED: a domain invariant rejected this row
  cause: posts.tenant: tenant: 'workspaceId' names no column โ€” pick from: id, title, body, orgId
  fix:   set tenant to one of id, title, body, orgId in entity('posts'), or remove the tenant key โ€” inference then takes the .tenant() column, else one named orgId

Removing the key is a real option, not a hedge: inference (steps 2โ€“4) still applies, so the table stays scoped whenever a column is marked .tenant() or named orgId.

Consequence Detail
Repo methods inject the tenant predicate from ctx.actor.orgId; a raw db.* call in a service is a boundary error
Cache keys framework-generated from the query name, its parsed input and its tags โ€” never hand-built. The tenant reaches the key through the input, so a cache: read must take it there (Caching and invalidation)
Live queries the tenant predicate is part of the matcher, not a post-filter
Vector search tenant + policy filters applied in SQL, so similarity search cannot leak across tenants

The repo

A feature's repo.ts is the only module that touches the database, and every statement in it goes through the typed handle. As of 2026-10.

// apps/web/app/widget/repo.ts โ€” as `x g entity widget` writes it
import { db } from '@myapp/db';
import type { Widget } from './entity';

export async function byId(id: string): Promise<Widget | undefined> {
  return (await db.widgets.where({ id }).one()) ?? undefined;
}

export async function list(limit = 50): Promise<readonly Widget[]> {
  return db.widgets.orderBy('createdAt', 'desc').limit(limit).all();
}

export async function insert(row: Omit<Widget, 'id' | 'createdAt'>): Promise<Widget> {
  return db.widgets.insert(row);
}
// packages/db/src/client.ts โ€” the handle. `x g entity` adds the import and the line.
const entities = {
  posts: post,
  widgets: widget,
};

export const db = database(entities, { driver });
The handle owns So the repo never writes
tenancy โ€” every read runs under the actor's org an org parameter, a .where({ orgId }), an org_id = โ€ฆ in SQL. No function in a generated repo takes or names an org. A read with no actor is X_TENANCY_UNSCOPED; an actor with no org is X_TENANCY_ACTOR_ORG_REQUIRED; a row written for another org is X_TENANCY_ACTOR_MISMATCH
codecs โ€” money, timestamps, sealed columns row decoding, a column list, money's three columns
paging โ€” .limit(n), then .after(cursor).page() an offset, or a paging module
the transaction โ€” inside withTransaction the handle is the transaction a client argument
Raw sql Where
belongs a query's source, built with from(); a migration
is the exception a repo.ts, for what the handle cannot say: a join, an aggregate, a CTE, a window
is refused a repo.ts, for select * / insert / update / delete on one table
Enforced by What fails
tsc db.widgets exists only when widget is in the set; a renamed column is an error in the repo
guards/repo-raw-sql.ts โ€” X_REPO_RAW_SQL, boundaries step a sql literal in a repo.ts the handle can express; the fix: is the handle call
X_DB_HANDLE_UNREGISTERED โ€” x g entity, x g resource a client.ts with no const <set> = { โ€ฆ } passed to database(), or an index.ts that does not export the handle; the fix: is the lines to add

The generated repo.test.ts runs all three functions against the in-memory driver, inside runWithContext โ€” the actor is what scopes the read, so a test names one, and asserts that an actor in another org reads nothing with no org named in any call. Row equality is expect(row).toEqualRow(other): a sealed column is a non-enumerable property, which toEqual skips.

After x g entity <name>: bunx x db gen "create <table>", then bunx x db migrate. The generator prints both, with bun install first when it added a workspace dependency (CLI reference).

A feature with more than one table: x g entity <second> --feature <feature> writes entity-<second>.ts + repo-<second>.ts (and a test beside each) next to the first entity's entity.ts, and registers it on the handle the same way (As of 27.6.2). One file per table; x db gen picks every one up from the registry.

Point lookups batch themselves

findById called several times in one microtask of one request is one statement, As of 2026-08:

// One `select โ€ฆ where "id" in ($1, $2, $3, โ€ฆ)`, not one statement per post.
const authors = await Promise.all(posts.map((post) => users.findById(post.authorId, { orgId })));

Nothing to opt into โ€” no dataloader(), no batch(). findById keeps its signature and its meaning, which is the only way the fix reaches code that is already written.

Property Behavior
Window one microtask, closed before the statement goes out. A sequential for โ€ฆ of loop shares no microtask โ€” its await ends the window โ€” so what batches one is the page it is looping over (below)
Lifetime one request. The batch is keyed by context identity, so it dies with the request and never crosses one
Never shared with another tenant, another soft-delete visibility, another projection, another entity, another client. The coalesced statement has to be one each single lookup would have been served by
Scope the tenant predicate and deleted_at is null are inside the statement, so an id that is missing, soft-deleted or another tenant's still reads as null โ€” never another caller's row
Wide batches past 500 ids, several whole statements rather than one Postgres refuses for its bind count
Outside a request a job, a script, a migration: the single statement it always was

A page batches the loop it causes

A for โ€ฆ of loop awaits between iterations, so no two of its lookups share a microtask. The page they came from batches them instead, As of 2026-08:

const page = await db.posts.findMany({ orgId });
for (const post of page.rows) {
  // Two statements for the whole loop: the page, then one `where "id" in (โ€ฆ)` over every author
  // on it. Every lookup after the first is memory.
  const author = await db.users.findById(post.authorId, { orgId });
}

Nothing new to write, and nothing to opt into: the relation is the references() the column already declares, so the fix reaches loops that are already written.

Property Behavior
Trigger the first findById whose id is a foreign key value on a page this request read. It resolves that key for every row of the page, in one statement
Never shared with a different scope key (another tenant, another soft-delete visibility, another projection, another entity), another client, or the same entity after a write to it
Scope the tenant predicate and deleted_at is null are inside the preload statement โ€” it is the statement each single lookup would have sent, widened to the page's ids โ€” so a page's ids can never resolve rows outside the reader's own scope
A write drops what was preloaded for that entity before the statement goes out, so a row this request changed is re-read, never served from a page read before it
Lifetime one request. What is held is the ids, not the rows, keyed by context identity
Wide pages past 500 ids, whole statements โ€” the same bound the microtask batch has
Outside a request a job, a script: a page leaves nothing behind, and every lookup is the statement it always was

Preload states a relation the loop would infer

The eager, declarative third of the family: the relation is named on the chain and resolved before the caller sees a row, As of 2026-08:

export const db = database({ orgs, posts, members });

// Two statements: the page, then one `select โ€ฆ where "id" in (โ€ฆ)` over its authors.
const page = await db.posts.where({ orgId }).preload('author').page();
page.rows[0].author;        // the member row, or null โ€” always present

Nothing new to declare: 'author' is the relation the authorId foreign key on posts already produces โ€” preload() names it, it does not define it. A name no foreign key produces is X_PRELOAD_UNKNOWN_RELATION, resolved and thrown when preload() is called, on the chain and not a page later.

Property Behavior
Shape a belongsTo attaches the row or null; a hasMany attaches an array, empty when there are none. Always present, so "no author" and "nobody preloaded the author" cannot read the same
Statements one extra statement per named relation, resolved concurrently โ€” two preload() calls are two statements in flight, never one after the other. Naming one relation twice is one statement
Ceiling preload('<relation>', { max }) โ€” the most related rows one relation may attach to one page; default 10,000 (MAX_PRELOADED_ROWS, the largest page a read may ask for). Past it the read is refused with X_INVARIANT_VIOLATED, never truncated
Terminals page(), all(), one() resolve every named relation. count(), countBy() and plan() do not โ€” a count reads no row to attach one to
Projection attached after select(). select() is widened internally with the relation's own key, so a projection can drop neither the key the preload reads nor the relation it attaches โ€” plan().select reports the widened list, the one that actually ran
Tenancy the page's own tenant predicate carries onto the related read only when both entities are scoped by a column of that same name โ€” a value that scopes one entity is a guess on another, and a source scoped by workspaceId may carry an ordinary orgId predicate that is a filter and not its tenancy. Carrying nothing is not silence: the related read builds its own plan, so it is refused as X_TENANCY_UNSCOPED rather than answered with a guess
Soft delete the related read is findMany, so deleted_at is null applies exactly as it does to any other read
Wide relations past 500 ids, whole statements โ€” the same bound the two batches above share. A hasMany wider than one page costs another keyset page rather than a silent truncation
Reach a table reads only the entities its own database() call named. A relation to one outside the set is X_INVARIANT_VIOLATED; the fix names the missing entity in that same database({ โ€ฆ }) call

Reach for it when the relation is part of what the page is โ€” a list rendered with its authors, rows handed to something that will not call back into the repo, or a read a reviewer should see stated rather than inferred from a loop. The other two members of the family ask for nothing: findById batches a same-microtask fan-out for itself, and a for โ€ฆ of loop over a page is already two statements, above.

Reading a whole table is a loop of pages

A page is bounded on purpose, so reading everything is iteration โ€” and the iteration is a terminal on the same chain, not something written around page(), As of 2026-08:

// One statement per batch, one page of rows in memory at a time.
for await (const batch of db.posts.where({ orgId }).preload('author').inBatches(500)) {
  await search.index(batch);
}

A batch is the page page() would have returned at that position: same filters, same tenancy, same soft-delete visibility, same select(), same preload(). Nothing here is a second read path.

Stopping early keeps the position, which is what makes a long backfill a job that can run out of time and pick up where it stopped:

await using batches = db.posts.where({ orgId }).after(checkpoint).inBatches(500);
for await (const batch of batches) {
  await search.index(batch);
  if (ctx.clock.now() > deadline) break;
}
await db.checkpoints.update(id, { cursor: batches.cursor });   // resume with .after(cursor)
Property Behavior
Statements one per batch, each asking for one row past it exactly as a page does. An empty batch is never yielded, so a consumer never checks batch.length
Position keyset, never OFFSET โ€” a row written mid-iteration cannot make the loop skip or repeat one. .after(cursor) starts it, .cursor is where the next batch starts, null once exhausted
Closing break, return and a throw all stop the next statement; await using is the same guarantee for a handle held in a variable, and close() is idempotent. One handle is one iteration โ€” a second for await continues it rather than restarting the table
Batch size a whole number of rows, at least one. A chain that also called limit() is X_INVARIANT_VIOLATED: one number with two meanings, and neither reading is safe to guess
Sort order an ordering no cursor can carry is refused at inBatches(), not one batch later โ€” a result that fits in a single batch mints no cursor, so deferring it would hide the mistake until the table grew. An ordinary nullable sort column is no longer one of them, As of 2026-08-24: NULL has a written-down place (asc nulls last / desc nulls first), the cursor carries it and the seek reaches it. What is refused is a nullable primary-key column โ€” the tiebreak totalOrder appends precisely so two rows sharing a sort value cannot straddle a page boundary, and null = null is unknown, so it cannot do that job. Reachable only through primaryKey: [...]; drop .nullable() from the column
Tenancy the plan's, as everywhere else: an unscoped chain is X_TENANCY_UNSCOPED on its first batch

A count per row is one grouped count

The batching family above collapses a lookup repeated per row. A count() per row is not a lookup โ€” each statement asks a different question, so no batch reaches it. One statement asks all of them, As of 2026-08:

// One statement for all of `ids`, not one `select count(*)` per post.
const counts = await db.likes.where({ orgId }).andWhere('postId', 'in', ids).countBy('postId');
for (const id of ids) await db.posts.update(id, { likeCount: counts.get(id) ?? 0 });

A ReadonlyMap keyed by the column's own values and typed from the row, so counts.get(postId) is a number | undefined โ€” and the undefined is load-bearing.

Property Behavior
Counts the whole predicate, exactly as count() does: the chain's filters, its tenancy and its soft-delete visibility are in the statement. limit() and after() bound a page, never a count
A value nothing matched absent, never 0. That is what group by returns, and it is what tells "none" apart from "never asked" โ€” the default is the caller's ?? 0
NULL one group, keyed null, in both drivers: a property a row never carried is read as null, so it lands where Postgres puts its NULL rows. 0, '' and false stay the values they are
Order biggest group first, ties by the value โ€” numbers and bigints numerically, everything else by its text โ€” and null last. Applied after the rows are in, not in the statement: a hash aggregate returns groups in whatever order it built them, so an order by there would let the two drivers disagree about a result they agree on
Groupable columns uuid, text, char, boolean, integer, bigint. A timestamp, a jsonb or a money column is X_INVARIANT_VIOLATED, whose fix names a column of that entity that is groupable โ€” a Map compares a non-primitive key by identity, so such a map could only ever answer undefined
More than 1000 groups X_INVARIANT_VIOLATED, never a truncated map: the statement asks for one group past the bound, exactly as a page reads one row past its limit, and the fix spells the andWhere('<column>', 'in', <values>) that bounds it. A map that lost its tail reads like a complete one, and recounting from it writes the wrong number to every row it missed
Statement select "post_id" as group_value, count(*) as group_count โ€ฆ group by "post_id" โ€” both names are fixed aliases, so an entity may still declare a column called count, and the grouped value is re-parsed by the column that declared it
Tenancy the plan's, as everywhere else: an unscoped chain on a tenant-scoped entity is X_TENANCY_UNSCOPED, never a count across tenants

There is no groupBy() builder and no error code of its own: countBy is a terminal on the chain that already exists, over exactly the rows count() counts.

Writing many rows is one statement

The sections above make a read loop stop being N statements. A loop of insert() calls is the same defect on the write side, and insertAll/upsertAll are its answer, As of 2026-08:

await db.tags.insertAll(names.map((name) => ({ orgId, name })));   // one statement, n rows

await db.likes.upsertAll(rows, {                                  // insert, or leave what is there
  onConflict: ['orgId', 'postId', 'memberId'],
  onMatch: 'nothing',
});

await db.counters.upsertAll(rows, { onConflict: ['orgId', 'day'] });   // insert, or overwrite

Rows are Insertable and go through $parse exactly as one row does, so declared defaults are filled here rather than by the caller; upsertAll also stamps onUpdateNow() columns through the same touch() update(id, patch) uses, because an upsert that lands on a stored row is an update. Both resolve with the rows this call wrote, in order โ€” under onMatch: 'nothing' a row already stored is skipped and absent, which is how a caller counts what it actually inserted.

Property Behavior
One builder every insert in the framework compiles through the same function, so insertAll([row]) is the text insert(row) always produced. There is no second insert path to drift
What a collision writes every column the batch names, minus the conflict target (how the row was found), minus the primary key (where it lives) and minus the soft-delete stamp (whether the row is there at all). Moving either of the first two moves a row nobody asked to move, and every foreign key pointing at that id misses it
A soft-deleted row it lands on stays deleted, and takes the batch's other columns. A stamped row still occupies its conflict target โ€” that unique index is not partial โ€” so writing deletedAt from the incoming row would bring back a row the app deleted, which is the resurrection update() and updateWhere() refuse by carrying deleted_at is null. insertAll is unaffected: a row that collides with nothing writes the stamp it carries
Conflict target properties of a declared unique constraint โ€” the primary key, a unique() column, an indexes: [{ on, unique: true }] entry, or an invariant(name, c.unique([โ€ฆ])). Anything else is X_INVARIANT_VIOLATED here rather than 42P10 from the server
Tenancy on a tenant-scoped entity, onMatch: 'update' requires the tenant column inside the conflict target, else X_TENANCY_UNSCOPED: an upsert builds no read plan, so a target that omits it matches another tenant's row and rewrites it. 'nothing' is allowed โ€” it writes nothing to a row it does not own
A batch repeating itself two rows with one conflict target under 'update' is refused: Postgres answers that statement ON CONFLICT DO UPDATE command cannot affect row a second time, so it cannot pass in memory either
Uneven batches under 'update' every row names the same columns โ€” excluded.<column> for a row that omitted one is that column's default, not the stored value. insertAll and 'nothing' accept an uneven batch and render default in the missing cell
Nulls a null in the conflict target collides with nothing, in both drivers โ€” a Postgres unique index is NULLS DISTINCT
Size past 65535 bind parameters the batch is several statements, never one the server refuses. Wrap the call in withTransaction when all-or-nothing matters

There is no updateAll: updateWhere(filter, patch) and deleteWhere(filter) are already the bulk forms of update and delete, and a second spelling of one of them would be a second path.

The composite unique index the tenancy rule requires is emitted correctly since 2.0.0 โ€” declare it on the entity and generate. Through 1.2.0 indexes: [{ on: ['orgId','createdAt'] }] emitted one mangled name (("org_id_created_at")) and the SQL would not apply, so an app pinned there writes that create unique index by hand.

Seeding

A seed is the fixture graph, written once and replayed anywhere: run it twice and the second run writes nothing and raises nothing. x db seed [<name>] applies it; defineSeed declares it, in packages/<pkg>/seeds/<name>.ts or packages/<pkg>/src/seed.ts โ€” the two places x db seed looks.

export const dev = defineSeed('dev', async ({ insert, upsert, exists, id }) => {
  // The table owns the id, so the NATURAL key is the only key: insert, or update what is there.
  await upsert(plans, { by: ['code', 'currency'] }, { code: 'team', currency: 'EUR', monthly });

  // The SEED owns the id, so a replay finds its own rows: `id('org:acme')` is a uuid v5 of the
  // label โ€” the same value on every machine, in CI and in the container.
  await insert(orgs, [{ id: id('org:acme'), slug: 'acme', name: 'Acme Editorial' }]);

  // Bulk volume data has no natural key worth upserting ten thousand rows against. The unit of
  // idempotency is the FILE, and the guard is a sentinel.
  if (await exists(reports)) return;
  await insert(reports, generated);
});
Verb Use it when What a replay does
insert(entity, rows) the seed chose the ids (id('label')) one on conflict โ€ฆ do nothing statement per call โ€” a stored row is left exactly as it is, and counted skipped
upsert(entity, { by }, values) the table owns the id and a natural key identifies the row reads first so an unchanged row answers 'skipped' with no statement, then one on conflict โ€ฆ do update โ€” never a read-decide-write race between two containers booting at once
exists(entity, where?) / count(entity, where?) volume data, where the file is the unit the sentinel returns early and nothing is written
deleteWhere(entity, where) a scoped wipe before a regenerate refused on a soft-deleting entity โ€” see the trap below

Rows go through entity.$parse and the invariants either way, so a seed is a test of the schema as well as data for one.

Rule Detail
Tier reference ships to production through this same command; dev is fixture data and is the default. x db seed runs reference alone under ULTIMATE_ENV=production โ€” naming --tier dev (or ULTIMATE_SEED_TIER=dev) is both the selection and the consent โ†’ CLI reference
Transaction one per seed, never one around the run: a seed that throws must not roll back the seeds that already landed
createdAt an upsert that lands on a stored row does not overwrite it. A replay must not move when a row first arrived; preserve: [...] names other columns to spare
Tenancy upsert on a tenant-scoped entity needs the tenant column inside by, else X_TENANCY_UNSCOPED โ€” the same rule upsertAll carries. insert has no such requirement: do nothing writes nothing to a row it does not own
Generated key insert refuses a row that leaves a uuid().primaryKey() unnamed. $parse would fill it with a fresh uuid, the conflict target would match nothing, and run five would leave five copies โ€” the one duplication nothing else can see
now one instant per run, so every row a bulk pass stamps carries the same timestamp
metrics { inserted, updated, skipped }, returned by run() and reported per seed by x db seed --json

The soft-delete trap, and why deleteWhere refuses. Deleting a seeded row on a soft-deleting entity stamps it rather than removing it. The stamped row still occupies its unique key โ€” that index is not partial โ€” and upsertPlan spares the soft-delete column on purpose, so no replay can clear the stamp: the rows are invisible to every read and no re-seed brings them back. A wipe-then- replay that looked idempotent left a demo's feed permanently empty. Inside a seed the call is therefore X_INVARIANT_VIOLATED naming x db reset, which is the only wipe such an entity has.

Column types

The blessed set is a decision the framework made for a table it was going to create. The wide set is the shape a table already has, As of 2026-08.

Builder Postgres Row type
uuid(), uuid<PostId>() uuid string, branded if declared. .primaryKey() generates a v7 when omitted
text({ max }) ยท integer() ยท boolean() ยท url() text ยท integer ยท boolean ยท text + CHECK string / number / boolean
enumerated(v) ยท tz(zones) ยท locale(tags) text + CHECK the union of the declared values
timestamp() timestamptz Date โ€” an instant, stored UTC
money() <n>_minor bigint + <n>_currency char(3) + <n>_scale integer null MoneyValue
json(schema) jsonb whatever the schema infers. The schema is required โ€” a json() returning unknown is an any hole, and the value arrives from the database as often as from a caller
decimal({ precision, scale }) numeric(p, s) string, the exact digits. A value with more decimal places than the column stores is refused, not rounded
date() date PlainDate โ€” a calendar date, no time, no zone
bigint() bigint string. A JS bigint is what JSON.stringify throws on and a number loses digits past 2^53, which is where a legacy int8 key lives
bytes() bytea Uint8Array, normalised across both drivers
arrayOf(column) <element>[] readonly T[], each member parsed by the element column. Elements: uuid, text (and url, enumerated, tz, locale), integer, boolean, timestamp, decimal, date, bigint, each optionally .nullable(). The row is the same on both drivers: a driver that returns an array as its text literal (Bun.SQL does for uuid[]) is read by the column's declaration

No NUL in text. text() and url() refuse a string carrying U+0000 (X_INVARIANT_VIOLATED, column.format) โ€” the one character a Postgres text value cannot hold (SQLSTATE 22021), so the memory driver no longer stores what production answers X_DB_STATEMENT_FAILED for. t.string refuses it at the wire first, as a 400. As of 2026-09-29.

Chain: .primaryKey() ยท .nullable() ยท .unique() ยท .default(v) ยท .defaultNow() ยท .onUpdateNow() ยท .references(() => other.id, { onDelete }) ยท .tenant() ยท .column(name) ยท .sealed() on text() โ€” Sealed columns.

A date is not an instant. effective_on is the date a rate applies, not a moment; stored as a timestamptz it is a different date on either side of midnight for half the planet. PlainDate (@ultimat3/time) is a branded YYYY-MM-DD string โ€” it sorts chronologically because it sorts lexicographically, round-trips through JSON as itself, and is the literal Postgres accepts. The framework's "never a date without an IANA zone" rule is about instants: a calendar date needs no zone because it names no instant.

Sealed columns

A credential column is text().sealed(). The row reads string; the database holds ciphertext.

export const connections = entity('connections', {
  columns: {
    id: uuid().primaryKey(),
    orgId: uuid().tenant(),
    password: text({ max: 200 }).sealed(),               // opaque
    email: text().sealed({ lookup: true }).unique(),     // equality only
  },
});

const row = await db.connections.where({ email }).one();  // found by the stored string
row?.password;                                            // the plaintext, on the server
Fact
Builder text() only; .sealed() reads the same before or after .nullable(), .unique(), .column()
Stored x1.<keyId>.<iv>.<ciphertext+tag> in a plain text column โ€” no new SQL type, and x db gen writes no migration for sealing a text() with no max. A max is checked against the plaintext and its CHECK is dropped
Key the master key x secrets manages; purpose entity:<table>.<column>, derived
Drivers memory and Postgres both seal
Manifest x.manifest.json lists the column as "sealed": "opaque" or "lookup"

A sealed property is server-only. On every row a repository answers it is an own, readable, writable property that is not enumerable: row.password is the plaintext, and nothing that walks the row sees it. As of 2026-10.

Reads it Does not
row.password, const { password } = row, 'password' in row JSON.stringify(row), { ...row }, Object.keys / entries / assign, structuredClone(row)
update(id, row) โ€” the row passed back whole the logger, an error's meta, an island's props, a cache tier, a job's stored input and step output
console.log(row) / Bun.inspect(row) โ€” Bun prints own properties, enumerable or not โ€” log through logger, never console, on a path that holds one
Leaves the server through Sealed column
entity.$schema as an output โ€” HTTP, the typed client, MCP, a job result absent, in the schema and the value
an output that is not the entity's schema (t.record(โ€ฆ), a hand-written Standard Schema) absent โ€” the row enumerates none
a query's rows โ€” HTTP list, Page, single: true, the record envelope's data, the MCP read, a cache: entry absent โ€” a query has no output parse, so this is the row's own doing
a live query's snapshot absent
a live query's change, on the socket and on the bus absent โ€” the row decoded off the write-ahead log drops the stored string
entity.$view([...]) X_ENTITY_SEALED_IN_VIEW
a record, the record envelope, a persist: true browser store absent
an island's props, a log line, an error's meta absent
a job's input and a step.run() output holding the row absent from the stored JSON โ€” pass the id and re-read
{ token: row.token } โ€” the secret named by hand leaves. Naming it is the one read there is; a serialised copy is the author's statement
It costs What happens Write instead
a spread patch update(id, { ...row, label }) leaves the stored secret alone โ€” an absent property is not a write nothing
a spread insert or upsert, required column X_INVARIANT_VIOLATED โ€” is required, sealed, and missing from the row to insert, with the rewrite as its fix: insert({ ...row, password: row.password })
a spread insert, nullable column stored NULL, silently โ€” an absent nullable column is a legal insert insert({ ...row, token: row.token })
equality in a test expect(row).toEqual({ โ€ฆ, password }) fails with an empty diff; toEqual(otherRow) cannot see a wrong secret expect(row).toEqualRow({ โ€ฆ, password })
a row out of a shared cache: tier or a replayed step.run() came back through JSON, so the property is gone read the secret from the repository, by id

insert(row) and update(id, row) with the row passed whole write what it holds; select({ password: true }) and preload() keep the property, still server-only.

Testing a sealed row. toEqual walks enumerable properties. Write one of:

expect(row).toEqualRow({ id, label: 'primary', password: 'hunter2' }); // every own property
expect(row.password).toBe('hunter2');
expect(row).toMatchObject({ password: 'hunter2' });

toEqualRow (Testing) names what differs โ€” a sealed property by name, never by value. A test needs no key: the testing preload installs a throwaway one.

A live read may not filter or order on a sealed column โ€” no change row carries one (X_MATCHER_UNSUPPORTED at subscribe).

The database may .sealed() .sealed({ lookup: true })
test for NULL (is-null, is-not-null) yes โ€” null is stored as NULL, never sealed yes
match by eq / in no โ€” a compile error, and X_ENTITY_SEALED_PREDICATE yes
order, range, like, group, aggregate no no
hold .unique() or an index no yes
run an invariant over it no only unique

lookup reveals equality: equal values store equal strings. Never for a low-entropy value.

Rotation. x secrets rotate keeps the old key in the ring. Until the rows are rewritten a lookup column matches through in (โ€ฆ) over every key, .unique() holds per key, and x doctor reports X_SEAL_RESEAL_PENDING. Re-seal with a backfill() (update(id, { col: row.col })), then x secrets rotate --drop <keyId>.

Sealing a column that already holds data

There is no reading of an unsealed value: a stored string that is not sealed is X_SEAL_INVALID. A column with existing plaintext is migrated โ€” expand, backfill, contract.

# Step Command or edit
1 expand: a NEW nullable sealed column beside the old one passwordSealed: text().nullable().sealed() ยท x db gen "add sealed password" ยท x db migrate
2 write both: every writer sets passwordSealed as well as password deploy
3 backfill the rows written before step 2 the declaration below ยท x db backfill seal-connection-passwords --write
4 switch readers to passwordSealed; stop writing password deploy
5 contract: drop the plaintext, take the name back delete password; password: text().sealed().column('password_sealed') ยท x db gen "drop plaintext password" ยท x db migrate
// apps/web/app/connection/backfills/seal-connection-passwords.ts โ€” a backfill IS a job: register
// the module in defineApi({ jobs }) beside the slice's other jobs
import { backfill } from '@ultimat3/jobs';

export const sealConnectionPasswords = backfill({
  name: 'seal-connection-passwords',
  tenant: 'none',                       // every tenant; the pass opens the cross-tenant scope
  requires: '20261001120000_add_sealed_password',
  // Only the rows still to seal: a NULL test is the one predicate an opaque column answers.
  // The second filter is for a NULLABLE old column โ€” a row whose `password` is NULL has nothing to
  // seal and would never leave the set. Delete that line when `password` is NOT NULL.
  source: () =>
    db.connections.andWhere('passwordSealed', 'is-null').andWhere('password', 'is-not-null'),
  count: () =>
    db.connections.andWhere('passwordSealed', 'is-null').andWhere('password', 'is-not-null').count(),
  handle: async ({ rows, signal }) => {
    for (const row of rows) {
      signal.throwIfAborted();
      // Reads the old column, writes the sealed one. Idempotent: a replayed page seals again.
      await db.connections.update(row.id, { passwordSealed: row.password });
    }
  },
});
Why
a new column, not .sealed() on the old one reads of the old rows would be X_SEAL_INVALID from the first request to the last row of the backfill
step 5 needs no re-seal the purpose follows the PHYSICAL column (password_sealed), and .column() pins it
count the same predicate, counted: the pass has converged when it answers 0, and X_BACKFILL_STALLED says so if it has not. source and count must filter alike โ€” the password is-not-null line goes in both or neither
a lookup column same recipe; add .unique() in step 5, once every row holds a sealed value

Adopting an existing database

Three overrides, As of 2026-08, and together they are what makes a schema Ultimate did not generate declarable at all. An entity that uses none of them emits exactly what it always did.

export const accounts = entity('account', {
  table: 'legacy_accounts',
  columns: {
    id: uuid().primaryKey().column('account_id'),
    githubLogin: text({ max: 40 }).column('gh_login'),
    balance: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
    openedOn: date().column('opened_on'),
  },
});
Override What follows it What does not
entity(name, { table }) every statement, index name and foreign key the entity NAME stays the key: the registry, the cache tag entity:account, x entities show, every relation and every policy
.column(name) the DDL, the binding, the decoder, predicates, sort keys, cursors nothing โ€” name it LAST in a chain, since the link returns the general column; uuid() and timestamp() keep their own methods across it
money({ columns }) the three physical columns, per part, merged over the defaults scale: null says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already meant

A physical name is validated where it is written โ€” lower-case letters, digits and underscores, at most the 63 bytes Postgres truncates at โ€” because it is spliced into every statement as an identifier.

What is not adoptable yet, As of 2026-08. Each of these is a real table shape that this vocabulary cannot express, said precisely rather than approximated:

Shape Why, and what to do instead
a numeric money column with no currency column beside it money() is an amount and a currency by construction, and a single implied currency is the bug it exists to prevent. Declare it decimal() and keep the currency where the app already keeps it, or add the column
a Postgres enum TYPE enumerated() emits a CHECK, never CREATE TYPE, so a column of a real pg_enum type has no declaration. text() reads and writes it correctly today; what is missing is the DDL and the closed set
a composite type, a range, hstore no builder, and no way to spell one
a live query over a renamed column @ultimat3/realtime rebuilds a row from the physical names alone (it holds no dependency on this package) and would deliver ghLogin where the repository says githubLogin. Renamed columns are safe everywhere else
x db gen over an adopted table the generator would emit create table for a table that exists. Baselining an existing schema is its own command and is not built

Invariants

Rule Behavior
Named inv('published-post-has-title', โ€ฆ) โ€” the name is the error text and the test name
Enforced on write via the repo and, where expressible, a Postgres CHECK generated into the migration
Violation throws X_INVARIANT_VIOLATED with the invariant name and the offending row id
Not authz an invariant is about data shape. Permission belongs in Policies and authz
Duplicate entity two entities on one table name is X_ENTITY_DUPLICATE โ€” rename or merge

Embeddings

embed: { field: 'body', model: 'text-embedding-3-large' },
Piece What is generated
Column a pgvector column sized from the model, in the same Postgres โ€” no second datastore
Index HNSW, created by the generated migration
Backfill a job with steps: resumable, rate-limited per tenant, safe to re-run (at-least-once)
Re-embed content-hash change enqueues a job; unchanged text is never re-embedded
Exact cache embeddings cached by content hash + model in cache tier 3
Search one hybrid query fusing pgvector cosine + Postgres FTS with Reciprocal Rank Fusion; weights are config

More in MCP and AI.

Migration workflow

Step Command Behavior
Generate x db gen "add publish_at" diffs entities vs migrations, writes a named, ordered migration + its down
Apply (dev) x db migrate runs pending migrations against the dev DB; live queries resubscribe
Check x db migrate --json the live schema against the ledger it just wrote; exits non-zero on a difference
Inspect x db studio tables, columns, indexes, FKs, generated SQL โ€” also the /_x Schema panel
Pre-deploy ROLE=migrate container run-once hook, same image; waits on the session-pinned migration lock while another version's migration is in flight, then applies. The wait is bounded at 60s (As of 2026-08) โ€” past that it exits non-zero with X_MIGRATE_CONCURRENT instead of hanging the rollout
Test template automatic migrate + seed once into myapp_test_tpl, then clone per worker

Prod ordering is fixed: ROLE=migrate completes, then web / sync / worker / scheduler roll. Migrations are forward-compatible with the previous release so a rolling restart never serves a request against a schema it cannot read. See Deployment.

Drift is a x verify failure

X_DB_DRIFT: schema differs from migrations
  cause: table "posts" has column "publish_at" not present in any migration
  fix:   x db gen "add publish_at"
Direction Meaning Caught by
Entity has what migrations lack you edited an entity and did not generate โ€” run the fix x verify
DB has what migrations lack someone changed the database by hand; generate a migration or revert the change x db migrate
Migrations have what the entity lacks a stale migration or a deleted column; reconcile before shipping. Deleting the last entity is this direction, not "nothing is declared so nothing drifts": x db gen "<name>" refuses with X_MIGRATION_IRREVERSIBLE, and the same command plus --allow-destructive writes the drop x verify
Neither declares anything not drift. Zero entities against zero migrations is agreement, which is what keeps a scaffold with no entity() green until its first one โ€”

One code, two detectors, because one check cannot be both. x verify's drift step reads the entity source and the .hash a migration recorded โ€” no database of yours, which is what lets the gate run in CI (it boots a scratch embedded one for the schema dump, below). x db migrate diffs the live catalog against the x_migrations ledger on the connection it just migrated over โ€” the only place a hand-edited column is visible at all. A pending migration is not drift, and neither is a table in the framework's x_ namespace.

The schema dump. packages/db/schema/ is the schema as committed SQL โ€” one file per table, the framework's x_ tables under framework/ โ€” written by x db gen and x db migrate, never by hand. From the first migration on it is owed: x verify's drift step replays the migrations on a scratch database, compares bytes, and loads the dump back. A missing, stale or hand-edited dump is X_SCHEMA_DUMP_DRIFT, fix: x db gen. The .snapshot.json sidecar is a different thing: the generator's diff base in the entity's vocabulary. Layout, the engine rule and limits: Migrations and backfills โ†’ The schema dump. As of 2026-10.

What the catalog diff compares

Thirteen DriftKind values, As of 2026-10-02 โ€” the executable list is DriftKind in packages/db/src/drift-findings.ts. Twelve are raised by the snapshot comparison in the table below; the thirteenth, unexpected-object, by the schema-dump comparison (Migrations and backfills). For indexes, CHECKs and foreign keys only the declared side is judged: a live index or foreign key no snapshot names is not drift, because Postgres creates an index for every primary key and every unique constraint and an index a DBA added is a planner decision. The primary key is the exception โ€” both sides are compared: a key the database holds and the snapshot does not, or the reverse, is changed-primary-key.

Kind Raised when
unexpected-column ยท missing-column ยท changed-column a modelled table's columns disagree with the snapshot. A column of the declared primary key reads as NOT NULL whether or not anything declared it; a column only the database keys does not, As of 2026-10-02. changed-column's fix: is one command, psql "$DATABASE_URL" -c 'alter table โ€ฆ alter column โ€ฆ set not null;' (or drop not null), run against the drifted database, then x db migrate to re-check
changed-primary-key new As of 2026-10-02. The two key column lists differ โ€” a different column, a different order, or a key on one side only. Its fix: is one command โ€” psql "$DATABASE_URL" -c '<the drop constraint / add constraint โ€ฆ primary key (โ€ฆ) pair>', naming the constraint the database holds โ€” run against the drifted database, then x db migrate to re-check. Until this kind existed a re-keyed table read ok: true
unexpected-table ยท missing-table ยท unknown-schema a modelled table is absent, or a table outside x_ is present that no migration declares
missing-index the snapshot names an index the catalog does not hold
missing-check the snapshot names a CHECK constraint the catalog does not hold. Compared by name only โ€” pg_get_constraintdef answers Postgres' own rewriting (status in ('draft','published') comes back as CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))), so the predicate's text can never be compared, exactly as an index's where cannot. Only the declared side is judged, so a NOT NULL, an enumerated() column's old anonymous constraint and an extension's own are all silent. Its fix: is the add constraint โ€ฆ check (โ€ฆ) statement as one psql "$DATABASE_URL" -c 'โ€ฆ' command
changed-index the index is there and one of four facts about it differs: its column list, its uniqueness, its direction, or whether it is partial. The last two are new As of 2026-08-19 โ€” a desc index rebuilt ascending served a feed's newest page off the wrong end, and a partial index recreated as a total one silently widened the constraint, and both read ok: true. asc is normalised to null first, because Postgres stores an ascending index as not-descending. The predicate's text is deliberately never compared โ†’ Known gaps
missing-foreign-key no key points those columns at that table. Matched on where the key points, never on its name: a hand-written constraint fk_posts_org is the same constraint as a generated posts_org_id_fkey
changed-foreign-key new As of 2026-08-19. The key points where it was declared to point and one side's on delete rule is not the other's. Reported apart from missing-foreign-key because it is a different repair โ€” the constraint is there, and what changed is what happens to the child rows. Its fix: is the pair as one psql "$DATABASE_URL" -c 'โ€ฆ' command, not x db migrate: a rule cannot be altered in place, add constraint alone is 42710 on a name already taken, and no x db gen diff emits either statement. Both sides go through one normalisation, so the catalog's c and a snapshot's cascade agree, and Postgres' a (no action) on every undeclared key reads as no rule

There is no separate migration tool and no "regenerate types" step. drift is one of x verify's twenty steps โ€” the list, in order, is in Testing.

Reversible or marked

Two questions, two answers. Reversible is asked at generation: can the down put the rows back? Destructive is asked at the gate: does applying the up destroy data, and does the file say so? A retype is reversible in DDL and still rewrites every row, which is why one flag cannot answer both.

Rule Detail
Every migration has a down x db gen writes both halves of one file, split by a lone -- down line
A generated drop refuses with X_MIGRATION_IRREVERSIBLE โ€” its down cannot restore the rows. Re-run with x db gen "<name>" --allow-destructive
A changed primary key As of 2026-10-02: alter table โ€ฆ drop constraint if exists "<table>_pkey" ahead of the table's column statements and add constraint "<table>_pkey" primary key (โ€ฆ) after them, reversed in down. Until then it wrote no statement and x db gen re-recorded the hash sidecar as if nothing had changed. A key another table's foreign key is written against refuses with X_MIGRATION_IRREVERSIBLE naming those constraints: drop the keys in one migration, change the key in the next, restore them in a third. A key over a column the same migration adds with no default refuses the same way โ€” every existing row would hold NULL there: add the column, backfill it, then key it. A nullable column leaving the key gets drop not null, in up and in down, because Postgres keeps a key column NOT NULL after the key is gone
A destructive up must carry -- destructive: true as a line in the file. x db gen writes it for you; an unmarked one fails x verify's drift step with X_MIGRATION_DESTRUCTIVE
What counts as destructive drop table, drop column, truncate, alter column โ€ฆ type โ€” a closed list. drop constraint, drop default, drop not null and drop index do not: the database rebuilds those. A truncate is a statement that starts with TRUNCATE. create trigger โ€ฆ before truncate and grant/revoke โ€ฆ truncate only name the operation to guard or permit it, and are not truncates (As of 2026-09-24, #522)
Only up is judged reversing a create table is a drop table, so a rail that read down would mark every migration ever generated
Mark it before it is applied the marker is SQL the checksum covers, so adding it to an applied migration is an edit โ€” X_MIGRATION_CONFLICT, correctly
Rollout a marked drop still wants a rollout note; prefer expand โ†’ migrate โ†’ contract across two releases
-- 20260814120000_drop_legacy
-- GENERATED by `x db gen` from the app's entities โ€” do not edit.
-- Editing an applied migration changes its checksum: X_MIGRATION_CONFLICT on the next apply.
-- destructive: true

alter table "posts" drop column "legacy";

-- down
alter table "posts" add column "legacy" text; -- data is not restored
X_MIGRATION_DESTRUCTIVE: this migration destroys data and does not say so
  cause: packages/db/migrations/20260814120000_drop_legacy.sql drops a column and does not
         declare it: alter table "posts" drop column "legacy"
  fix:   add the line "-- destructive: true" to packages/db/migrations/20260814120000_drop_legacy.sql,
         or regenerate it: x db gen "<name>" --allow-destructive

Branch DBs for agents

The shipped command is x db branch, and it takes a verb from a closed set โ€” list, create <name>, delete <name>. x branch (no db) is planned and exits X_NOT_IMPLEMENTED; the build and MCP-socket halves of the design below are what it will add.

x db branch create feat-new-billing --json
{"ok":true,"command":"db","data":{"branch":"feat-new-billing",
 "database":"myapp_branch_feat_new_billing",
 "preview":"http://feat-new-billing.localhost:3000","mode":"external"}}
Property Detail As of 2026-08
Mechanism CREATE DATABASE "<source>_branch_<slug>" TEMPLATE "<source>" โ€” Postgres file-copies, cheap, isolated, disposable. <slug> is the name with every character outside [A-Za-z0-9_] replaced by _ (branchDatabaseName), because a hyphen is not legal in an unquoted identifier โ€” hence myapp_branch_feat_new_billing above. On the embedded database it is branchPglite(), a data-directory copy named pgdata-<name>, which keeps the name as typed shipped
Writes the MCP db.migrate tool applies only in a branch DB, never the shared dev DB (X_MCP_NOT_BRANCH_DB) shipped
Preview URL reported in data.preview, subdomain-routed off PORT the URL is computed; nothing routes that subdomain for you
Listing x db branch list โ€” name, location, created-at, size, over either database. Managed branches only: external, a database carrying createBranch()'s marker comment (listBranches() is that read); embedded, a pgdata-<name> directory under the state dir (listPgliteBranches(), in the CLI). A branch cloned by the pre-1.2.x psql path carries no marker and is invisible โ†’ Known gaps. created-at and size read unknown where nothing recorded one โ€” always the size on the embedded side, since measuring it is a full directory walk shipped
Teardown x db branch delete <name>. It may only delete what list shows, and that guard lives in the CLI (runDelete): the shared database this session is connected to carries no marker, so it is not in the set, and there is no --force to get it wrong with. @ultimat3/db's dropBranch() is the statement underneath, not the same operation โ€” it takes the database name (<source>_branch_<slug>), reads no marker, and will drop any database but the current one. Its force: true means "terminate other sessions first", which the CLI always passes; it is not an override of the guard shipped
Reaping reapBranches({ maxAgeMs }) drops branches of this database only, As of 2026-08-19. The marker is ultimate:branch:<base>:<iso> and BranchInfo carries base; a branch whose base is not current_database() is skipped, and so is a pre-3.x marker that records no base at all. A createdAt that is not finite, or that does not round-trip through toISOString(), is also skipped โ€” a truncated comment used to read as an infinitely old branch and be dropped on the next sweep whatever maxAgeMs said shipped
Build + scoped MCP socket a per-branch build id scoping the service worker, and ws://localhost:9229/<branch> planned, part of x branch

The same clone mechanism powers test parallelism: each worker gets its own ultimate_test_template_w<N> cloned from the migrated template ultimate_test_template, typically 100โ€“400ms. Never mock the database โ€” clone it.

Errors

Code Cause Fix
X_DB_DRIFT schema differs from migrations x db gen "<name>"
X_ENTITY_DUPLICATE two entities on the same table rename one, or merge them
X_INVARIANT_VIOLATED a write broke a named invariant fix the caller, or change the invariant and generate a migration
X_TENANCY_UNSCOPED a query without a tenant predicate go through the repo
X_PRELOAD_UNKNOWN_RELATION preload('<name>') named a relation no references() produces pick one of the relation names the error lists, or add the .references() call that creates it
X_ENTITY_SEALED_PREDICATE a .sealed() column named in a filter, an order, a group, an index or an invariant find the row by another column, or declare text().sealed({ lookup: true }) for equality
X_ENTITY_SEALED_IN_VIEW a .sealed() column listed in $view([...]) remove it from the list

Full list with --json shapes: Error codes. Source: docs/idea/02-primitives.md, docs/idea/10-testing.md.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ