-
Notifications
You must be signed in to change notification settings - Fork 0
Migrating An Existing App
A runbook for the agent doing the migration. Ordered phases, each with an entry condition, executable steps, a machine-checkable exit condition, and a table of error codes to branch on. Strangler fig on one shared database — a big-bang rewrite is the topology that fails, and it is not documented here.
As of 2026-08. Source app: a production app on another stack — Rails, Node/Drizzle, Django, Laravel. This page is not about moving between Ultimate versions.
| You want | Page |
|---|---|
| move an app from another framework to Ultimate | this page |
move an app from @ultimat3/* 1.x or 2.x to the current major
|
Upgrading |
| what is unfinished in the release being adopted | Known gaps |
every X_* code named below |
Error codes |
| Rule | Detail |
|---|---|
Every command takes --json
|
the envelope is { ok, command, summary, steps?, findings?, data? }. .ok === true is the postcondition for every step below unless it says otherwise |
| A failure names a code |
.findings[].code is a stable X_*. Branch on it by name; never on the summary string |
| An unrecognised code |
x errors explain <CODE> --json → .data.fix is a runnable line. Run it, then re-run the failed step |
A x verify failure |
.steps[] carries { name, ok, findings[] }. The failing step's name is the branch |
| A fork | is pre-decided below, with the condition under which the other arm applies. Where a genuine judgement is required, it is a ⛔ STOP and you halt |
| Never invent a command | if a step names no command for a case, that case is a STOP |
Do not perform these unattended. Each is irreversible, outward-facing, or a judgement no repository state can settle.
| # | Stop |
|---|---|
| 1 | The production cutover — routing live traffic to Ultimate for the first time |
| 2 |
Dropping, truncating or retyping any legacy table or column — x db gen refuses without --allow-destructive; that refusal is the guard, do not pass the flag |
| 3 | Deleting legacy source data after a backfill, at any scale |
| 4 | DNS, load-balancer or reverse-proxy configuration changes |
| 5 |
Running any migration against production — ROLE=migrate in a deploy hook is a human's release, not a step here |
| 6 | Deciding which app owns DDL when both still run migrations (Phase 0, check 6) |
| 7 |
Any preflight branch this page marks STOP — a money column past the ceiling, a password scheme with no verifier, a required vector column |
| 8 | Retiring the legacy slice (Phase 7) |
Everything else here is safe to run against a development or branch database. x db branch create <name> --json gives you a disposable copy-on-write clone to work in.
Determine the current phase from repository and database state alone. Run in order; the first row that does not hold is the phase to enter.
| Probe | Holds when | Else |
|---|---|---|
x doctor --json → .ok
|
the app boots and its environment validates | fix what .findings[] names |
x entities --json lists every table this slice owns |
Phase 1 is done | Phase 1 |
x db migrate --json → .ok
|
no X_DB_DRIFT
|
Phase 2 |
x verify --json → .ok
|
the gate is green | Phase 2 |
a configureAuthenticator() call exists under apps/
|
a legacy session resolves to an Actor
|
Phase 3 |
x queries --json lists the slice's reads |
Phase 4 is declared | Phase 4 |
x actions --json lists the slice's writes |
Phase 5 is declared | Phase 5 |
x db backfill --pending --json → .ok
|
nothing declared is unswept | Phase 6 |
| all of the above | Phase 7 — ⛔ STOP |
If no app.config.ts exists at or above the working directory, every command answers X_NOT_IN_APP. Scaffold first: x new <name>.
Entry: a connection to the legacy database; read-only is enough. Exit: all seven checks answered, and none landed on a ⛔ STOP.
MoneyValue.minor is a JavaScript number. The ceiling is Number.MAX_SAFE_INTEGER = 9,007,199,254,740,991 minor units — roughly $90 trillion in USD cents. Past it the value is refused, never truncated.
Find the candidates:
select table_name, column_name, data_type
from information_schema.columns
where table_schema = 'public'
and (column_name like '%amount%' or column_name like '%price%'
or column_name like '%cents%' or column_name like '%total%'
or column_name like '%minor%' or column_name like '%balance%')
order by table_name, column_name;Then, per hit:
select max(abs(<column>)) as biggest from <table>;biggest |
Branch |
|---|---|
null, or under 9007199254740991
|
proceed — declare it as money()
|
at or over 9007199254740991
|
⛔ STOP. Report the table, the column and the value. The remedy is a judgement: the column may store units rather than minor units (fix the writer, then backfill), or the amount may be genuinely that large — then it is bigint() or decimal({ precision, scale }) with the currency in a column of your own. There is no bigint money type and there will not be one |
A single over-ceiling row makes every read of that entity throw X_INVARIANT_VIOLATED forever. Fix the row before the entity exists.
money() is an amount and a currency by construction. Per candidate column from check 1, confirm a currency column sits beside it in the same table.
| Table has | Declare as |
|---|---|
amount_cents + a currency column, no scale column |
money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }) |
amount_cents + currency + a scale column |
money({ columns: { minor, currency, scale } }) — per part, merged over the defaults, so a table that renamed one of the three does not restate the other two |
| an amount with no currency column |
decimal({ precision, scale }) plus the app's own currency column. Not money() — a single implied currency is the bug Money exists to prevent, and a framework that guessed one would guess on every row |
Detail: Money.
select data_type, count(*) as columns
from information_schema.columns
where table_schema = 'public'
group by data_type order by columns desc;Match every data_type against the type map.
| Result | Branch |
|---|---|
| every type has a builder | proceed |
timestamp without time zone |
plan the conversion to timestamptz as a migration step, naming the zone the old values were written in. There is no naive-timestamp builder and there will not be one |
USER-DEFINED that is a native Postgres enum type |
declare the column as text(). It reads and writes correctly; what is missing is a declaration that knows the variants |
USER-DEFINED that is vector (pgvector) |
⛔ STOP. A vector column on an entity is not declarable As of 2026-08. Report which table |
inet, cidr, tsvector, hstore, interval, citext, PostGIS |
⛔ STOP. No builder, none planned. Report the columns; the options are leaving them out of the entity or leaving the table to the legacy app |
select left(password_digest, 4) as scheme, count(*)
from users group by scheme order by count desc;| Prefix | Branch |
|---|---|
$2a$, $2b$, $2y$ (bcrypt) |
proceed. Verified: the hash verifies through verifyPassword, needsRehash answers true, and the first successful login rewrites it as argon2id — no code, no user-visible change |
$arg (argon2) |
proceed. Rehashed on login when the stored parameters are weaker than the policy |
anything else — pbkdf, sha, md5, scrypt, bespoke |
⛔ STOP. No verifier ships. Such a row is safe — it is the one generic credential failure, not a 500 — but it never migrates itself and it burns lockout budget on every attempt (Passwords). Report the schemes and their counts |
select relname, n_live_tup from pg_stat_user_tables order by n_live_tup desc limit 20;backfill() paces at rate batches per second of batch rows. At the defaults — 1,000 rows, 5 batches/second — 200 million rows is about eleven hours of wall clock. Record the number; it sizes Phase 6.
select table_name from information_schema.tables
where table_schema = 'public'
and table_name in ('schema_migrations', 'django_migrations', 'knex_migrations',
'_prisma_migrations', 'x_migrations')
order by table_name;| Result | Branch |
|---|---|
only x_migrations, or nothing |
proceed — Ultimate owns DDL |
| a legacy ledger table is present | ⛔ STOP. Two migration engines against one schema is the failure mode of every shared-database strangler. A human decides which one is frozen, and writes it down |
select table_name, column_name from information_schema.columns
where table_schema = 'public'
and column_name in ('org_id', 'tenant_id', 'account_id', 'workspace_id')
order by table_name;Presence of a tenant column is what turns tenancy on. Declare it with .tenant() on the column, or tenant: on the entity; omitted, it is inferred from .tenant() or a property named orgId. A tenant-scoped entity refuses any query with no org predicate — so a table listed here and not declared is X_TENANCY_UNSCOPED at the first read.
Entry: Phase 0 exits clean.
Exit: x entities --json → .ok, and every table this slice owns appears with every one of its columns.
One entity() per table, in packages/db/src/. Every column of every table the slice owns — a column left out is drift (Phase 2).
import {
arrayOf, bigint, bytes, database, date, decimal,
entity, json, money, t, text, timestamp, uuid,
} from '@ultimat3/entity';
export const orders = entity('order', {
table: 'tbl_orders', // the physical table
columns: {
id: uuid().primaryKey().column('order_uuid'),
orgId: uuid().tenant().column('tenant_id'),
legacyId: bigint().unique().column('id'), // int8 past 2^53 — row type is a string
reference: text({ max: 64 }).column('order_ref'),
total: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
taxRate: decimal({ precision: 18, scale: 8 }).column('tax_rate'),
effectiveOn: date().column('effective_on'), // a calendar date — PlainDate, no zone
payload: json(t.object({ source: t.string, retries: t.number.int() })).column('meta'),
signature: bytes().column('sig'),
tags: arrayOf(text({ max: 40 })),
createdAt: timestamp().defaultNow().column('created'),
},
});
export const db = database({ orders });
orders.$name; // 'order' — the framework's key
orders.$table; // 'tbl_orders' — the physical object
orders.$cacheTag; // 'entity:order' — keyed by the NAME, so renaming a table moves no tag| Override | Declares |
|---|---|
entity(name, { table }) |
the physical table, when it is not the entity's own name. The entity name stays the framework's key — the registry, the cache tag, x entities describe, every relation and every policy are keyed by it, so renaming a table never moves a cache tag or a policy. Index names follow the table, because an index is a physical object |
.column('<physical>') |
one column's physical name. Name it last in the chain — that link returns the general column, so a builder's own methods (defaultNow(), a uuid key's narrowed primaryKey()) come first. uuid() and timestamp() override it to keep theirs |
money({ columns: { minor, currency, scale } }) |
where the money columns already live, merged per part over the <name>_minor / <name>_currency / <name>_scale defaults. scale: null says the table has no scale column, which means every amount is at the currency's own minor unit |
Without an override a column is snake(property) and the table is the entity name.
Two vocabularies. columns.ts holds the opinionated builders — one way to store an id, an instant, money — each a decision the framework made for a table it was going to create. columns-data.ts holds the shapes a table already has. Both export from @ultimat3/entity.
| Legacy Postgres type | Declare as | Row type | As of 2026-08 |
|---|---|---|---|
uuid |
uuid() |
string |
shipped |
text, varchar(n)
|
text() / text({ max: n })
|
string |
shipped. The CHECK is char_length(col) <= n, not a varchar cast |
integer, serial
|
integer() |
number |
shipped |
boolean |
boolean() |
boolean |
shipped |
timestamptz |
timestamp() |
Date |
shipped |
text with a fixed value set |
enumerated([...]) |
the union | shipped — emitted as a CHECK, never CREATE TYPE, so adding a variant is a one-line migration instead of an ALTER TYPE that cannot run inside a transaction on older servers |
| a URL column | url() |
string |
shipped, validated on write |
| an IANA zone column | tz([...]) |
the union | shipped |
| a BCP-47 locale column | locale([...]) |
the union | shipped |
bigint + char(3) (+ integer scale) amount |
money({ columns }) |
MoneyValue |
shipped |
jsonb / json
|
json(schema) |
the schema's inferred type | shipped. The schema is required — there is no untyped json(), because a column returning unknown is the any hole this framework forbids, and the value arrives from the database as often as from a caller |
bigint / int8
|
bigint() |
string |
shipped. Not a JS bigint (JSON.stringify throws on one) and not a number (it loses digits past 2^53, exactly where a legacy int8 key or a snowflake id lives). Both driver spellings arrive and leave as one: Bun's sql returns int8 as a string, PGlite as a bigint
|
numeric / decimal
|
decimal({ precision, scale }) |
string |
shipped, and deliberately not arithmetic-friendly — the honest thing a driver already does is give you the digits. A value with more decimal places than the column stores is refused, not rounded. precision and scale are declared together, or neither |
date |
date() |
PlainDate |
shipped. A calendar date: no time, therefore no zone. A branded ISO string, not a Date — binding a Date to a date parameter fails outright on a server whose client zone has no name Postgres knows |
bytea |
bytes() |
Uint8Array |
shipped, normalised — Bun returns a Buffer, PGlite a Uint8Array, and the two serialise differently |
| an array column | arrayOf(column) |
readonly T[] |
shipped. The element is a column, so its own $parse decides every member. arrayOf(money()) and nested arrays are refused at declaration
|
timestamp without time zone |
— | — | no builder, and there will not be one. A naive timestamp is a bug that only surfaces twice a year |
a native Postgres enum type
|
text() today |
string |
deferred. text() reads and writes such a column correctly; what is missing is a declaration that knows the type's variants |
vector (pgvector) |
— | — |
deferred. PgVectorStore in @ultimat3/ai is the retrieval path; a vector column on an entity is not declarable |
inet, cidr, tsvector, hstore, interval, citext, PostGIS |
— | — | no builder, and none planned |
No command turns a live schema into entity() declarations As of 2026-08. introspect() from @ultimat3/db is public and gives the catalog as deterministically ordered, JSON-safe output — the same reader drift detection uses. It has exactly that one framework consumer: the /_x schema panel and the MCP schema.describe tool both answer from the entity registry, never from the live catalog, so what they show is what the app declared and not what the database holds.
import { introspect } from '@ultimat3/db';
const live = await introspect();
// { tables: [{ schema, name, columns, primaryKey, indexes, foreignKeys }] }Use it as the input; write the entities.
App tables only, As of 2026-08-24. introspect() excludes every relation Postgres records as extension-owned (pg_depend, deptype = 'e') and everything that is not an ordinary or partitioned table — views, materialised views and foreign tables. create extension pg_stat_statements in public is the CNPG, RDS, Supabase and Neon default, and its view used to read as unexpected-table with x db gen "add pg_stat_statements" as the fix, so every deploy failed terminally and the fix would have written an extension's internal view into the app's migration set. Ownership rather than a name: a pg_* prefix rule covers that view and misses PostGIS's spatial_ref_sys. A table someone created by hand carries no such dependency and is still unexpected-table, which is the finding this phase is about.
Entry: Phase 1 exits clean.
Exit: x db migrate --json → .ok and x verify --json → .ok.
x db gen "adopt <slice>" --json # entities diffed against what the migrations declare
x db migrate --json # apply, then the live post-migrate drift check
x verify --json # the gatex db migrate, x db reset and ROLE=migrate run a post-migrate check of the live catalog against what the applied migrations' snapshots declare. It filters out framework bookkeeping — every table whose name starts with x_ — and nothing else.
| Live | Reported | Meaning |
|---|---|---|
| a table no migration declares | unexpected-table |
a legacy table not modelled |
| a column on a modelled table that no snapshot names | unexpected-column |
a column left out of the entity |
| a nullability disagreement | changed-column |
the entity says NOT NULL and the table does not, or the reverse |
| a type disagreement | nothing | types are deliberately not compared — the catalog and a snapshot spell them differently often enough that comparing them would report drift on a correct database |
| an index no snapshot names | nothing | only the declared side is judged. A DBA's index is a planner decision, not a divergence |
x verify's drift step is a different check: it compares the entity source against the hash sidecar committed beside each migration and opens no database. Two conditions, two detectors, one X_DB_DRIFT.
No command snapshots what is already there as the starting point migrations are measured against. Two paths, pre-decided:
| Path | Take it |
|---|---|
| Model every table and every column | by default. It is the only path that ends with x db migrate green against the real database |
| Give Ultimate its own database | only when modelling the legacy schema is a bigger project than the migration. Drift is clean immediately; cross-database work becomes entirely yours, and db() holds one connection (One database, globally) |
A separate Postgres schema is still not a third path As of 2026-08, for one reason rather than the two this page carried until now.
The search_path itself now works. connectionUrl merges the operator's libpq options instead of assigning over them, on every role, so a DATABASE_URL carrying ?options=-c search_path=app survives everywhere. The framework wins only on the setting it names — the role's statement_timeout bound — and every other -c an operator wrote is theirs.
The drift check does not follow it. introspect() defaults schema to 'public', checkDrift() forwards a schema option only when it is given one, and runMigrations calls checkDrift({ migrations }) with none. So the post-migrate check reads public whatever the session's search_path is: it finds none of the app's tables, and reports every declared table as missing-table whose fix: is x db migrate — the command that just ran. There is no --schema flag on x db to close it with.
.findings[].code |
meta.kind |
Action |
|---|---|---|
X_DB_DRIFT |
unexpected-table |
add an entity() for the table cause names, then re-run x db gen
|
X_DB_DRIFT |
unexpected-column |
add the column to that entity — .column('<physical>') when the property name differs — then re-run x db gen
|
X_DB_DRIFT |
changed-column |
the fix: is the exact alter table … alter column … to put in a new migration |
X_DB_DRIFT |
unknown-schema |
the newest applied migration wrote no snapshot. The fix: names both remedies, in order |
X_MIGRATION_SNAPSHOT_MISSING |
— | the same condition from the other side. Take the fix: verbatim |
X_MIGRATION_DESTRUCTIVE |
— |
⛔ STOP. A committed up drops, truncates or retypes. Do not add -- destructive: true and do not pass --allow-destructive
|
X_TENANCY_UNSCOPED |
— | the entity has a tenant column and the query carries no org predicate. Scope the read |
X_NOT_IN_APP |
— | no app.config.ts at or above the cwd |
| anything else | — |
x errors explain <CODE> --json → run .data.fix → re-run the step |
Entry: Phase 2 exits clean.
Exit: a request carrying a legacy session cookie resolves to an Actor with the right id, orgId and roles.
Ultimate re-reads the user row on every request and trusts no claim from a token. A revoked user is revoked on the next request, with no stale-claims window — and that is why the bridge is app code rather than a config line.
configureAuthenticator() is the seam. Ultimate ships no general-purpose JWT verifier: verifyIdToken is OIDC-shaped and wants an issuer, a client id, a nonce and a JWKS key source, which a bespoke app JWT has none of.
import { userActor } from '@ultimat3/core';
import { configureAuthenticator } from '@ultimat3/http';
/** Your own verifier, over the legacy app's signing key. */
declare function verifyLegacyJwt(token: string): Promise<{
sub: string;
org: string;
roles: readonly string[];
} | null>;
configureAuthenticator(async (request) => {
const cookie = request.header('cookie') ?? '';
const token = /(?:^|;\s*)legacy_session=([^;]+)/.exec(cookie)?.[1];
if (token === undefined) return null;
const claims = await verifyLegacyJwt(token);
if (claims === null) return null;
return userActor({ id: claims.sub, orgId: claims.org, roles: [...claims.roles] });
});| Rule | Detail |
|---|---|
null means anonymous |
not an error |
| One authenticator per process | two functions answering "who is this?" is two identities per request |
Call it at module scope under apps/*/
|
a process with routes declaring auth: 'required' and no authenticator refuses every session, and x dev says so with an X_CONFIG_INVALID line naming the fix |
verifyPassword calls Bun.password.verify, which reads the algorithm out of the PHC string.
| Stored hash | Behaviour |
|---|---|
$argon2id$… at weaker parameters than the policy |
verifies; needsRehash is true; login() rewrites it at the current parameters while it still holds the plaintext |
$2a$ / $2b$ / $2y$ bcrypt
|
verifies; needsRehash is true (the PHC prefix is unrecognised, which the rule treats as "upgrade it"); rewritten as argon2id on that first successful login |
| PBKDF2, scrypt, SHA-family, MD5, bespoke, or a truncated hash | does not verify — it is the one generic credential failure, indistinguishable from a wrong password. Not a migration path: nothing rewrites the row |
An unreadable hash is safe, and it is silent — both matter here. Bun.password.verify throws on a hash it cannot parse (measured on bun 1.3.14: a Django pbkdf2_sha256$… row is UnsupportedAlgorithm, a truncated bcrypt string is InvalidEncoding). verifyPassword catches that and routes it through the same branch as an unknown user — burning the same full KDF, so neither the answer nor the response time separates it from a wrong password (packages/auth/src/password.ts). X_OVERLOADED from the KDF gate is re-thrown rather than swallowed: load shedding is not a verdict on the credential.
Three consequences you plan around:
| Consequence | What it means during a migration |
|---|---|
| It consumes lockout budget | a failed verification is a failed verification: recordFailure fires on the account, the IP and the org bucket alike (packages/auth/src/auth.ts:246). A table full of foreign hashes locks accounts out through ordinary login attempts, at the normal rate |
| Nothing is logged, deliberately | the algorithm of an unreadable hash is the same oracle one layer down, and a line per attempt is a spray amplifier. So you see uniform credential failures and no signal that the cause is the hash scheme rather than the password |
| It is not a migration path | nothing rewrites the row. bcrypt is the only "proceed" case — verified natively, needsRehash flags it, and the first successful login rewrites it as argon2id |
So a non-bcrypt estate still needs a plan before Phase 3: bridge the legacy login endpoint and keep it until the last user has signed in, or force a reset for the remainder.
OAuth2/OIDC with PKCE, nonce and JWKS signature verification ships, and the provider set is open — registerOAuthProvider(), or discoverOAuthProvider({ id, issuer }) to read one out of the issuer's discovery document. Okta, Entra, Ping, Keycloak and an internal gateway are all expressible.
SAML does not ship and will not. XML-DSig canonicalisation has no Bun native, and implementing it would put a real dependency in the primitive vocabulary. Put an OIDC-speaking SAML bridge in front and register that.
Entry: Phase 3 exits clean.
Exit: x queries --json lists the slice's reads, x verify --json → .ok, and the slice's read paths answer from Ultimate for a full traffic cycle with error rate and p95 unchanged.
| Step | Postcondition |
|---|---|
declare the slice's query()s and policys |
x queries --json → .ok, listing them |
| write the contract tests |
x verify --json → the .steps[] entry named contract has ok: true
|
| route the slice's read paths at the proxy | ⛔ STOP when this is production traffic |
Reads move before writes, and that ordering is what makes Phase 4 rollback-safe: routing the paths back at the proxy is a complete undo, because nothing was written.
| Concern | Consequence while both apps run |
|---|---|
| Caching | Ultimate's tags are its own. A legacy write it does not know about goes stale until its TTL → Caching and invalidation |
| Realtime | live queries observe Ultimate's own layer. A row the legacy app writes pushes no patch |
| Background jobs | Ultimate's queue is x_jobs plus a transactional outbox. It does not read the legacy queue and cannot |
Which is why the unit of migration is a slice: move a table's reads and writes together, or accept staleness on the half you did not move.
On 21.x, a live query fed by the WAL decoder (a real DATABASE_URL and the replicator
role) delivers a renamed column under its physical name: the decoder rebuilt an entity row from
the replication stream's column names alone and never consulted .column() overrides.
| Read through, on 21.x |
githubLogin: text().column('gh_login') arrives as |
|---|---|
a repository / query()
|
githubLogin |
| a live query patch | ghLogin |
The next major closes it — in the tree, not yet tagged, As of 2026-09-23: entityRow in
packages/realtime/src/pg-entity-row.ts
decodes through the registered entity (decodeRow, the repository's own reader), so a live patch
carries githubLogin too. Action on 21.x: do not make a slice with renamed columns your first
live slice, or have the subscriber map the physical name itself.
Entry: Phase 4 exits clean and has held for a full traffic cycle. Exit: the legacy write path for this slice is dead code, not a fallback.
| Step | Postcondition |
|---|---|
declare the slice's action()s and mutator()s |
x actions --json → .ok, listing them |
x verify --json |
.ok |
| route the slice's write paths | ⛔ STOP when this is production traffic |
Expand before you contract. New columns nullable, the old column still written for one release, no drops. That is what keeps the Phase 4 rollback available after writes move: routing back only works if Ultimate wrote nothing the legacy app cannot read.
Entry: Phase 5 exits clean.
Exit: x db backfill --pending --json → .ok (it exits non-zero while anything declared is unswept).
backfill() is a factory over job() — a one-pass sweep declared as durable background work. It resumes, it paces, and it refuses to run in an environment it was not declared for.
import { backfill } from '@ultimat3/jobs';
import { db, referenceFor } from './schema';
export const normaliseOrderRefs = backfill({
name: 'normalise-order-refs',
tenant: 'none',
requires: '20260814120000_add_order_ref', // the migration this sweep needs applied first
environments: ['staging', 'production'],
batch: 1_000,
rate: 2, // batches per second
source: () => db.orders.where({ reference: null }),
count: () => db.orders.where({ reference: null }).count(),
handle: async ({ rows }) => {
// Idempotent: the second run writes the same value, so a replayed page changes nothing.
await db.orders.upsertAll(
rows.map((row) => ({ ...row, reference: referenceFor(row.id) })),
{ onConflict: ['id'], onMatch: 'update' },
);
},
});x db backfill <name> --json # dry run — reports the plan, writes nothing
x db backfill <name> --write --json # enqueue the pass
x db backfill --list --json # the x_backfills ledger
x db backfill --pending --json # declared minus completed; non-zero exit when unswept| Property | Why it matters here |
|---|---|
Every page is its own step.run
|
a killed attempt resumes on the page it stopped at. What a step persists is a cursor, never the page |
handle is at least once
|
it runs before its checkpoint lands, so an attempt cancelled between the two replays that page. Write with upsertAll, updateWhere, or a statement whose second run changes nothing — never count + 1
|
| Pacing is mandatory |
rate defaults to 5 batches/second and there is no unthrottled mode: this pass shares its pool with the requests the app is still serving |
environments is a gate |
a mismatch is X_BACKFILL_ENVIRONMENT, refused inside the pass as well as by the CLI, because a backfill enqueued by app code never passes through a command |
requires names a migration |
checked against x_migrations, so a sweep cannot run against a schema that has not caught up |
count makes convergence arithmetic |
a pass whose source is exhausted while count still answers above zero has two predicates that disagree — X_BACKFILL_STALLED, an authoring bug |
| The ledger is append-only | a rerun is a new row; history is never overwritten. The idempotency key is the name, so a second enqueue while it runs is the same pass |
.findings[].code |
Action |
|---|---|
X_BACKFILL_ENVIRONMENT |
the sweep is not declared for this environment. Add it to environments:, or run it where it is declared |
X_BACKFILL_STALLED |
source and count disagree. Fix the predicate; do not re-run |
X_ABORTED |
the attempt was cancelled. Re-enqueue the same name — it resumes at the last checkpoint |
For reference data the new app is wrong without — currencies, plans, tiers, locations — use x db seed instead. A seed is replayable by construction and declares a tier: reference ships to production, dev does not reach it without --tier dev.
x db seed --dry-run --json # lists every declared seed, writes nothing
x db seed <name> --jsonFull detail: Migrations and backfills.
⛔ STOP. Every step of this phase is a human's.
| Step | Why it is a stop |
|---|---|
| Delete the legacy slice's source | the rollback path dies with it |
| Drop the legacy columns or tables | irreversible. x db gen refuses without --allow-destructive, and that refusal is the guard |
| Delete migrated source data | irreversible at any scale |
| Retire the legacy login endpoint | strands every user still holding a legacy cookie |
Report to the human: which slices are complete, x verify --json's result, x db backfill --pending --json's result, and the exact tables and columns that would be dropped.
| Topology | Supported As of 2026-08
|
|---|---|
| Strangler fig, one shared database | the supported path. Everything above assumes it |
| Strangler fig, two databases |
partially. db() is one global handle, so Ultimate holds exactly one of the two. The reconciliation is entirely yours |
| Read replica first |
no, and that is unchanged by the read/write split that now ships. DATABASE_REPLICA_URL + withReplicaReads route plain reads off a primary the app still holds; pointing DATABASE_URL itself at a replica fails every write, the migration ledger's included. A replica is a capacity tier, never a cutover step |
| Big bang | technically possible, and the one that fails. Not documented here |
db() is a module-level singleton in packages/db/src/client.ts: one ambient client, built lazily from DATABASE_URL, sized by the runtime ROLE. setDbClient() replaces it and does not add a second.
| Consequence | Detail |
|---|---|
| Ultimate holds exactly one database | a second is reachable only through your own client — outside db(), outside transactions, outside the observer seam, outside N+1 detection |
Inside withTransaction, db() is the transaction |
which is what lets a repository written against db() join the caller's transaction without knowing it exists |
| The pool is sized per role |
web: 20, sync: 10, worker: 8, scheduler: 2, migrate: 1, replicator: 4. DATABASE_POOL_MAX is the one knob an operator turns without a rebuild — 400 web pods at 20 is 8,000 backends against a max_connections of 450 |
Verified against the code, As of 2026-08.
| Gap | Detail | Do this instead |
|---|---|---|
| No schema baseline | every unmodelled table is unexpected-table, every unmodelled column is unexpected-column; no command snapshots the pre-existing schema as a starting point |
model everything, or give Ultimate its own database |
| No schema-to-entity generator | no command turns a live schema into entity() declarations. introspect() is public and gives the catalog as JSON |
write the entities from introspect()'s output |
| Three column shapes have no builder | a naive timestamp without time zone (permanently), a native Postgres enum type, and vector
|
convert a naive timestamp to timestamptz; declare an enum column as text(); keep vectors in PgVectorStore
|
| A renamed column arrives under its physical name in a live query |
@ultimat3/realtime rebuilds rows from physical names and cannot read .column() overrides across the tier boundary |
do not make a renamed slice live first, or map the name in the subscriber |
| One database globally |
db() is a singleton |
one Postgres, or a second client outside the framework |
shipped As of 2026-08-24 — DATABASE_REPLICA_URL builds a primary + replica client and withReplicaReads(fn) is the scope inside which a plain read may leave the primary, with read-your-writes and a 3-failure/10s breaker back to it |
opt in twice (Configuration → Read replicas); the scope is the app's to open | |
| No legacy-hash verifier beyond bcrypt/argon2 | an unreadable hash is the generic credential failure — safe, silent, and it consumes lockout budget — but nothing rewrites the row | bridge the legacy login, or force a reset |
| No SAML | permanently out of scope | an OIDC bridge in front |
No app-contributed raw Route
|
the HTTP surface is composed from actions, queries, assets and page routes; configureAuthenticator() is the only app-installed hook of that shape |
express the endpoint as an action or a route
|
| Cache and realtime do not see legacy writes | Ultimate's tags and live queries observe its own layer | move a slice's reads and writes together |
Available at every phase up to 7, by routing the slice's paths back to the legacy app at the proxy.
| Risk | Mitigation |
|---|---|
| A migration dropped or retyped a column |
x db gen needs --allow-destructive to emit one, and a committed up that destroys must carry -- destructive: true or the gate refuses it. Take the refusal as the answer |
| Ultimate wrote a column the legacy app cannot parse | add columns nullable, expand before you contract, keep the old column written for one release |
| A backfill half-ran | it resumes; it does not restart. Re-enqueue the same name |
| Sessions were reissued as Ultimate sessions | keep the legacy cookie valid until the slice is permanent |
- Strangler fig on one shared database. Move a slice's reads and writes together.
- Every step has a machine-checkable postcondition:
--json, then.ok; on failure,.findings[].code. - Branch on error codes by name. An unrecognised one is
x errors explain <CODE> --json, then.data.fix. - One owner for DDL, decided by a human before Phase 1.
- Audit money before writing an entity:
select max(abs(<col>))against 9,007,199,254,740,991, and confirm a currency column beside every amount. - An amount with no currency column is
decimal()plus your own column, nevermoney(). - Declare every table and every column, or drift is red on every
x db migrate. - Physical names are overrides:
entity(name, { table }),.column('<physical>'),money({ columns }). The entity name stays the framework's key. - A live query delivers a renamed column under its physical name.
- Bcrypt migrates itself on first login. Nothing else does — a foreign hash fails safely, silently, and forever.
- Reshape with
backfill(): paced, resumable, at least once, so everyhandlemust be idempotent. - Reads move before writes, so rollback stays a proxy change.
- Anything irreversible or outward-facing is a ⛔ STOP. Halt and report.
Ultimate — v22.1.0 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
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
Reference