ADR-0032: The command compiles a driver, and the project declares itself in Go
- Status: Working —
sqlb generateandsqlb checkproduce byte-identical output to the hand-written generators they replaced, andmise run generate-checkis now the command rather than three bespoke mains.sqlb migrateis built and exercised against a real Postgres inpgtest - Confidence: High on the mechanism — the driver compile runs end to end
against this repository’s own blog example. Medium on the convention, which has
two projects behind it. Lower on
migrate, whose first real run surfaced a round-trip defect since fixed (#24) - Decided: 2026-07-29
- Last reviewed: 2026-07-29
Context
Section titled “Context”The promise is one edit, one command. What a project actually wrote first was
cmd/gen/main.go: a -check flag, a -dir flag whose default was correct from
the module root and wrong from the directory go generate runs in, two error
branches, and a codegen.Options literal. Only the literal said anything about
the project. Both examples carried a copy, the copies had drifted, and
mise run generate-check invoked all of them by hand with different arguments.
The obstacle is ADR-0004. The schema is Go, and a
table is registered by importing the package that declares it, so a prebuilt
binary cannot read a registry — there is nothing in one until the schema package
is linked in. sqlc and atlas ship a binary because their schema is a file
their binary parses. Ours is a program, and only another program can read it.
So the question is not whether to compile — that is forced — but where a project declares what no amount of compiling reveals: which directories the emitters write to.
Decision
Section titled “Decision”sqlb writes a driver, compiles it inside your module, and deletes it.
sqlb generate ./taskschemasqlb check ./taskschemaThe argument is the schema package in the form go build takes. The command
resolves it with go list, writes a three-line main to a temporary directory
outside the repository, builds it with the working directory at the module
root, runs it, and removes the directory — so there is no artefact to gitignore
and none left behind on a failure.
The driver is three lines, and everything it could contain lives in
codegen.Main. Generated code cannot be tested; the package it calls can.
A verb that reads a database rather than a declaration compiles nothing, and
is still a verb of this command. sqlb survey builds its registry by
introspecting a live Postgres, so there is no package to link in and the driver
mechanism has nothing to do. It runs in the sqlb process and takes two DSNs
where the others take a package. The compile is a consequence of
ADR-0004, not a property of the command — so the
right boundary is one binary with one help text, not one binary per argument
shape.
A project declares itself by exporting SqlbProject() codegen.Project.
func SqlbProject() codegen.Project { return codegen.Project{ Options: codegen.Options{ Package: "tasks", TSDir: "web/src/api", DartDir: "mobile/lib/api", CLIDir: "cli", CLIName: "taskctl", }, }}A convention rather than a config file, because a config file is a second
declaration language mirroring codegen.Options field for field, drifting
whenever a field is added, reporting its mistakes at run time. In Go the options
are compiler-checked and go doc-documented — and a project that outgrows the
convention can still write a main calling codegen.Generate directly.
Paths resolve against the module root, never the schema package and never the
working directory. That single rule deletes -dir: the command means the same
thing from a shell, a //go:generate directive and CI — the three callers the
old default had to be right for simultaneously, and was not.
Project wraps Options rather than being it. Options is the emitters;
Project is the repository, which also has the migration directory, the format,
the minimum Postgres version and the scratch database. Widening a struct is
invisible to every project; changing what SqlbProject returns is not. Five
fields landed a day later and no project’s SqlbProject changed shape.
check writes nothing and needs no database. It is the drift gate and runs
on every push; the emitter half fails often and every project has it, so gating
it must not require a service container.
sqlb migrate is the half that needs a database. check asks whether the
committed output matches what the emitters produce, which is a pure function of
the schema. migrate asks whether the committed history builds that schema,
and the only trustworthy answer is replaying it into an empty Postgres
(ADR-0014). Two gates, two costs, kept apart.
The scratch database is a function, not a DSN. Project.ShadowDB opens it,
in the project because the driver has to enter through the consuming module and
because the database must be empty — creating and dropping databases needs
credentials the rest of sqlb never asks for, and dropping the wrong one is
unrecoverable. The statement that wipes a database is written out, by name, in
the repository that owns it. The first migration needs no database at all: an
empty history replays to an empty schema, which is what an empty registry is.
Two halves, in two places. cmd/sqlb never sees your schema: it resolves a
package, checks by AST that the convention function is there, compiles, and
forwards an exit code. codegen.Main and codegen.Run have the registry and are
ordinary tested code. The AST check earns its place by being unnecessary to
correctness — without it, a missing SqlbProject is a compile error inside a
temporary file the user cannot open.
Consequences
Section titled “Consequences”Buys. The workflow is a command. Both examples deleted sixty lines of generator that existed to be got right before the tool would run. Output is byte-identical across all six emitters, which is the strongest available evidence that this changed the interface and nothing else.
Costs. A compile — sub-second warm, the module’s dependency graph cold, which
in example/tasks includes cobra and pgx. This is the bill for ADR-0004,
presented at the point of use instead of hidden in a per-project main, and it
will not get smaller. The command shells out to go, so sqlb is not a binary
you can drop into a container with no compiler.
What building it changed, each caught by a test written to fail:
Project.Validaterefuses an absoluteDir, and the first test helper handed itt.TempDir()— the guard fired on its own author.- Build and run as separate steps, not one
go run, which prints its ownexit status 1underneath the list of stale files. - Versions cannot come from the clock.
TimestampVersionhas one-second resolution, and the symptom of a collision is not a duplicate filename butshadowrefusing to replay the history at all, several steps later. The version now comes from the directory: the timestamp is a starting point, and the highest present version is incremented if it does not already sort after. - A hand-written
schema.Checknever round-tripped —migrate.Diffcompares definitions as strings and Postgres returns a normalised form. A pre-existing defect (#24), since fixed.
The declared CHECK, and why the fix needed a database. Postgres stores a
parse tree and renders it canonically, so the author’s spelling is unrecoverable
and the two sides can only agree by putting the declared expression through the
same normalisation. Canonicalising both strings in Go was rejected on consequence
asymmetry: stripping parentheses loses information — (a OR b) AND c and
a OR (b AND c) reduce alike — so a heuristic can call two different constraints
equal, and a diff that says “unchanged” produces no migration at all. That
failure is silent; the one it replaces is loud, visible churn.
So shadow.Normalize adds each declared expression to the replayed table,
reads back what Postgres stored, and rolls back — correct by construction, at one
round trip per expression. Each probe takes a savepoint, because Postgres aborts a
transaction on any error and one unprobeable check would take down every check
after it. A check that cannot be probed is reported and left as declared, since
the ordinary reason is that it names a column this migration adds. migrate.Diff
is untouched and still a pure function; the impurity is in a separate call the
caller makes first.
With that fixed the check became a gate: example/tasks/migrations/drift_test.go
is the only thing in the build that can catch a schema edited without a
migration. Writing it found one more thing — an earlier version applied
migrations with goose, whose goose_db_version table came back from
introspection as a table the declaration does not have, so the gate proposed
dropping it. That shadow writes no version table is a line in its package doc;
this is what the line is for.
What would change our mind
Section titled “What would change our mind”- Projects start writing a
mainagain to do somethingProjectcannot express — widen the type before the pattern sets, because a hand-written generator that works is one nobody comes back from. SqlbProjectwants arguments — a profile, a target, a variant. The convention is the wrong shape, and the answer is a function taking a named configuration.- The compile cost is felt in the inner loop rather than CI — cache the built driver keyed on the module’s build ID, do not abandon the mechanism.
- A schema becomes readable without compiling — a manifest complete enough to
emit from, which
sqlb.jsonis not today. - More than one schema package per module becomes normal —
Projectshould name its own registries.
Cost of change
Section titled “Cost of change”The driver, the temp directory and the two-step build are private and can be
rewritten without a consumer noticing. SqlbProject is a name in other
people’s repositories, so renaming it breaks every adopter — loudly, which is
the good kind; codegen.ProjectFunc exists so the name is written once here.
The module-root rule is the expensive one: every path in every adopting
project is written against it, and changing it would silently relocate generated
files rather than fail.
Revisions
Section titled “Revisions”-
2026-07-29 — Written, after building
generateandcheck. -
2026-07-29 —
sqlb migrateadded, withProject’s migration fields andShadowDB. The wrapper paid for itself as predicted. Two things learned: versions must come from the directory, and a declared CHECK never round-tripped. -
2026-07-29 — #24 fixed with
shadow.NormalizeChecks. Worth a revision rather than an edit: the fix is only available because the command already had a database open at the right moment, which was not an argument this record made for the design and is now one of the better ones. -
2026-07-30 — Condensed.
-
2026-08-01 — #63: a partial index’s
WHEREis stored the way a CHECK is, and arrived as the same complaint from the same direction — a declaration that never matched the live index, and a diff proposing DDL identical to what the database already held. It goes through the same probe now, soNormalizeChecksisNormalize. That the mechanism extended without argument is the evidence for the paragraph above: having a database open at that moment keeps paying. -
2026-08-01 — The other half of #63, which is about the reporting rather than the normalisation. Three issues — #24, #56 and this one — shared a shape: a declaration is compared with the database as text, and the diff proposes a statement identical to what is already there. Normalising fixes it for callers who call it;
Diffis a pure function and cannot call it for them. So a rebuilt partial index, and a hand-written CHECK replaced under its own name, now say in theirCommentwhen the expression differs only in formatting.The heuristic this record rejects is the same one, used for a different job: it explains a diff the exact comparison already decided, and never decides one. A wrong answer there is a misleading sentence on a correct migration; a wrong answer in the comparison is a schema edit that never reaches the database. The clause is worded to survive being wrong —
(a OR b) AND canda OR (b AND c)reduce alike, and they do differ only in parenthesisation, so it reports that rather than claiming the two are the same expression.Not attached to every constraint. A primary key, a unique, a foreign key and an enum’s CHECK are rendered from column names and values on both sides rather than carried through as text, so a difference between two of those is never formatting — and naming a normalisation step that does not touch them would be advice that cannot help.
-
2026-08-02 —
sqlb-surveyfolded in assqlb survey. It had been a secondmainsince #112 because it needs no schema package, and that reasoning was backwards: needing no package is a fact about one verb’s arguments, not a reason for a second binary a user has to hear about separately. Two commands meant two help texts, two install lines, and an adoption probe absent fromsqlb help— which is where somebody deciding whether to adopt sqlb looks first. The Decision above now says what the boundary actually is.Cobra was considered for the merged tree and rejected on this command’s own shape rather than on dependency policy alone.
cmd/sqlbdeliberately does not parse the driving verbs’ flags — it forwards them opaquely, and they are parsed on the far side bycodegen.Run. Cobra confined tocmd/sqlbwould therefore own a five-case switch and none of the flags, and cobra pushed far enough to own them would put a command-line framework incodegen, which is a library package every consumer imports. That is the cost ADR-0040 says a new dependency has to argue for, and this one has no argument: the framework would be paid for by every consumer to improve the help text of a binary only maintainers run.