Data branching
Branch the live schema and REHEARSE your migration on it — apply the plan to a throwaway copy, flag every lossy operation, prove it converges, drop the branch. Plus the branch primitive itself (namespace snapshot on Postgres, Neon copy-on-write fast-path).
Data branching creates an isolated copy of a schema you can read, write and migrate without touching the source. The headline use is not the branch — every serverless-Postgres vendor sells one of those — it is what Voltro can do WITH a branch that a vendor cannot: rehearse your pending migration on it and tell you what it would do.
voltro db branch — the migration rehearsal
voltro db branch --pr 128 # branch the live schema, rehearse, report, drop it
voltro db branch --pr 128 --seed copy # …with the parent's rows copied in
voltro db branch --pr 128 --keep # leave the branch standing to poke at
voltro db branch --pr 128 --json # machine-readable, for a PR commentWhat it does, in order:
- Branches the LIVE schema into a throwaway namespace (
br_pr128_<app>). - Checks fidelity — plans the declared schema against the branch AND against the parent, and aborts if the two disagree. A branch that is not a faithful copy rehearses a different migration from the one you are about to run, and saying nothing about that would be worse than not rehearsing at all.
- Plans your migration against the branch and classifies every operation.
- Executes it there, including the destructive operations.
- Re-plans. An empty re-plan is the verdict; a migration that applies and then re-proposes itself forever is not a migration.
- Drops the branch (unless
--keep), even when the apply failed.
branch rehearsal · br_pr128_shop · mechanism namespace
branched 18 table(s), replayed 3 foreign key(s)
plan: 2 operation(s), 1 lossy, 0 refused
• add-column members [safe]
✗ drop-column members [lossy]
⚠ 1 operation(s) DESTROY DATA. They were executed on the branch (it is
disposable) so they are rehearsed, but production refuses them until you set
VOLTRO_DESTRUCTIVE_OK — naming the tables, not `1`.
✓ applied on the branch, and the re-plan is EMPTY (the migration converges).
branch br_pr128_shop torn down.
Lossy operations are executed, not skipped
Production refuses a drop-column until a human acknowledges it. A branch that
is about to be dropped has no such reason — and refusing there would mean the one
operation most likely to fail is the one operation never rehearsed. So the
rehearsal unblocks lossy operations on the branch, runs them, and leads the report
with every one of them. That report is the thing you paste into the PR.
Operations the planner refuses anywhere (needs-rename-annotation,
multi-step) are NOT executed — the plan is reported as blocked instead.
Exit codes
| code | meaning |
|---|---|
0 |
applied on the branch and converged, nothing lossy |
2 |
a REVIEW signal: the plan destroys data, or the planner refuses part of it |
1 |
the rehearsal could not answer — it failed, the branch was not faithful, or the migration did not converge |
lossy is deliberately not an error. A drop-column in a PR is a normal,
intentional thing; making it exit 1 trains people to pass --force, and the
next real failure goes with it.
What --seed does and does not rehearse
--seed empty (the default) branches the SCHEMA only. That is enough for every
structural question and costs nothing. It does not rehearse anything
data-dependent: a NOT NULL meeting existing NULLs, a backfill meeting real
values, a unique constraint meeting duplicates, or the row-count threshold that
promotes an operation to online-required. Use --seed copy for those — it
copies every row, which is fast on a small database and slow on a large one.
Mechanisms — what actually works
Be precise here, because the vendor landscape invites over-claiming. The branch plan is dialect-agnostic. The shipped executor is not.
| mechanism | when | status |
|---|---|---|
namespace |
Postgres — a schema per branch | shipped, and what voltro db branch uses |
neon-cow |
Postgres on Neon, seed: 'copy' |
plan shipped; executed by the cloud control plane, not the CLI |
- MySQL, MariaDB, SQLite and SQL Server are not supported by
voltro db branch.makeNamespaceBranchExecutoremitsCREATE SCHEMA,CREATE TABLE … (LIKE … INCLUDING ALL)and"-quoted identifiers, none of which those engines accept. The command refuses with that reason rather than sending Postgres syntax at them. Writing your ownBranchExecutorfor another dialect is the supported path — the plan is already portable. - Neon copy-on-write is a call to Neon's branch API and needs a Neon token,
which the CLI does not hold.
voltro db branchnames the mechanism and refuses, pointing at--prefer namespace. The cloud control plane executes it. - There is no Supabase mechanism and no template-database mechanism. Neither exists in the codebase.
The plan/execute split
Branching is a plan (what to do) + an executor (do it), mirroring the migration engine. The plan is pure and inspectable; the executor performs the I/O.
import { planBranch, resolveBranchMechanism } from '@voltro/database'
const mechanism = resolveBranchMechanism({ seed: 'copy', dbUrl: connectionString })
const steps = planBranch({
mechanism,
branchId: 'br_pr128_shop',
tableNames,
seed: 'copy',
parentNamespace: 'public',
})Provision + tear down
provisionBranch / teardownBranch run a plan through an injected
BranchExecutor. Injected so the lifecycle is unit-testable with a recording
executor — no live database:
import { provisionBranch, teardownBranch, makeNamespaceBranchExecutor } from '@voltro/database'
const result = await provisionBranch(
{
appSlug: 'shop', prNumber: 128, seed: 'copy', tableNames,
parentNamespace: 'public',
foreignKeys, // see below — LIKE does not copy these
indexNames, // see below — LIKE renames these
dbUrl: connectionString,
},
makeNamespaceBranchExecutor({ run: (sql) => client.query(sql) }),
)
await teardownBranch(result.branchId, result.mechanism, executor)admitBranch enforces the storage-cost caps (a TTL and a maximum number of live
branches) before a new one is provisioned.
Two things LIKE … INCLUDING ALL does not carry
Both were found by pointing the rehearsal at a real Postgres and watching a converged schema propose work. They are properties of Postgres, not of Voltro's emission, and a branch missing either is not a copy:
- Foreign keys are not copied. There is no
INCLUDINGclause that copies them. A branch without them accepts writes production rejects. - Index names are re-derived from the table and columns. Measured on pg 17:
byApiKeyTenantcame back as_voltro_api_keys_tenantId_idx, and_voltro_idempotency_scope_key_uqas…_scope_key_idx. The migration planner compares indexes by name, so every custom-named index reads as a different index.
provisionBranch replays both (foreignKeys + indexNames, sourced from the
parent's introspected snapshot). voltro db branch does it for you, and refuses
to report anything about your migration if the branch still differs from the
parent.
Branch-per-PR in CI
- run: voltro db branch --pr ${{ github.event.number }} --json > rehearsal.json
continue-on-error: true # exit 2 is a review signal, not a build failure
- run: node scripts/comment-rehearsal.mjs rehearsal.jsonThe --json report carries operations, lossy, blocked, residual,
converged and infidelity — everything a PR comment needs, already classified.