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 comment

What it does, in order:

  1. Branches the LIVE schema into a throwaway namespace (br_pr128_<app>).
  2. 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.
  3. Plans your migration against the branch and classifies every operation.
  4. Executes it there, including the destructive operations.
  5. Re-plans. An empty re-plan is the verdict; a migration that applies and then re-proposes itself forever is not a migration.
  6. 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. makeNamespaceBranchExecutor emits CREATE 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 own BranchExecutor for 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 branch names 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 INCLUDING clause 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: byApiKeyTenant came back as _voltro_api_keys_tenantId_idx, and _voltro_idempotency_scope_key_uq as …_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.json

The --json report carries operations, lossy, blocked, residual, converged and infidelity — everything a PR comment needs, already classified.