Architecture
How sqlb fits together, and why the seams are where they are. For the reasoning behind individual choices, see the decision records. For where this is going, see the vision.
Last reviewed: 2026-07-28.
The shape of it
Section titled “The shape of it” blogschema/schema.go ← you edit this │ │ go generate ./... (a generator main, not a CLI) ├──────────────▶ migrations/*.sql DDL, diffed against the last state ├──────────────▶ models.go db + sqlb struct tags ├──────────────▶ columns.go typed column facade ├──────────────▶ rest_gen.go request bodies + registration ├──────────────▶ sqlb.json the manifest ├──────────────▶ client.gen.ts TypeScript client (ADR-0028) ├──────────────▶ client.gen.dart Dart client (ADR-0031) ├──────────────▶ cli/client/ Go client, stdlib only (ADR-0029) └──────────────▶ cli/ cobra tree over it (ADR-0029)
The last four are generated from the *schema*, not from openapi.json. The OpenAPI document cannot say what they need to say — `?status=eq.published` documents as `array<string>`, which is exactly the guarantee being sold — so the emitters read the same declaration everything else does.
┌─────────────────────────────┐ Go code ────────▶│ │ │ predicate AST │──▶ compiler ──▶ SQL + args HTTP query ─────▶│ (sqlb.Pred, sqlb.Expr) │ (filter) └─────────────────────────────┘ ▲ │ BeforeQuery hooksTwo things carry most of the design. The first is that a query is a value, so it can be built conditionally, handed to a hook to amend, and inspected without being run (ADR-0002). The second is that there is one predicate AST with two producers — hand-written Go and the URL filter grammar — so escaping, authorisation and hook application each happen exactly once (ADR-0003).
Almost everything else follows from those two.
Packages
Section titled “Packages”| Package | Responsibility | Depends on |
|---|---|---|
schema |
The declarative DSL and its validation. Design-time only; nothing at runtime imports it. | nothing |
. (sqlb) |
AST, Postgres compiler, generic builder, model reflection, mutations, hooks, Describe. |
stdlib only |
filter |
URL grammar → predicates, validated against model capabilities. | sqlb |
migrate |
Diffs two schemas into changes, renders them as Postgres DDL, and writes migration files for goose, golang-migrate or plain SQL. Does not apply them. | schema |
introspect |
Reads pg_catalog back into a *schema.Registry, and reports every construct the DSL cannot express. Design-time; connects through a sqlb.Executor, so the handle is the caller’s. |
schema, sqlb |
codegen |
Generates models, the typed column facade, the REST request bodies, the manifest, the TypeScript and Dart clients, and the cobra CLI. Check is the dry-run mode wired into CI. |
schema |
rest |
Mounts a model on a Huma API: handlers, and an OpenAPI operation built from the model’s capabilities. | sqlb, filter, huma |
shadow |
Replays a checked-in migration history into an empty database, so the current side of a diff can come from the history rather than from a live schema. Design-time. | schema, migrate |
example/recipes |
One file per aspect, one point per example, each ending in output the test compares. The narrow-question counterpart to the worked applications, and the surface an agent greps. | all of the above |
example/blog |
A worked schema plus the artefacts codegen must produce. | all of the above |
example/tasks |
A multi-tenant task manager: hooks as the authorisation seam, JWT middleware feeding the context hooks read, and a migration history applied by goose. A separate module, like pgtest. |
all of the above, migrate |
example/withsqlc |
The same schema rendered as DDL for sqlc, and a test that sqlb reads sqlc’s structs. Proves docs/with-sqlc.md rather than leaving it asserted. | sqlb, filter, stdlib |
The dependency direction matters: schema is a leaf that nothing imports at
runtime, and sqlb has no dependency on schema. That is deliberate. It is
what makes ADR-0010 possible — the engine
cannot quietly grow a dependency on the schema DSL, because it cannot see it.
Capabilities reach the runtime as struct tags or Describe calls, never as a
schema import.
migrate, introspect and codegen sit on the other side of that line: all
three are design-time tools that read or write schema, and none is reachable
from the request path. migrate is the only package that renders DDL, which is
why the Postgres type mapping lives there rather than beside the query compiler
— a Format decides what a runner wants a file to look like, and the DDL
layer decides what the database wants a statement to look like.
introspect is the same mapping pointed backwards, and it is a separate package
because it connects to a database and migrate deliberately does not. That
separation is what keeps migrate a pure function over two data structures, and
it is why the two can be checked against each other: render a schema, apply it,
read it back, and the diff between what went in and what came out must be empty.
sqlb depends on pgx and nothing else, and neither does anything else on the
request path. rest is the single exception: it depends on huma, and nothing
depends on rest. mise run deps-check proves this per package rather than per
module — the allowed set is computed from what pgx itself pulls in, so it cannot
go stale — and it ends by checking that it can still see huma in rest and that
it still refuses huma everywhere else. A guard that cannot fail is worse than
no guard (ADR-0016).
Executor is the two-method subset of pgx that the engine needs — Query and
Exec — so a *pgxpool.Pool, a *pgx.Conn and any instrumenting wrapper all
work unchanged. So does a pgx.Tx, which is the point of taking pgx at all:
sqlb writes join a transaction the application opened
(ADR-0040). sqlb.DB is a handle over
an Executor, adding WithTx and a scoped hook registry; it satisfies
Executor itself, which is what lets it be adopted without touching call sites
(ADR-0020). DB.Tx reaches the
underlying pgx.Tx, which is how a unit of work is shared with code wanting
more than two methods — CopyFrom, SendBatch, or sqlc’s generated DBTX.
rest takes a huma.API, not a router, so the choice of chi, gin, echo or
net/http — and all of that router’s middleware — stays the application’s. It
wraps each generated write in a transaction, which is what gives a hook a commit
to be after; reads are left alone, since one SELECT is atomic already
(ADR-0021).
Request path
Section titled “Request path”A list request through rest.Resource:
- Parse.
filter.Parsereads the query string against the model. Unknown parameters, undeclared capabilities and uncoercible values are collected into afilter.Errors— all of them, not the first (ADR-0011). Values become typed Go values here; nothing downstream sees strings. - Apply.
filter.Applywrites predicates, ordering, projection and limits onto a*sqlb.Builder[T]. It owns the projection and defaults to non-hidden columns, so a handler cannot leak aHiddencolumn by forgetting to project. - Hook. The terminal method clones the builder, then runs
BeforeQuery. Cloning is what stops a hook’s predicates accumulating when the same query value runs twice. A hook that returns an error aborts before any SQL is issued, so a missing tenant fails closed (ADR-0008). Which registry the hooks come from is read off the executor: a*sqlb.DBcarries one, and anything else carries none, so a statement issued against a bare pool runs unconfined (ADR-0047). - Compile. The AST renders to SQL with
$Nplaceholders. Values are always bind parameters. Identifiers are validated against the model and quoted.LIMIT/OFFSETare literals so the planner can see them — safe because both are range-checked ints. - Scan. Result columns are matched to struct fields by name. Unmatched columns are read and discarded, so a query selecting extra expressions still scans into the model.
A write takes the same path with a transaction around it: BEGIN, the hooks and
the statement, COMMIT, then the AfterCommit callbacks — outside the
transaction, since there is nothing left to join. A callback that fails does not
fail the request, because the row is already durable and a retry would write it
twice; rest logs it and returns the success it achieved.
Where safety lives
Section titled “Where safety lives”Four independent mechanisms, each covering what the others cannot:
Bind parameters. Values never reach SQL text. There is one bind method on
the compiler and no way to interpolate a value around it.
Identifier validation. Column names are checked against the reflected model
before compilation. Raw is the documented escape hatch and is the one place
this does not apply — which is why raw fragments are parenthesised as operands,
since their contents are opaque and could otherwise re-associate a surrounding
predicate.
Opt-in capabilities. A column that does not declare Filterable cannot be
filtered, ever (ADR-0006). Hidden goes
further: the column is reported as unknown rather than as forbidden, so its
existence cannot be probed, and Hidden plus Filterable is a schema
validation error because a filterable secret can be recovered a character at a
time.
Query hooks. Tenant scoping applies to every read of a model, including reads issued by generated handlers, because both go through the same builder.
Two smaller rails worth knowing: Update and Delete without a WHERE return
ErrUnscoped until Everything() is called explicitly, and LIKE
metacharacters in user input are escaped so a search for 50% searches for the
literal string.
Model metadata
Section titled “Model metadata”The engine needs to know four things a Go struct does not say: which column is the key, which columns the database defaults, which capabilities each column exposes, and which columns are hidden.
That metadata arrives by one of two routes, which merge:
- Struct tags —
db:"email"for the column name,sqlb:"filter,sort"for capabilities. This is what codegen emits. sqlb.Describe[T]()— the same information supplied at startup, for structs you did not generate and would rather not edit.
Without either, the builder still works — column names derive from field names — but no column is filterable, so the REST layer exposes nothing. That default is the point.
API surface
Section titled “API surface”There are no internal/ packages, and the layout is flat
(ADR-0013). The genuinely internal machinery —
the compiler, scanning, model building, escaping — is already unexported within
package sqlb, which is a finer-grained boundary than internal/ can express.
What is exported falls into three tiers. They are a convention, not a compiler
check, and they exist because the module is v0:
| Tier | What | Promise |
|---|---|---|
| Stable | Query/Builder, F/Pred/And/Or/Not/If, Field and its operators, Col/TextCol/Typed/TextColumn, Order, the aggregates, InsertRows/UpdateRows/DeleteRows, On/Hooks, Describe, Collect, Executor, DB/New/WithTx, ErrNotFound/ErrUnscoped, all of filter and schema |
Changes are breaking changes and are treated as such |
| Provisional | Model, ColumnInfo, ModelOf, Selectable, Selection, Dialect, Postgres, Registry/On/WithHooks, Beginner, TxFrom |
Public because filter and generated code need them across a package boundary, or — for the registry surface — because they are new enough that no one has used them in anger yet |
| Escape hatch | Expr and the node types: Raw, Binary, Unary, Call, Cast, BetweenExpr, List, Param, Column |
Use Raw, RawPred, RawSel. The rest is the compiler’s vocabulary and will change without ceremony |
The tiers exist because the obvious extraction does not work: Expr and Raw
are the documented escape hatch and appear in SetExpr, GroupByExpr,
Coalesce and OrderByDesc, so they cannot be hidden. Hiding the rest of the
node set alone would buy little and would leave Pred.Expr() returning a type
callers cannot name.
The dialect is not among them. It is package-level but unexported and
unsettable: a mutable global read on the compile path of every query is a data
race with no legitimate trigger, since sqlb targets Postgres only. UseDialect
overrides it per statement, which is scoped and race-free.
Failing loudly
Section titled “Failing loudly”Where sqlb cannot do the right thing, it says so rather than guessing. The rule is that a wrong answer must never be quieter than no answer.
| Situation | Behaviour |
|---|---|
Collect[R] has a field no result column fills |
Error naming the field and both names — a mistyped As("revenu") would otherwise scan as a real-looking 0 |
Describe called after a statement was built |
Panic; mutating the cached model then would race and half-apply |
Describe names a column that does not exist |
Panic at startup, listing the columns that do |
Update/Delete with no WHERE |
ErrUnscoped until Everything() is called |
| Destructive migration | Rendered commented out with the reason stated |
| A change over a column a commented-out change adds | Commented out with it, naming what it waits for. Emitting it live makes the file fail partway through instead of being the no-op the guard intends |
| A column or table that was renamed | A drop and an add, unless RenamedFrom says otherwise — inferring a rename from a similar name would destroy data whenever the guess was wrong |
| A migration that rewrites or scans a table | Emitted live with the lock it takes and the sequence to use instead named above it. Not commented out: whether a scan matters depends on a row count the schema does not have. migrate.Unblock writes the sequence when the remedy is mechanical |
A change with no Down |
Renders an explanation, not an empty section that looks like a working rollback |
| Filter names an unknown or uncapable column | 400 listing what would have been accepted |
| Schema authoring mistake | Every problem reported at once, each with the fix |
A resource over a Scoped or soft-deleting model with no hook confining it |
Refused at mount, listing every missing registration and the declaration that asked for it. Serving it would answer 200 with another tenant’s rows, which is the quietest wrong answer in the system |
Two deliberate exceptions, both documented where they happen: a page size above
the maximum is capped rather than rejected, since a client asking for too much
should get the maximum rather than an error; and Builder.All tolerates
unfilled fields, because a partial projection is exactly what ?select=id,name
is. Collect is strict precisely because its destination type was written to
match the projection.
Testing
Section titled “Testing”The engine’s tests run against an in-memory Executor that records statements
and replays canned rows, so hooks, scanning and the mutation paths are covered
end to end without a live Postgres. The pgx shapes that stands on —
pgx.Rows and pgx.Tx — are in internal/pgfake, written once and used by
every test package that needs them. SQL-string assertions cover the compiler.
What a fake cannot cover, pgtest does, and the driver flip made that split
sharper rather than softer: both bugs ADR-0040’s port introduced were cases
where pgx hands back exactly what Postgres sent, and neither was reachable from
a canned result set.
The typed facade is checked by attempting to compile the cases that should fail and confirming they do — a test that passes vacuously if the facade stops working is worse than no test, so those are exercised as real build attempts.
Known gaps
Section titled “Known gaps”introspectproduces a registry from a live database andcodegen.RenderSchemarenders it back asschema.go, so adoption is a closed loop, andshadow.Buildreplays a migration history into an empty database so the current side of a diff can come from what the history builds. What it cannot reproduce is a destructive change: those render commented out, so the checked-in file is not the SQL that ran, and the shadow will differ from production wherever one was uncommented by hand.- No change feed, and no MCP server over the manifest. See the vision. The TypeScript client, the Dart client and the CLI have since landed (ADR-0028, ADR-0031, ADR-0029); all three read the schema rather than the OpenAPI document, for the reason the diagram above gives.
?expandresolves one level. A relation expands to its row; that row’s own relations do not expand in turn, and there is no?expand=list.workspace. One level is a join per relation and a bounded statement; nesting is where a depth limit and a cost model have to be argued for, and neither has been.