Branchwater (bw)
git for your local databases.
Branchwater is an open-source, engine-agnostic TypeScript/Node CLI that brings a
git-like workflow to the databases on your laptop. Take a snapshot of your
local data, branch off to try something risky, checkout back to a known
good state, and delete the experiment when you are done — all without
hand-rolling pg_dump scripts or nuking your dev database by accident.
It is built for the everyday loop of local development: seeding fixtures, testing a destructive migration, reproducing a bug against a specific data shape, or handing a teammate the exact state that triggered an issue. Your data becomes versioned, restorable, and disposable.
$ bw snapshot -m "seeded demo accounts"
$ bw branch try-risky-migration
$ # ...run the migration, it goes sideways...
$ bw checkout main # back to the clean seed, instantly
Why Branchwater
- Safe by default. Every
checkoutfirst takes an autosave of your current state, so switching branches can never silently lose what you had. - Engine-agnostic core. The version-control brain knows nothing about Postgres, MySQL, or any specific engine. Engine support is a plugin.
- Plain JSON manifest. Your history lives in a readable
.bw/manifest.jsonyou can inspect, diff, and reason about — no opaque binary store. - No magic dependencies. A small, focused dependency set (
commander,picocolors,zod) and Node's built-in crypto.
Architecture
Branchwater has three moving parts, with a strict boundary between the engine-agnostic core and the engine-specific plumbing.
┌─────────────────────────────────────────┐
bw <command> │ CLI (commander) │
│ │ init · snapshot · branch · checkout │
▼ │ · list · delete │
└───────────────────┬─────────────────────┘
│ (talks only to the core)
▼
┌─────────────────────────────────────────┐
│ Orchestrator │
│ the engine-agnostic "brain": coordinates │
│ snapshot / branch / checkout / delete │
│ across every configured engine. │
└─────┬──────────────────────────────┬─────┘
│ │
reads/writes │ │ calls only the
▼ ▼ EngineAdapter contract
┌────────────────────┐ ┌──────────────────────────┐
│ JSON manifest │ │ EngineAdapter │
│ .bw/manifest.json │ │ (interface contract) │
│ branches+snapshots │ └────────────┬─────────────┘
└────────────────────┘ │ resolved at startup
▼ by the composition root
┌──────────────────────────┐
│ PostgresAdapter │
│ (src/adapters/postgres) │
│ pg_dump / pg_restore ... │
└──────────────────────────┘
1. The Orchestrator (the brain)
src/core/orchestrator.ts turns a manifest plus a set of adapters into
the "git for your databases" experience:
- A single logical snapshot bundles one per-engine artifact id per engine.
- A branch is just a named pointer at a snapshot.
- A checkout swaps the working state across all engines at once.
The orchestrator is engine-agnostic by construction. It talks to concrete
engines only through the EngineAdapter interface, resolved from an injected
registry. It imports nothing from src/adapters/**.
2. The EngineAdapter contract (the boundary)
src/core/adapter/types.ts defines the entire contract between the core and
any database engine:
export interface EngineAdapter {
readonly type: string;
validate(ctx: AdapterContext): Promise<void>;
snapshot(ctx: AdapterContext): Promise<SnapshotResult>;
restore(ctx: AdapterContext, id: EngineSnapshotId): Promise<void>;
list(ctx: AdapterContext): Promise<EngineSnapshotInfo[]>;
delete(ctx: AdapterContext, id: EngineSnapshotId): Promise<void>;
}The core hands each adapter an AdapterContext containing the opaque
connection config, an assigned storageDir
(<bwDir>/snapshots/<engineName>), and a logger. The orchestrator owns where
artifacts live; the adapter owns how they are produced. An EngineSnapshotId
is an opaque token the core stores and forwards but never interprets.
3. The JSON manifest (the history)
src/core/manifest/store.ts persists history to .bw/manifest.json, written
atomically (temp file + rename). On disk:
.bw/
├── manifest.json # version, head, branches, snapshots
└── snapshots/
└── <engineName>/
└── <engineSnapshotId>.<ext> # the engine's own artifact (e.g. a dump)
The manifest records branches (name -> snapshotId), snapshots
(id, parent, createdAt, message, and a per-engine engines map), and the
current head.
The overriding rule: engine boundary
Nothing under
src/core/**orsrc/cli/commands/**may import anything undersrc/adapters/**.
All Postgres (and future engine) specifics live only under
src/adapters/**. The core talks solely to the EngineAdapter interface. The
single exemption is the composition root src/cli/index.ts, which registers
the concrete PostgresAdapter factory into the registry and hands that registry
(never a concrete adapter) to the core.
How a new adapter slots in
Adding an engine never touches the core. You:
Create
src/adapters/<engine>/implementingEngineAdapter(and validating its ownconnectionblock withzod).Export a zero-argument
AdapterFactory(e.g.createMysqlAdapter).Register it in the composition root
src/cli/index.ts:registry.register("mysql", createMysqlAdapter);
That is the whole integration surface. See docs/ADAPTERS.md
for the full guide.
Install
Branchwater is a Node CLI (requires Node 18+). Install it globally from npm:
npm install -g branchwater
bw --helpOr run it without installing:
npx branchwater --helpFrom source
git clone https://github.com/namanchopra/branchwater.git
cd branchwater
npm install
npm run build # compiles TypeScript to dist/
npm link # makes the `bw` binary available on your PATHYou should now have bw available:
bw --helpPostgres prerequisites: the bundled Postgres adapter shells out to the standard
pg_dump/pg_restore/psqlclient tools, so make sure they are installed and on yourPATH.
Quickstart
A complete, copy-pasteable loop — initialize, snapshot, branch, experiment, restore, and clean up.
# 0. From your project directory, point bw at your local database.
# bw init scaffolds a bw.config.json you then edit (see Configuration below).
bw init
# Edit bw.config.json so the connection points at your local dev database,
# e.g. postgres://postgres:${PGPASSWORD}@localhost:5432/app_dev
export PGPASSWORD=postgres
# 1. Capture the current state as your first snapshot.
bw snapshot -m "baseline: clean seed data"
# 2. Branch off to try something risky. The new branch points at the
# same snapshot and becomes your current branch (HEAD).
bw branch try-risky-migration
# 3. ...run your migration / mutate data / break things...
# Then capture the experiment, too:
bw snapshot -m "after risky migration"
# 4. Changed your mind? Jump back to the pristine baseline.
# checkout autosaves your current state first, then restores 'main'.
bw checkout main
# 5. See where you are: branches, the current HEAD, and snapshots.
bw list
# 6. Throw the experiment away. (You can't delete the branch you're on,
# which is why we checked out 'main' first.)
bw delete try-risky-migrationThat is the entire core workflow. Everything else is detail.
Configuration
bw init scaffolds a bw.config.json in your working directory. A minimal
single-Postgres config (also available as bw.config.example.json):
{
"version": 1,
"engines": [
{
"name": "app-db",
"type": "postgres",
"connection": {
"url": "postgres://postgres:${PGPASSWORD}@localhost:5432/app_dev"
}
}
]
}version— config schema version (currently1).engines— one entry per database you want versioned together.nameis a label you choose (and becomes the on-disk snapshot subdirectory);typeis the engine discriminator the adapter is registered under (e.g."postgres").connection— opaque to the core. Only the matching engine adapter reads and validates it. The Postgres adapter accepts aurl.
Environment interpolation. Any ${VAR} reference inside a string value is
resolved from process.env at load time — keep secrets like passwords out of
the committed config. An unresolved reference (e.g. ${MISSING} with no such
env var) throws an error rather than silently substituting an empty string.
Adding a second engine? Just append another entry; snapshot, checkout, etc.
fan out across all of them.
Commands
Branchwater exposes six commands. All accept these global flags:
| Flag | Meaning |
|---|---|
--config <path> |
Use a specific bw.config.json instead of <cwd> one. |
--cwd <dir> |
Resolve the config and the .bw directory against dir. |
--json |
Emit a machine-readable JSON result on stdout. |
--yes |
Assume "yes" for destructive confirmations. |
--verbose |
Verbose logging and full error stack traces. |
bw init
Scaffold a Branchwater project: create the .bw directory and manifest, and
write a starter bw.config.json you then edit to point at your database.
bw initbw snapshot [-m <message>]
Capture the current state of every configured engine as one logical snapshot,
record it in the manifest, and advance the current branch (HEAD) to it. The
optional message is free-form and shown in bw list.
bw snapshot -m "seeded 50 demo accounts"If any engine fails to snapshot, Branchwater deletes the partial artifacts it already wrote and aborts without recording anything — you never end up with a half-captured snapshot.
bw branch <name>
Create a new branch pointing at the current HEAD snapshot, and switch to it. This
does not take a new snapshot — it is a cheap named pointer, like
git branch && git checkout. (You must have at least one snapshot first.)
bw branch try-risky-migrationbw checkout <name>
Switch to a branch, restoring every engine to that branch's snapshot. Before restoring anything, Branchwater takes an autosave snapshot of your current state onto your current branch, so the pre-checkout state is never lost. The result reports which engines restored, which failed, and the autosave id.
bw checkout mainIf a restore partially fails, HEAD is left on the autosave snapshot so recovery
is unambiguous (no silent split-brain). Use --yes to skip confirmation in
scripts.
bw list
Show the manifest: all branches, which snapshot each points at, the current HEAD,
and the snapshot history. Add --json for a machine-readable dump.
bw list
bw list --jsonbw delete <name>
Delete a branch. Any snapshot left unreferenced by any branch is then garbage-collected, and its per-engine artifacts are removed from disk. You cannot delete the branch you are currently on — check out another branch first.
bw checkout main
bw delete try-risky-migrationWeb UI (bw ui)
Prefer a browser to a terminal? bw ui launches a small local web app for
browsing your branches and snapshots, inspecting table data, and diffing two
branches side by side — all backed by the same engine-agnostic core the CLI
uses. It is read-mostly: the only state-changing actions it exposes are the same
four operations as the CLI, and the destructive ones require an explicit
confirmation (see below).
# Start the UI and open it in your browser.
bw ui
# Pick a fixed port instead of a random free one.
bw ui --port 4321
# Start the server but do NOT auto-open a browser (print the URL instead).
bw ui --no-openOn start, bw ui prints a tokenized URL such as
http://127.0.0.1:<port>/?token=<session-token> and (unless --no-open) opens
it for you. The server runs in the foreground until you stop it with Ctrl-C,
at which point it shuts down gracefully.
Flags
| Flag | Meaning |
|---|---|
--port <n> |
TCP port to bind, 0–65535. Default 0 lets the OS pick a free port. |
--no-open |
Do not auto-open a browser; just print the URL to open yourself. |
bw ui also honors the global flags (--config, --cwd, --json,
--verbose). With --json, it prints { "url", "token", "port" } and does not
open a browser — handy for scripting or wiring the UI into another tool.
Security model: localhost + per-session token
The UI is a tool for your machine only, and it is locked down accordingly with two independent layers of defense:
- Loopback binding. The server binds
127.0.0.1only — never0.0.0.0or a LAN address — so it is unreachable from any other host on the network. This is asserted at startup; bw refuses to bind a non-loopback interface. - Per-session token. Each
bw uirun mints a fresh, cryptographically random 256-bit session token. Every/api/*request must present it (via thex-bw-tokenrequest header, or a?token=query parameter for the first navigation) or it is rejected with HTTP 401. The token is valid only for the lifetime of that one server run; stopping and restartingbw uimints a new one and invalidates the old.
The token is embedded in the URL bw ui prints/opens, so your first navigation
authenticates automatically and the web client re-uses the token (as a header)
for every subsequent API call. The static page shell and assets load without a
token so the app can bootstrap and then authenticate its own requests. Because
the token gates the whole API, copying that URL is what hands access to someone
else — treat it like a password and do not paste it into shared logs or chats.
What you can do
- Branch & snapshot explorer. See every branch, which snapshot each points
at, the current HEAD, and the snapshot history — the same view as
bw list. - Table browser. For any engine whose adapter supports inspection, browse its
tables (names, columns, and row counts) and page through the rows read-only. An
engine whose adapter does not implement that capability simply does not offer
data views — see
docs/ADAPTERS.md. - Cross-branch diff. Pick a
fromand atobranch to see what changed: added/removed tables, per-table row-count deltas, and column-level schema changes (and, when the engine can materialize both sides, representative added/removed rows). - Run the core operations. Trigger
snapshot,branch,checkout, anddeletefrom the UI, just like the CLI. - Edit your data (table actions). For any engine whose adapter supports mutation, the UI becomes a small database editor — see Table actions below.
Table actions
For an engine whose adapter implements the optional mutation capability
(MutableAdapter — see docs/ADAPTERS.md), the table
browser turns into a lightweight database editor. An engine whose adapter does
not implement it is read-only: the table browser still lets you page through
rows, but none of the actions below are offered. There are four sets of actions:
- Row edits — edit · insert · delete. Change a cell and save it, add a new row, or remove a row, straight from the table view. Edits and deletes target the table's primary key when the engine reports one, falling back to the full original row otherwise — so the action affects exactly the row you picked. A delete (or update) that would match no specific row is refused rather than risk touching every row.
- Table-level — truncate · drop. Empty a table (
truncate, structure preserved) or remove it entirely (drop). Both are destructive and gated (see below). - SQL console. Run an ad-hoc SQL statement against the engine. Result-
returning statements (e.g.
SELECT) come back as a bounded table of rows; everything else reports the engine's command tag and the affected row count. The number of returned rows is capped. - Export. Download the current table (or a query result) as CSV or JSON for use outside Branchwater.
Undo: every write auto-snapshots first
Table actions are deliberately fearless because they are reversible. Before any
write runs, Branchwater takes an automatic snapshot of the current state — the
same mechanism bw checkout uses to autosave before restoring. The response to
every action includes the id of that undo snapshot, and the UI surfaces a
one-click Undo that restores it, rolling the engine back to exactly how it
looked the instant before the action.
So the lifecycle of any table action is:
confirm → auto-snapshot ("before <action>") → run the write → offer Undo
Because the pre-write state is captured first, even a drop table or a bad
SQL statement is recoverable: hit Undo and the auto-snapshot is restored.
(These auto-snapshots are ordinary snapshots, so they also show up in bw list
and participate in normal garbage collection.)
Every write is confirmed and token-gated
Table actions sit behind the same two guardrails as the rest of the API, with no exceptions:
- Confirmation required. Every mutating request must carry
confirm: true. An unconfirmed request is rejected (HTTP 400,confirmation_required) and the database is left untouched — the UI must surface an explicit confirmation step first. This applies to all writes (even a single-cell edit), not just the obviously destructivetruncate/drop. - Token-gated. Like every
/api/*call, a table action must present the per-session token (see Security model) or it is rejected with HTTP 401. Combined with loopback-only binding, the editor is reachable only from your own machine, by whoever holds the session token.
Destructive operations require confirmation
Just like the CLI guards checkout and delete, the UI never performs a
state-restoring or branch-removing action implicitly. checkout and delete
require an explicit confirmation before they run — the API rejects an
unconfirmed request, so the UI must surface a confirmation step first. Capturing
a snapshot or creating a branch is non-destructive and proceeds directly.
Multi-engine write consistency (important caveat)
Branchwater v0 does not provide cross-engine point-in-time consistency.
When you configure more than one engine, bw snapshot captures each engine
sequentially and best-effort, one after another. Branchwater does not
freeze, quiesce, or coordinate writes across engines while it works. That means:
- If your application keeps writing during a multi-engine
snapshot, the artifacts for engine A and engine B can reflect slightly different moments in time. A row that exists in one engine's snapshot may not yet exist in the other's. - The all-or-nothing guarantee Branchwater does make is manifest atomicity: if any engine fails to snapshot, the already-written artifacts are cleaned up and no manifest entry is recorded. This prevents a half-recorded snapshot — it does not guarantee the successfully-captured engines were consistent with one another at a single instant.
For a faithful single-instant snapshot across engines today, quiesce writes
yourself before running bw snapshot (e.g. stop the app, pause background
workers, or run within a maintenance window).
True cross-engine point-in-time consistency — coordinating a consistent cut across all engines without requiring you to stop writes — is future work.
Project layout
src/
├── core/ # engine-agnostic brain — imports NO adapters
│ ├── adapter/ # EngineAdapter contract + registry
│ ├── manifest/ # JSON manifest types, schema, atomic store
│ ├── config/ # config types, zod schema, env-interpolating loader
│ └── orchestrator.ts # coordinates snapshot/branch/checkout/list/delete
├── adapters/
│ └── postgres/ # the ONLY place Postgres specifics live
├── server/ # local web UI server (node:http) — imports NO adapters
│ ├── dto.ts # API types shared with the web client
│ ├── http.ts # router, static serving, JSON body + SPA fallback
│ ├── security.ts # loopback binding + per-session token guard
│ └── routes/ # ops / inspect / diff endpoints
├── cli/
│ ├── index.ts # composition root — the sole adapter-importing file
│ └── commands/ # the command handlers (incl. `ui`)
└── util/ # exec, ids, logger, spinner helpers
The web client lives in a separate web/ npm workspace (Vite + React) and talks
to the server above purely over the documented HTTP API. Like the core, the
server imports nothing from src/adapters/**; it reaches engines solely through
the Orchestrator.
See docs/ADAPTERS.md for how to build a new engine adapter.
License
MIT.