Skip to content

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.

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 hooks

Two 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.

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).

A list request through rest.Resource:

  1. Parse. filter.Parse reads the query string against the model. Unknown parameters, undeclared capabilities and uncoercible values are collected into a filter.Errors — all of them, not the first (ADR-0011). Values become typed Go values here; nothing downstream sees strings.
  2. Apply. filter.Apply writes 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 a Hidden column by forgetting to project.
  3. 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.DB carries one, and anything else carries none, so a statement issued against a bare pool runs unconfined (ADR-0047).
  4. Compile. The AST renders to SQL with $N placeholders. Values are always bind parameters. Identifiers are validated against the model and quoted. LIMIT/OFFSET are literals so the planner can see them — safe because both are range-checked ints.
  5. 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.

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.

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 tagsdb:"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.

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.

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.

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.

  • introspect produces a registry from a live database and codegen.RenderSchema renders it back as schema.go, so adoption is a closed loop, and shadow.Build replays 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.
  • ?expand resolves 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.