Database Management CLI
Growing twilldb from a project scaffolder (spec 18) into a database management CLI — run SQL and inspect data, author and apply migrations, generate client types, and manage copy-on-write branches — in the spirit of the Supabase CLI, but shaped by Twill's embeddable, single-writer, branchable architecture. This page is the design intent. Milestones 1, 2 & 3 are implemented (issue #110), all behind the manage cargo feature: Milestone 1 — sql, shell, tables/describe, migrate new/up/status, gen types, seed, stats; Milestone 2 — branch create/list/delete (a branch is addressed as <url>#branch=<id>), migrate up --branch (preview-and-swap), db reset, schema dump, and serve (the engine behind pgwire); Milestone 3 — the postgres:// pgwire transport across the read/inspect/migrate commands, so the same surface manages a running engine-server single-writer-safely. Both transports are chosen purely by the connection-string scheme.
Purpose & relationship to scaffolding (spec 18)
Spec 18's twilldb new/init only writes files — it never opens a database. Management is the opposite: every command here talks to a real database. That single difference drives the whole design (a transport, the single-writer lease, the engine linked into the binary). The two halves share one binary and one help surface; the scaffolder stays dependency-free, and the management half is additive behind a cargo feature.
What this is modeled on, and where it deliberately diverges
The Supabase CLI is the reference for the command vocabulary (sql, migration new/up/list, db reset, gen types, branches, seed). Twill diverges on three axes that make some commands easier and some harder: it is embeddable (no server required — a transport choice, not a given), single-writer (a separate CLI process is itself a writer), and branchable (copy-on-write branches are a first-class engine capability, not a cloud feature). The design leans into the third and respects the first two.
Design principles
- MUST reach durable state only through the engine's public API (the
twill-enginecrate'sConnection:exec/query/prepare/catalog/branch/stats) or over the pgwire wire — never by opening a file, socket, or cloud SDK directly. No new engine or storage-seam surface is required; management is a consumer of what Phases 1–6 already expose. - MUST choose its transport by the connection-string scheme, exactly like the engine chooses its backend:
file:///s3://→ embedded (link the engine);postgres://→ a pgwire client. Unknown schemes are rejected, never defaulted. - MUST respect the single-writer lease (see below) — the CLI is a writer when it opens a database embedded, so it must not be used to mutate a database a server/app currently holds; manage a live deployment over
postgres://instead. - MUST be additive and feature-gated: the management commands link
twill-enginebehind amanagecargo feature, so a default build remains the lean, dependency-free scaffolder (the same walltwill-bench'scustom-profilefeature uses). - SHOULD be safe-by-default: read-only commands (
sqlSELECT,tables,describe,stats,gen types) run anywhere; destructive commands (db reset,migrate upon a non-empty db) confirm or require an explicit flag. - MUST NOT add a GUI, a cloud/login surface, or a hosted control plane — there is no Twill cloud;
link/login/studioare explicitly out of scope.
Transport model & the single-writer caveat
Two transports, chosen by URL scheme:
| Scheme | Transport | Mechanism | Best for |
|---|---|---|---|
file://, s3:///r2:///gs:// | Embedded | links twill-engine; opens a Connection in-process (function-call latency, no server) | local dev, CI, one-off scripts, an offline/stopped database |
postgres:// | pgwire (implemented) | connects to a running engine-server as a plain Postgres client (cleartext subset), reusing twill-bench's dependency-free pgclient | a live or shared deployment |
The CLI is a writer when it opens a database embedded
Twill is single-writer-per-database, enforced by a durable, epoch-fenced lease (Phase 4). If the CLI opens file:// in its own process while an app or server already holds that database, the two contend for the writer lease and one is fenced. Unlike a Postgres-centric tool (where every client funnels through one server process), an embedded Twill CLI is a participant. The rule, surfaced in docs and command help:
- Local / stopped database → manage embedded (
file://directly). - Live deployment → manage over
postgres://, so the server remains the sole writer and the CLI is just a client.
Command surface
The full proposed surface, with the engine capability each rides and a feasibility note. Milestone assignment is in Phased delivery.
| Command | Does | Rides | Feasibility |
|---|---|---|---|
twilldb sql <url> "<query>" | run one statement/query, print rows (table or --json) | query/exec | STRAIGHTFORWARD |
twilldb shell <url> | interactive REPL (history, multi-line, .tables/.schema dot-commands) | query/exec | STRAIGHTFORWARD |
twilldb tables <url> · describe <url> <table> | list tables / show columns, PK, FKs, unique sets | catalog() | STRAIGHTFORWARD |
twilldb migrate new <name> | create a timestamped migrations/<ts>_<name>.sql | file gen | STRAIGHTFORWARD |
twilldb migrate up <url> | apply pending migrations in order, record each | exec + tracking table | DOABLE (atomicity caveat) |
twilldb migrate status <url> | show applied vs pending, flag checksum drift | tracking table | STRAIGHTFORWARD |
twilldb gen types <url> | emit TypeScript types for @twilldb/bun from the live schema | catalog() | DOABLE |
twilldb seed <url> <file.sql> | run a seed script | exec | STRAIGHTFORWARD |
twilldb db reset <url> | fresh database → replay all migrations → seed | exec + branching/fresh open | DOABLE |
twilldb schema dump <url> | print reconstructed DDL (and optionally data) | catalog() + query | DOABLE |
twilldb branch create|list|delete <url> | manage copy-on-write branches | branch() / open_branch | DOABLE — differentiator |
twilldb serve <url> | run the engine behind pgwire (wrap engine-server) | twill-server | DOABLE |
twilldb stats <url> | print EngineStats/StorageStats (or SHOW twill.stats over pgwire) | stats() | STRAIGHTFORWARD |
twilldb schema diff | diff live catalog vs a target SQL schema | a schema differ | DEFERRED — needs a differ; out of v1 |
Migrations design
A directory of ordered, immutable SQL files plus a tracking table in the database. The model is intentionally close to Supabase / dbmate / golang-migrate so it's familiar.
Layout
migrations/
20260627120000_init.sql
20260627123000_add_notes_index.sql
...Tracking table
Created on first migrate up if absent. Names are underscored to stay out of the user's namespace:
CREATE TABLE _twilldb_migrations (
version TEXT PRIMARY KEY, -- the file's timestamp prefix
name TEXT NOT NULL,
checksum TEXT NOT NULL, -- hash of the file at apply time (drift detection)
applied_at TEXT NOT NULL -- ISO-8601
)Apply semantics
- MUST apply pending versions (those not in the tracking table) in ascending order, one file at a time, recording each in
_twilldb_migrationson success. - MUST split a file into statements and run them in order. DML-only files run inside one transaction; a file containing DDL runs its DDL in autocommit (see the constraint below) — so a file is the unit of ordering, not always of atomicity.
- MUST stop on the first failing statement and report which migration/statement failed; already-applied earlier migrations remain applied.
- SHOULD detect drift: if an already-applied file's checksum no longer matches,
migrate up/statuswarns (a previously-applied migration was edited — a footgun in every migration tool).
Constraint: DDL is autocommit-only — migrations are not always one transaction
Twill runs CREATE/ALTER/DROP in autocommit; DDL inside an explicit transaction returns ENGINE_ERR_TXN (a deliberate Phase-1 boundary, still in force). So a migration that mixes schema and data cannot be a single atomic transaction the way Postgres allows. The design accepts this rather than fighting the engine:
- Guidance: keep one concern per migration (schema changes separate from large data backfills) so a partial failure is easy to reason about.
- Mitigation — Twill's advantage:
migrate up --branchapplies to a copy-on-write branch first (zero data copy), lets you verify, then you promote — a preview-and-swap that is safer than Postgres's in-place transactional DDL for risky changes. This turns the single-writer/branchable architecture into a migration feature, not just a constraint.
Type generation
gen types reflects the live schema through catalog() (the same TableSchema — columns, types, PK, FK, unique sets — the server already uses for PostgREST reflection) and emits TypeScript for the Bun client: a row interface per table, with the engine's value types mapped to TS (INTEGER/REAL/TEXT/BLOB → number/number/string/Uint8Array, vector(N) → number[], nullable columns → | null). The output is a single .ts file the app imports, keeping db.query<Row>() calls statically typed against the real schema. This is the most @twilldb/bun-aligned command and a strong reason to do it early.
Binary shape & dependencies
One binary, two halves. The scaffolder (spec 18) stays dependency-free. Management is gated behind a manage cargo feature that pulls in twill-engine (and, when postgres:// support lands, a minimal pgwire client — reuse the bench driver's pgclient rather than a new dependency). A default cargo install twilldb-cli compiles no management code; --features manage (or the release build) includes it. The engine itself carries no heavy third-party dependencies (it hand-rolls its SQL parser, WAL codec, and base64), so even the management build stays auditable. The Homebrew/release builds ship the full-featured binary; the lean build remains available for embedders who only want the scaffolder.
Phased delivery
- Milestone 1 — inspect & migrate (embedded) (implemented):
sql,shell,tables/describe,migrate new/up/status,gen types,seed,stats— all over the embedded (file:///s3://) transport, behind themanagefeature. Covers "create schema, check data, run migrations." Each command ships with a test (and migration apply/idempotency/drift is covered end-to-end onfile://). - Milestone 2 — branches & serve (implemented):
branch create/list/delete(branches are durable on the base and addressed as<url>#branch=<id>),migrate up --branch(preview-and-swap — applies pending migrations to a zero-copy fork, leaving the base untouched until you re-runmigrate upon it to promote),db reset(drop → re-migrate → seed,--forceon a non-empty database),schema dump(reconstructedCREATE TABLEDDL fromcatalog()), andserve(the engine behind a Postgres-wire listener, composingtwill-serverin-process). The branch commands are thin consumers of the engine's additive branch API (Connection::create_branch/list_branches/delete_branch/open_branch) over the Phase-4 storage seam; no seam or C-ABI change. Each command ships with a test (branch isolation and migrate-preview verified end-to-end onfile://). - Milestone 3 — live deployments (implemented): the
postgres://pgwire transport across the read/inspect/migrate commands (sql,shell,tables/describe,migrate up/status,seed,gen types,schema dump,db reset,stats), so the same surface manages a runningengine-server— the server stays the sole writer and the CLI is just a wire client, the single-writer-safe path for a live database. The transport is a thinConnenum (embedded vs. wire) the commands are written against, reusingtwill-bench's hand-rolled, dependency-freepgclient(no new third-party dependency). The SQL-execution commands run unchanged; the inspect commands need the catalog, which the engine exposes only through the Rustcatalog()API, so the server reflects it as the plain-texttwill.catalog/twill.relationshipssurface — the same#53mechanism behindSHOW twill.stats— which the CLI reads back and reassembles into the engine's catalog shape so the renderers stay transport-agnostic. That reflection lives entirely in the server (composition glue); no engine or storage-seam surface changed. Branching andserveare storage-seam / server-lifecycle operations with no wire form, so they stay embedded-only (apostgres://URL is refused with guidance). Each command path ships with a test driving it against an in-process listener. - Deferred:
schema diff(needs a differ), datadump/restore round-trips, a TUI. No GUI/cloud, ever.
Deliberate scope boundaries
- OUT — GUI/studio, cloud login/link, hosted control plane (no Twill cloud).
- OUT — any new engine or storage-seam surface; management consumes the existing
ConnectionAPI only. - DEFERRED — transactional, all-or-nothing migrations (blocked by autocommit-only DDL; branch-and-swap is the v1 answer).
- DEFERRED — schema diff / declarative schema; v1 migrations are imperative ordered SQL.