Skip to content

Releases

Every tagged release, newest first.

Each entry is the annotated tag’s own message, marked up for the web — git show v0.5.0 prints the original. That is the arrangement rather than a hand-kept changelog because a tag message cannot go stale: it is written where the release is made and is immutable once pushed, so the only way this page can be wrong is by missing a version, which is one failure rather than five.

Compatibility says what is frozen and what is expected to move. Semantic versioning applies from v1.0.0; until then a minor bump may break a surface listed there, and the break is described here with the mechanical edit that fixes it. The road to 1.0 says what has to be true before that promise becomes permanent.

2026-08-05 · tag

The release where the reports started rhyming. Twelve issues, every one of them from somebody putting sqlb on a real schema — a sixteen-registry adoption, a headless shop, a product catalog — and the last four turned out to be one shape rather than four small gaps. A layer below the declaration could say something the declaration could not, and nothing reported the gap. Naming that is the most useful thing in this tag.

One break, and it was listed under Will move before it landed.

BREAKING: a computed column is nullable unless it says otherwise. schema.Computed generates a pointer field now, and NotNull() is the opt-in for an expression that cannot produce a NULL — a count(*), an EXISTS, a comparison already guarded against its own nulls. The old default was the one an expression cannot satisfy: a correlated subquery matching nothing is NULL, arithmetic over a nullable column is NULL, and a comparison against one is NULL. The failure was a 500 at scan time saying cannot scan NULL into *string, naming the generated model rather than the Computed call that produced it, on data a fixture is unlikely to contain — and both gates were green, because generate had no opinion and Diff correctly ignores a column that is not in the database.

The mechanical edit is NotNull() on every computed column whose expression genuinely cannot produce a NULL. Leaving it off is the safe direction: a pointer scans a non-null value fine and the reverse is the 500. Stored columns are untouched, as is the structs-first path, where the Go field’s own type has always carried this. Inferring nullability from the expression was the alternative and its own objection stands — an incomplete inference is wrong in the unsafe direction, which is the direction the change is about.

A gap below the declaration is reported, not silent

Section titled “A gap below the declaration is reported, not silent”

Four issues filed as minor, each with a workaround already in hand, and the missing spelling was the cheap half in every one. The expensive half is that a tool reporting no difference is making a claim, and a tool that cannot see a property makes that claim about it whether or not a difference exists. So: close the gap where that is cheap, and where it is not, make the gap visible — a refusal at the boundary, a report from the tool that reads the database, or a sentence where the reader is standing. What that rules out is the fourth option all four had: correct behaviour, an available workaround, and the two facts documented in different files from each other (ADR-0051).

Two cost ceilings the mount could express and the schema could not. schema.REST gains MaxSortTerms and MaxOffset, so all five per-request bounds are declarable beside the table. MaxOffset is the one that matters: its default of 100,000 is right as a default precisely because it has to be safe for a table nobody described, which puts it two to four orders of magnitude above what any particular resource wants. A catalog with ten thousand products has no legitimate offset past ten thousand, and every one above it is a guaranteed empty page that still costs a scan to the end. Two surfaces were dropping the pair on the way out: the ejected exit emitted a literal MaxSortTerms: 0 and had no MaxOffset at all — so the handlers that replace the API served ?page=50000000 while the API refused it, with no ?cursor to redirect to since keyset paging does not come out — and the generated skill stated the filter budget and neither of the others.

The inspection points now show the statement that runs. SQL() renders what the caller built, which on a model confined by a BeforeQuery hook is a statement with the confinement missing. Explain is the sharp half, because its documentation claims otherwise: WHERE status = $1 and WHERE status = $1 AND org_id = $2 have different plans, the second is the one with the composite index behind it, and a plan-regression test written on the first stays green through exactly the change that makes the real query seq-scan.

q, err := sqlb.Query[Post]().Where(…).Resolved(ctx, db)
sql, args, err := q.SQL() // … AND "org_id" = $2

Builder.Resolved applies the hooks and the expansion scopes and hands back a copy; Update and Delete have the same for theirs; Explain and ExplainAnalyze compile through it, which on ExplainAnalyze is a correctness property rather than a reporting one, since it executes. The exec paths were rewritten onto Resolved rather than keeping their own copy of clone-then-run-hooks: the failure being fixed is two paths disagreeing about one statement. Insert is deliberately not resolvable — BeforeCreate rewrites the rows, so resolving one would mutate the caller’s data as a side effect of inspecting it.

A constraint’s deferrability is declared, read back and diffed. The missing spelling was the small half. The interesting half is that the round trip was a fixpoint because both sides were blind to the same property — the introspector did not read condeferrable, the differ had no field to compare, and a hand-altered constraint passed sqlb check green. Unique.Deferrable and Field.Deferred() declare it; every constraint kind is read; and the kinds it cannot be declared on are reported as skips with their definition attached rather than dropped in silence. The proof is the break-on-purpose: with the mapping reverted the rebuilt database no longer matches the original and names the constraint that lost its clause, while the fixpoint test passes — both registries having dropped the same thing, which is ADR-0016’s failure mode stated about a field rather than about an object.

A hidden column can say it is the key it is looked up by. Hidden names one property — the value must never leave the process — and the generated facade asserted a second by omitting the typed column. For a password hash they coincide. For session tokens, API keys, reset and verification tokens, webhook secrets and idempotency keys they do not: the presented secret is hashed and the hash is the lookup key, so Hidden took away the operation the column exists for.

schema.Text("token_hash").Hidden().LookupKey()

LookupKey keeps the facade entry and moves nothing else. It adds no capability and no struct-tag token, and ?token_hash=eq.… is still a 400 naming what would have been accepted — a client that can probe a credential column by equality has an oracle, and that refusal is what capabilities are for.

A headless shop reads products from a public storefront and from an admin panel, and the admin surface exists precisely to serve cost_price_minor and internal_notes. Neither lever reached it: Hidden is a property of the model and there is one model per table, and Expose assigns rather than appends, so a second call replaces the first.

rest.Options.Columns narrows a mount, and filter.Options carries it into the parser and into Apply’s default projection — both, because a resource whose parser refused a column while its projection selected it anyway would read the value out of Postgres on every request and drop it on the way out. A column not listed is unreachable: not projected, not filterable, not sortable, not searched, not nameable in ?select, cleared off any row a body produced, and absent from the list a rejection offers back — that last one because a surface narrowed to conceal something must not confirm the column exists. ADR-0050 records what it costs and what the stronger schema-side answer would need.

AfterUpdate received the rows and AfterDelete received a count, and that asymmetry stopped a port: a module publishing a domain event per mutation could say how many posts were deleted and not which, and an event carrying no id is worse than no event. AfterDeleteRows sits beside the count form rather than replacing it, so nothing is added to the statement unless a hook of that kind is registered and a program that only wants “did anything change” pays what it always paid.

rest.PublishChanges moves to it, which is the half that was not asked for and the reason to pay the cost: Event.Scope is read off the changed row, so a keyless delete was also a scopeless one and every tenant’s subscribers woke on every other tenant’s delete. ADR-0045 had listed keyless deletes as a what-would-change-our-mind gated on a measured refetch cost; that named the wrong axis and the record says so.

Writes is what the envelope persists — columns, on one row — and the same page of docs hands the verb a transaction it can write anything through. Three tools reported Writes as complete with no signal that a verb can exceed it, and the CLI case is the sharp one, since ADR-0029’s argument for the CLI is that --help answers a caller with no compile step. A declared write set of two columns invites the inference that the route is confined to one row, and that inference can be wrong by ten tables.

Touches names tables beside Writes’s columns and travels with it — the manifest, the contract snapshot, the OpenAPI description, the generated doc comment and --help — unenforced, deliberately, and a verb that declares nothing now gets a sentence saying so rather than silence.

“Give me exactly one row” and “do not produce a row on conflict” cannot both hold, and the way it used to resolve was the worst reading available: the conflict came back as ErrNotFound, through the same if err != nil as everything else, from a call whose job was to make the row exist. The failure inverts with state, so a test inserting into a clean database passes and only the second call fails.

The refusal names both routes out, because which one is right depends on what the caller wanted: Exec, whose empty slice and nil error are what “it was already there” looks like, or OnConflictUpdate with the target as its own update column, since a write that changes nothing is still a written row and a written row is a returned one.

The emitted skill was wrong about the wire, and two registries wrote one file

Section titled “The emitted skill was wrong about the wire, and two registries wrote one file”

Both from adopting Options.SkillDir across eighty modules and 184 tables.

A camelCase registry got a capability table listing org_id and a closing sentence asserting that those names are the JSON field names. An agent doing exactly what the file said wrote ?org_id=eq.… and got a 400 — worse than guessing, since guessing from the camelCase model would have been right. generate-check was green throughout, because the file was a faithful render of a manifest that was itself wrong: BuildManifest reported the column’s own name in a section that describes the wire. It is fixed there rather than in the emitter, so anything else reading the manifest for a contract is fixed with it, and Manifest.WireCase and ColumnManifest.Wire are new so a consumer holding only the document can tell the two spellings apart. Wire is absent where they are equal, so a Verbatim schema’s sqlb.json is byte-identical.

Worth stating next to ADR-0049’s claim that gating is what makes writing instructions into a repository safe: this is the first bug in that emitter the gate structurally could not have found. Being gated proves the file matches the schema, not that it is right about it.

And SkillDir had no per-registry component, so every registry pointing at one directory wrote the same SKILL.md and the last writer won — with the doc comment recommending the placement that does it. Options.SkillName is the fix, defaulting to sqlb-schema.

Also: Immutable’s doc comment now names its boundary the way ReadOnly does. It is enforced at the REST layer and application code writing through the query engine is trusted, which is the right design and was not what the sentence said.

  • The one break above. Everything else in this tag is additive: a schema setting none of the new fields generates what it generated before.
  • An adoption whose database defers a foreign key, primary key, check or exclusion now gets report entries where it got none, and sqlb introspect exits non-zero on them. That is the change working — the entry is what turns an invisible divergence into one a person can decide about — and it is still a new obligation for a schema that had been quietly fine.
  • A camelCase or snake_case schema’s sqlb.json changes: the REST section’s capability lists carry the wire spelling now, and columns carry both. A Verbatim schema’s is byte-identical.
  • A delete on a model with an AfterDeleteRows hook — including any model wired to rest.PublishChanges — runs DELETE … RETURNING and scans every row it matched. That is real on a bulk delete, and it is why the count form stayed.
  • An ejected package regenerated from a schema declaring MaxOffset now refuses deep offset paging. It did not before, against a README saying it refuses what the API refused.

Twelve issues: #142, #143, #144, #146, #147, #148, #149, #150, #151, #153, #154, #155. Two new records, ADR-0050 and ADR-0051, and one revised, ADR-0045.

2026-08-03 · tag

The release that measured the agent-facing claim rather than asserting it. Two hand-written skills and one generated, gated the way every other emitted file is — and then sixty A/B runs which said the honest case for it is latency and not correctness, so the record says that instead of what it was built on. Beside it, three constructs and one false alarm, all found the way v0.8.0’s were: a real database refusing to be described, or described wrongly.

Nothing breaks. Everything here is additive, opt-in, or a build-step tool, and no name a program compiles against has moved.

The schema an agent reads is generated, because a written-down one cannot be gated. Options.SkillDir emits <SkillDir>/sqlb-schema/SKILL.md: the mounted path and operations per resource, the four capability lists with an undeclared one named as none rather than omitted, the declared verbs and what they write, the inverse relations ?expand knows, and the tables whose declaration obliges a hook.

codegen.Options{ …, SkillDir: ".claude/skills" }

Capabilities are opt-in, so “can I filter on this column” has a different answer in every project and no static document can carry it. example/tasks commits a generated skill, so generate-check gates it in CI rather than a test asserting the property in the abstract — and it earned that on the first run, catching a real drift when the emitter changed after the file was written (ADR-0049).

What the document does not carry is prose. introspect reads col_description off a live database and calls Field.Comment, so a comment is not necessarily first-party text. Every other emitter passes those through safely because DDL and OpenAPI are read as data; a skill is read as instructions. This one carries names, types and capability flags and nothing else, guarded by a test that injects an instruction-shaped comment as both a table and a column comment and requires it absent.

Then the claim was measured, and it shrank. Twenty runs per arm across three rounds, control given the schema declaration and treatment the same plus the skill. Both arms answered ten direct questions at 50/50. Both caught every trap in the final round — 80 trap-instances scored, zero misses on either side — and a 2-of-5 silent failure seen at n=5 did not replicate at n=20, having been an artefact of the prompt. What survived is cost: 3.5 tool calls and 47s against 1.1 and 19s, with the control’s real figure understated because two of its runs spawned research subagents that do not show up in the parent’s counters. So the emitter is worth ~290 bytes per resource for round-trips, not for correctness, and ADR-0049 now says so and says the premise should not be restated without new evidence.

The two hand-written skills are the half no check can reach. sqlb-queries carries four traps that compile, pass their tests and answer the request: an aggregate over an empty set scanning NULL into an int64, one bind parameter numbered twice across a projection and a GROUP BY, a day filter against timestamptz matching zero rows with no error, and OnConflictDoNothing turning a retried write into ErrNotFound. Every sample was compiled and rendered rather than written from memory, which caught three wrong idioms and one claim about this API that had rotted in four days — which is the argument for generating the other half, observed rather than reasoned. The second skill is the adoption census, and its load-bearing content turned out to be not the procedure but the two conditions that end an evaluation cheaply.

An auto-incrementing integer key is declarable, in both of Postgres’s spellings:

schema.BigSerial("id").PrimaryKey() // bigserial
schema.BigInt("id").Identity().PrimaryKey() // identity, by default
schema.Int("attempt").IdentityAlways() // identity, always

Neither was expressible before, so no auto-incrementing key was at all. Across the eleven applications that reported it — 80 modules, 184 tables — exactly one table was not describable and this construct is what it had; a drift gate is per registry, so that one column took its module out of the gate. The substitution was not the cheap one it looks like: all three tables using it used the serial as the tiebreak that makes ORDER BY occurred_at DESC, id DESC a total order, and the id is in a public interface, so widening it is an API change and sixteen bytes a row on the highest-volume tables in the system.

Auto-ness is a property of the column and not a Type. A bigserial column is a bigint: that is what the catalog reports, what an ALTER COLUMN TYPE has to name, and what comes back when you read it. A type constant would have given int64 two spellings and split the filter grammar from the sort machinery; the evidence the cut is right is that scalarSQLType did not change. The older spelling is not deprecated, either — every report came from a database that already has a serial, and a DSL that could only declare the modern one would propose rewriting the column on its first diff (ADR-0048).

An enum value is data, so a dotted one names a Go constant instead of failing to parse. task.assigned produced NotificationTypeTask.assigned and generation refused its own output, which was right about the output and wrong about the value set, which had no declaration at all. Every run of characters that cannot appear in an identifier is a word boundary now, which is what _ already was, so the value stays verbatim and the initialism table still reaches api.key. Dart derived its members the same broken way and is fixed in the same place; TypeScript was already correct, because a union is the raw strings. A collision is refused with both values and the column named rather than emitted as a duplicate const that fails in the consumer’s package. This also reopens a door: a table whose CHECK introspects back as an enum column is adoptable.

Phase C stopped reporting a residual that was not one. Postgres stores a CHECK over a varchar as a cast of the array on first application and as a cast of each element when fed that back, so the round trip is a fixpoint at two iterations and the probe compared after one — 26 of these across eleven applications, every one that shape, on schemas whose every table was clean. It iterates now, bounded at three rounds, and the verdict carries the round count, so a residual of 0 reached after two still says something was rewritten on the way in. The reading it invited was wrong in the expensive direction: this schema will never be stable under sqlb, about a schema that is stable, in the one phase an adopter trusts precisely when Phase B looked too good.

And the gate moved off the laptop. Six packages across three modules each started a Postgres of their own through testcontainers, so one mise run ci brought up six servers and six worktrees testing at once brought up forty-two. They read a DSN now and start nothing; compose.yaml defines the three servers and mise run pg-up starts them, CI gets the same three as service containers, and no reaper removes another worktree’s containers by label any more. pgtest’s go.mod goes from 50 modules to 6, test-pg from 49s to 12s, and 130 of its 135 tests take t.Parallel() because each already created a database of its own. What runs before a push is mise run preflight — 17s, no containers — since CI is the gate and running it twice only starved the machine.

Also, for anyone reading rather than importing: every library package’s introduction is in doc.go now rather than in whichever file sorted first, moved verbatim, and CLAUDE.md is the map plus the four traps that are not visible from the code.

What it cost:

  • The database-backed suites require SQLB_TEST_POSTGRES, SQLB_TEST_PGVECTOR and SQLB_TEST_PGBOUNCER. There is no skip-when-absent path and no fallback that starts a container: an unset variable is a fatal error naming the task that fixes it, and mise run pg-up supplies all three.
  • A project that sets SkillDir gets a file that wants committing, and this is the one emitter that writes into a directory sqlb does not own. Its path and the document’s shape are under Will move, since the SKILL.md convention belongs to the agent tooling rather than to sqlb. The asymmetry is worth stating in advance: if this emitter is ever removed, the verb has to delete the file rather than stop writing it, because a stale skill still loads.
  • That document is linear in exposed resources and uncapped. Measured over twelve real applications: 12KB at 29 resources, 37KB at 127. Past about 40KB the answer is an index with per-resource detail on demand rather than another round of trimming.
  • Dropping the per-column table, which was 44-49% of it, cost the ability to name a column an agent should not filter on. Two of 25 runs invented the identifier while correctly saying no such filter exists. Probably still the right trade, recorded as a real cost.
  • A column that becomes a serial on a table that already has rows starts its sequence at 1. The change says so in its hazard and names the setval to run first; it is not generated, because the row count is not in the schema and getting it wrong is a duplicate key.
  • sqlb survey’s Phase C verdict names the round its fixpoint was reached on, so anything reading that line reads one more field.

What is still owed is the trigger. Inlining a skill assumes its description already caused it to load, and nothing here tested that — a skills directory that did not exist when a session started is not watched, so the emitted skill could not be invoked from the session that wrote it. ADR-0049 lists that as the live unknown rather than as a footnote, because if a schema skill is only ever read when someone names it, the frontmatter is doing nothing and this design reduces to a document with a pointer.

2026-08-02 · tag

The release that stopped refusing tables. Every version before this one was argued from the library outward. This one was argued inward, from two corpora of real databases and one 312-route application, and what they said is that the declaration language itself had become the thing blocking adoption — one construct at a time, and never the same one twice.

One break, and it is a word. sqlb-survey is now sqlb survey, a second binary folded into the one command tree: needing no schema package is a fact about one verb’s arguments, not a reason a user has to hear about a separate command — and the one it made separate was the adoption probe, the first thing somebody deciding whether to adopt sqlb would run, and the only thing sqlb help did not mention (ADR-0032).

go run ./cmd/sqlb-survey … → go run ./cmd/sqlb survey …

It fails loudly rather than quietly, which is the whole of the risk.

Five constructs the database has and the DSL could not declare. Each was found the same way — a survey refusing a table, then a person deciding whether to change the schema or give up — and each is here because the answer to that question kept being wrong. The gate is per registry and all-or-nothing, so one unmodelable table takes its whole module out.

t.PrimaryKeyColumns("provider", "model_id")
t.Unique("tenant_kind", "tenant_id", "name")
t.AddExclude(schema.Exclusion{Using: "gist", Elements: …})
schema.SmallInt("pos_x") // smallint, int16
schema.Real("confidence") // real, float32

The workarounds these replace are the reason they are worth the surface. A surrogate UUID beside a composite unique index is a schema change forced by the declaration language: 16 bytes and an index per row identifying something nothing points at, plus a data migration on any deployed database. Widening smallint to integer is four columns × two bytes × every row, forever, for nothing. Both fail the rule an adopter actually applies, which is that a schema change must be defensible if sqlb vanished tomorrow.

The exclusion constraint is the one where dropping the construct loses a correctness property rather than performance or ergonomics. Its alternatives were enforcing the overlap in Go, where two concurrent requests interleave between the check and the insert — precisely the drift surface sqlb exists to remove — or holding a permanent known-difference exception in the gate. One app in ten, and the only skip in either corpus that cost correctness.

Two things that were reported wrong rather than not reported. A serial column imported as an ordinary bigint whose default named a sequence, so the table read clean and the DDL it produced did not run; it is refused with a reason now, and the schema that found it goes from two apply failures and a residual of one to a fixpoint. And an extension was invisible on both sides — a clean Report and a clean Diff both claimed everything was represented about a schema that could not be created, which surfaced as 228 identical function uuid_generate_v4() does not exist errors naming a function when the missing thing was an extension. introspect reads pg_extension now and prints the statements to run, ahead of the skips, because that list is useless as trivia and load-bearing as the step before a bootstrap.

Measured on the ten schemas that ranked composite UNIQUE first: clean tables 174 to 214 of 233, partial 59 to 19.

The probe that found all of it is now a command, rather than a throwaway program written twice per adoption. sqlb survey reports the whole database in three phases — the schema as a gate would see it, every table alone so a blocked one is named rather than mixed into a list of skips, and a render into a scratch database to separate a construct that survives import from one that survives the round trip. -modules groups the verdict the way a modular monolith is deployed, -exclude takes SQL wildcards so a project on another migration runner needs no patch, and an unmatched-table set above 25% now says which two explanations to check rather than reading as a shared core. sqlb introspect is the single-shot half, and takes a -dsn rather than a package. Surveying an existing codebase is the other half of that census — the routes and queries in front of the database.

Additive, and new:

  • WireCase. schema.NewModule("app").WireCase(schema.Camel) makes created_at read createdAt in the body, the filter, the sort, the OpenAPI document and both clients, and leave it created_at in the database, in every hand-written query and in pg_dump. One spelling per deployment, derived from the column, no mapping layer and no per-field override — ADR-0036 amended rather than reversed. It exists because six applications were blocked on the same rename and the escape the record offered was to rename 615 columns into quoted camelCase identifiers, which is not reversible and should not have been in the record. Verbatim is the default and regenerates byte-for-byte identically.
  • rest.ReadsOpRead | OpList, named. The most common mount in an adoption was the one with no name, and with only CRUD named it read as a resource with two thirds switched off rather than as an app that already has its writes and has four different reasons a generated one would be wrong.
  • ?not=(…) joins ?and= and ?or=, so a negated group no longer has to be De Morgan’d by the caller — which got silently wrong rows rather than an error. Both grammars spell the same set, and a test pins that rather than leaving it to two parsers happening to agree.
  • The TypeScript and Dart clients emit their runtime once, beside the client. A second module used to ship a second copy of Page, Problem and Transport and ask the application to wire a second transport; in Dart, nominal typing made those two unrelated classes, so no shared pager could accept both.

And three that only show up in a profile. A page of rows is one buffer with its keys rendered at registration — 1,776 allocations to 279 on a fifty-row page, 173µs to 120µs, byte-identical output. A timestamp is appended straight into that buffer rather than marshalled, which is the fifty allocations time.Time.MarshalJSON cannot avoid because the Marshaler interface makes the value answer in bytes it owns. An identifier is quoted into the compiler’s buffer rather than into a string first, 25% off parse-apply-compile. None of the three changes a byte of any statement or any response.

What it cost:

  • Two files appear on the next regeneration, runtime.gen.ts and runtime.gen.dart, and want committing with it. A project with one module need not notice otherwise: both clients re-export the runtime, so an existing import keeps compiling.
  • A composite-key table is declarable so that it can be gated, and is refused by name for REST exposure, as the target of a Ref, and for a non-collection Action. One column is what addresses a row in a URL, a cursor and a cache key, and each of those is a wire format.
  • Changing a deployment’s WireCase after it ships is a breaking change for that deployment, exactly as renaming a column is. compatibility.md says so where it freezes the wire spelling.
  • real widens to double precision and not to numeric, and a diff that would make that cast renders destructive: it swaps an approximate binary float for an exact decimal, so what comes back is the rounded expansion of the stored approximation rather than what anyone wrote.
  • CREATE EXTENSION is printed for a person, never emitted into a migration and never dropped by one. Creating an extension usually needs a superuser a production runner deliberately does not have. Worth revisiting with a decision attached; not worth deciding as a side effect of fixing the diagnosis.

One fix worth naming on its own, because the failure it prevents is silent. Describe checked its in-use flag when the Description was constructed and wrote in the chained calls after it, so a query starting in that window raced the writes to the fields the request path reads to decide what a caller may see — a torn read there is a hidden column reaching a response, not a crash. Every mutator now clones the model, writes the clone and publishes it, so a published *Model is never written again and a statement in flight keeps a consistent snapshot. ADR-0010’s no-locks-on-the-read-path constraint holds because the cost moved to the writer, where it is one copy at startup. The two new concurrency tests are the only ones in the suite that run two requests at once, and two of them fail against the old code — one without the race detector, since copy-on-write is a property an ordinary CI run can check.

2026-08-01 · tag

One break, and it is the one this library most needed to make before anyone depended on it: there is no default hook registry (ADR-0047).

Hooks are the rules that confine what a query may see, so they were also the one surface where ambient state could decide a tenant boundary. On[T]() registered into a package-level default, New(exec) handed every handle that same default, and OnIn[T](r) — the form that says where the rules land — carried the longer name. compatibility.md had listed the hazard under Will move for two releases, in the words it turned out to deserve: which registry a statement uses is decided by the dynamic type of the executor passed to it.

What made it a release rather than a note is that it cost an adopter a tenant boundary. Moving an application onto a per-application registry left one module still calling On[T](), so that module’s rules were no longer on the handle it queried through — and it still compiled, still mounted, and still answered, with every tenant’s rows in the response. Both spellings were valid, and the wrong one was shorter.

So the default is gone. sqlb.New gives each handle an empty registry of its own, On[T](r) is the only registration form, rest.PublishChanges[T](r, p) takes the registry too, and an Executor that is not a *sqlb.DB resolves to a registry nothing can register into — a statement against a bare pool is unconfined, and says so.

The mechanical edit, and every one of them is a compile error rather than a behaviour change, which is the point: the failure this prevents is silent, so its migration must not be.

On[T]() → On[T](reg)
OnIn[T](reg) → On[T](reg)
PublishChangesIn[T] → PublishChanges[T]
sqlb.New(pool) → sqlb.New(pool).WithHooks(reg)

There is no shim, because a shim is the ambient registry under a new name.

What goes with the default is its bookkeeping. Hooks.Reset survives with its reason rewritten — a test gets isolation from NewRegistry, which cannot be forgotten in a teardown — and the sync.Once guards, t.Cleanup(Reset) pairs and “has anything registered yet?” checks that existed to manage a registry nobody asked for are deleted. The examples had all left already, which is the tell: example/tasks built its own registry and said why, and the fx kit never used the default at all.

What this does not fix, named in the record rather than discovered later: a registry nothing attaches. On[T](reg) compiles whether or not any handle carries reg, so hooks can still be registered where nothing runs them. That is strictly narrower — the registry and the handle are usually adjacent expressions rather than action at a distance — and the case that matters is still caught at the mount, because a model declaring Scoped is refused when the handle’s registry has no hook for it (ADR-0030).

2026-08-01 · tag

The release an issue tracker wrote. Twenty issues were filed against the request path on 31 July from an external review and an adoption port; this closes the last of them, plus six more raised the day after by an adoption that got far enough to find sharper ones. The theme is not a feature — it is that most of what a real consumer hit was a default that was right for the schema and wrong for the reader.

Three breaks, and the first is the one to read.

A computed column is opt-in. It is declared on the model, and the model is shared, so projecting every declared one charged every reader for the most expensive one — three correlated subqueries attached to an existence check by id — and a column declaring Needs made that check fail, demanding a viewer bind from a query with no business supplying one. Nothing projects a computed column now unless it asks:

sqlb.Query[Project]().WithComputed("total_tasks", "is_starred")
rest.Options{Computed: []string{"total_tasks", "is_starred"}}

The mechanical edit is WithComputed on a hand-written query and Computed on a hand-written mount. A generated resource opts into its table’s own computed columns, so generated endpoints answer exactly as they did; what changes is everything else reading the same model, which is where the bug was. For a resource it is a boundary rather than a projection setting — a column the resource does not select is not filterable, sortable or nameable in ?select there either, because a filter on a correlated subquery costs what the projection would have. The obligation moved with it: rest.Resource used to refuse any mount whose model declared a Needs column, and now asks only of the resources that render one.

The generated Go client is its own package. A program that wanted the typed client took spf13/cobra and a whole command tree with it, so a sync job made one HTTP request at the cost of a command-line framework. cli/client/client_gen.go now carries Client, Request, Transport, Do and Run against the standard library and nothing else, and cli/cli_gen.go is the cobra tree importing it. Regenerate, then the edit is in the four-line main: &cli.Client{…} becomes &client.Client{…} from the new package. ClientDir emits the client with no command tree at all, which is the server-to-server case; CLIDir emits both and defaults the client into a client/ subdirectory, so a project that set only CLIDir keeps working.

A nil member of OneOf widens the set. IN (NULL) is never true, so a set assembled from nullable values came back quietly narrower than the caller wrote; the nil member now contributes IS NULL instead. A set with no nil in it renders byte-identical, and generated endpoints never reach it — this is hand-written Go. NotOneOf is deliberately unchanged, and now says why on itself.

One thing that is not a source break and will still stop a build: every generated struct tag gained the column’s logical type, so sqlb generate has to run before sqlb check passes. That tag is what fixed the expansion bug below.

Worth stating rather than leaving to be noticed: none of the three was listed under Will move. compatibility.md says a minor bump may break a surface listed there and that each break is described here with its mechanical edit — half of that promise is kept above and half is not, because all three came out of consumer reports rather than a plan. The document now records them where the announcement should have been, which is the correction available after the fact.

What landed, beyond the breaks:

  • A change feed, as a transport. rest.Events mounts an SSE stream through huma’s sse package, so it lands in the OpenAPI document typed rather than as untyped text; rest.Broker is the in-process source behind a rest.Source seam the outbox implements later. A subscriber receives {table, key, op} and refetches, because a payload built outside the subscriber’s context would skip the resource’s BeforeQuery scope and hand one tenant’s rows to another. Correct on one replica and quietly wrong on two, which is the first thing its doc comment says. ADR-0045.
  • The filter tree gained not, and containment gained its negation. nhas, nhasany, nhasall and nhasdoc exist because the URL grammar conjoins by design and has nowhere to put a not — shipping only the tree would have left the two frontends compiling different vocabularies, which is the one thing ADR-0003 claims they do not. A negation is not a complement: each compiles to NOT (…), so a NULL column matches neither has nor nhas, exactly as nin already behaved.
  • ON CONFLICT DO UPDATE assigns an expression. An upsert could only copy the proposed row, so updated_at = now() had to come from the application clock and a counter could not be written at all. OnConflictSet takes any expression, and a column reference inside one has to say which row it means — Excluded or Current — because count = count + 1 reads like an accumulation whichever side SQL silently picks.
  • Where NULLs sort is declared on the column. Postgres’s default is not one placement but two, NULLS LAST ascending and NULLS FIRST descending, so a feed ordered by a column that is NULL until a row is published lifted every draft to the top. Sortable(schema.NullsLast) fixes it once, in both directions, for every caller including the generated clients — which need no new syntax for it. The cursor carries the declared placement, so cursors issued before this release still decode and one issued under a since-changed declaration is refused rather than mispaged.
  • An expanded row is the same shape as a direct one. Expanding a relation whose target had a date column answered 500: json_build_object serialises a date as "2026-07-01" and the Go field is a time.Time, which parses strictly as RFC 3339. Cast to UTC midnight now — ::timestamp AT TIME ZONE 'UTC', not ::timestamptz, which resolves through the session zone and loses a day east of UTC.
  • ?search can reach past the row. A Searchable computed column of text type is now legal, so a chat named in the UI by its participants — a direct message has no name of its own — is findable by a participant’s name. The refusal that blocked it gave a reason about type that the “Searchable requires a text column” rule already made; what it actually cost was the only way to search across a relation. The cost objection is answered by the opt-in above.
  • An adoption’s declarations. numeric(p, s), index column ordering, a foreign-key cycle broken with an ExternalRef instead of dropped, and a self-referential FK that no longer reads as permanent drift — the four things that made a drift gate against a live database un-buildable.
  • The request path’s bounds. ?page=/?offset= are capped and no longer overflow; a repeated single-valued parameter is refused rather than silently dropped; POST and PATCH reject unknown query parameters like every other operation; a multi-row insert decides default-omission per row rather than per statement.
  • sqlbfx, an fx module over the same handles, and a principal seam so a core-style app takes Handles() only.

What it cost. FromGo is cut rather than pending: ADR-0041 wrote the condition — “if the first two applications express everything in SQL” — and both did, so the record says so and closes #17 with the evidence rather than leaving a fourth tier in the tracker. The change feed is correct on one replica and loses a publication if the process dies between COMMIT and the fan-out; both are stated where a reader meets them rather than in a footnote. And a time column has the same expansion defect a date column had, unfixed on purpose: nothing round-trips one, so casting it would have been a guess.

2026-07-31 · tag

Three things the adoption review ranked, built: a computed column, a declared action, and the exit. Between them they answer the two objections that were not about missing features — that one derived value pushed an entity off the generated path entirely, and that sqlb owns too much to be reversible.

One break, and it is a rename. schema.Action is no longer the foreign-key referential type; that noun went to the domain verb below, and the type is schema.RefAction. The constants every call site actually writes — schema.Cascade, schema.SetNull and the rest — are unchanged, so a schema breaks only if it named the type, and the mechanical edit is schema.Actionschema.RefAction in a foreign-key position. compatibility.md announced it under Will move and now records that it landed.

A computed column is an expression. ADR-0041, three of its four tiers:

schema.Computed("is_overdue", schema.TypeBool,
schema.FromSQL("due_date < current_date AND open_tasks > 0")).
Filterable()

One interception point, as the record’s trace predicted: every consumer already resolves through a *ColumnInfo and renders through the compiler’s column, so substituting the expression there puts the value in the projection, the WHERE and the ORDER BY at once. The parameterised tier takes ADR-0030’s shape — Needs("viewer") declares the bind, a BeforeQuery hook supplies it through Builder.Bind, and rest.Resource refuses to mount when nothing does. Without that refusal an unbound expression renders member_id = NULL, returns false for every row forever, and looks exactly like a feature that works. No DDL in either direction, so converting a stored column into a computed one proposes the drop. FromGo is cut, not pending — ADR-0041 set the condition “if the first two applications express everything in SQL”, and both did. Nothing in the tree reaches for it and nothing outside it did either.

A declared action generates the envelope, and the verb stays plain Go. ADR-0043, against the 26 item verbs and ~20 collection verbs the evaluated application had, and the ~30 lines of identical envelope written four times over before any domain logic:

Task.Action(schema.Action{
Name: "complete",
Body: schema.Body(schema.Text("note").Nullable()),
Writes: []string{"status", "completed_at"},
})

serves POST /tasks/{id}/complete and asks Register for one func. The id, the scoped fetch, the 404, the body, the transaction, the row lock, the write set and the response are generated; the transition is not. Writes is enforced rather than documented — exactly those columns, off the row the verb mutated — and it is what makes the fetch take FOR UPDATE, since every one of these is a read-modify-write across a round trip. The verb reaches the TypeScript, Dart and CLI emitters, sqlb.json and the sqlb impact diff, where removing one is breaking and adding one is additive. There is no Method field: every legal value was POST. And the hole is named rather than papered over — a collection action fetches nothing, so it obliges no hook, and that is two in five of the measured verbs.

sqlb eject writes the way out. ADR-0042, and the answer to the objection a pre-1.0 library with no consumers cannot answer with a promise: sqlc and chi are cheap to reverse because they own almost nothing, while sqlb owns the schema, the migrations, the wire format, the client and the CLI. sqlb eject ./schema generates a package that imports pgx and the standard library and nothing else — the DDL, the row structs without their sqlb tags, one function per statement with the SQL written out, net/http handlers, and a README saying what came out and what did not. Deleting sqlb from go.mod afterwards is a supported end state.

The fidelity line is between the surface and the engine. Out whole: CRUD and list at the same paths with the same status codes and the same envelope, every filter operator that is one SQL fragment, ?sort, ?search, ?page, ?per_page, ?count=exact, the declared ceilings and the RFC 9457 error shape. Not out, and refused with a 400 that says so rather than ignored: keyset cursors, ?select, ?expand, the JSON filter tree, and the array and document operators — reproducing those would mean emitting a copy of sqlb, which is a fork with a different import path rather than an exit. Two properties survive the loss of the machinery they were implemented in: capabilities stay opt-in, so a column that never declared Filterable is not filterable in the exit and a Hidden one has no spelling at all, and ADR-0030’s obligation stays compulsory. The load-bearing half is pgtest/eject_test.go, which stands the committed exit beside the generated resources it came from, points both at one database, sends both the same requests and compares the bodies byte for byte.

Adopting an existing database is where the rest of the work went. Each of these made a schema-vs-database gate propose migrations nobody asked for, which is the failure that teaches people to stop reading the gate:

  • IndexNamed and UniqueIndexNamed declare an index under the name the database already gave it. The name is not inert — Postgres reports a violated constraint by name, so renaming a unique index turns a handled collision into an unhandled 500 without touching the code that handled it. The generated migration says so now.
  • ExternalRef(...).Enforced() emits a real FOREIGN KEY against a table this schema has not declared, which is the thing an incremental adoption always has to say and had no spelling for. What it gives up is what ADR-0015 bought by refusing the constraint: two modules joined this way can no longer be migrated independently, so it is opt-in and unenforced stays the default. introspect imports foreign keys this way, which is what stops a gate proposing DROP CONSTRAINT forever.
  • A jsonb default is compared as a document, so '{"a":1,"b":2}'::jsonb and '{"b": 2, "a": 1}' are one default — which is what Postgres thinks too, since jsonb stores a parsed value rather than the text it arrived as. Only for jsonb; on a text column those are two strings, and the test says so.

The round trip is a fixpoint, asserted rather than assumed. introspect, RenderSchema and Diff were each well tested and nothing checked that they agreed with one another about one schema, which is why none of their own tests could see the three disagreements that fell out. RenderSchema could not write a vector column at all, so a 69-table database could not be turned into 69 declarations to review on account of one column; an index lost its operator class and storage parameters, which for pgvector Postgres rejects outright, since the class selects the distance function and there is no default; and an enum’s CHECK lost its name, so every later diff proposed dropping and re-adding it. The gate applies an awkward schema, reads it, renders it back to source that must compile, rebuilds a second database from what was read, and compares the two through pg_catalog — databases rather than registries, because two registries agree about everything they both dropped.

A family of codegen import bugs, in both directions and all with one cause: format.Source parses without type-checking, so an import that is named but missing, or present but unused, is valid Go source that fails only at the consumer’s compiler. Three were jsonb-shaped and the rest were found by auditing the whole set — a read-only resource importing time, a table whose patchable columns are all nullable importing errors, a schema whose only uuid column is a primary key importing google/uuid, a hidden timestamp named by the typed update with nothing importing time, and a nullable vector matched against a hand-maintained list of type spellings that was one short. Beside them, a nullable jsonb create body assigning a pointer into a non-pointer field. The general guard is TestGeneratedGoCompiles: eight schema shapes generated into a scratch package and handed to one go build, so the compiler decides rather than a substring assertion naming the mistake in advance.

2026-07-30 · tag

The release ADR-0040 was announced for. v0.3.0 said the driver question had been decided and that nothing of it was built; this is it built. sqlb depends on pgx v5, database/sql is not the contract, and Executor — Frozen in compatibility.md — broke on purpose, before the tag that would have made the same work a major version and a hand migration for everybody.

The mechanical edit is at the seam: pass a *pgxpool.Pool where a *sql.DB used to go. *pgx.Conn and pgx.Tx satisfy Executor as they stand, and the last of those is what this was for — sqlb writes now join a transaction the application opened itself, which two handles over one pool could never do. sqlb.New(tx) knows it is inside one and deliberately does not take the boundary over, so AfterCommit refuses there rather than queueing callbacks behind a commit sqlb will never perform. Something that still wants a *sql.DB — goose, sqlc — gets one from stdlib.OpenDBFromPool over the same pool, and the examples are written that way because that is the shape a real adopter lands on.

Two surfaces disappear with it. sqlb.EncodeArray is gone with nothing in its place, and the 449-line array-literal codec behind it: a []string binds as text[] and scans back from one because pgx does that. SetErrorClassifier stays, but the case it was written for is now the default — ConstraintError carries the constraint name, table, column and detail read off *pgconn.PgError, with nothing registered.

The second break is smaller and shows up on the next regeneration. A nullable jsonb column’s model field is *json.RawMessage: it was the one column whose generated type did not say it could be NULL. It was also unreadable through database/sql, which is how it was found in a real port — that half no longer reproduces, because taking pgx replaced the executor that had the gap. Regenerate, and the compiler names the call sites.

Additive, and new:

  • A jsonb column is filterable. ?metadata=hasdoc.{"lang":"de"} compiles to @>, subset containment rather than equality, so a document carrying more keys than the filter named still matches. Not spelled contains, for the third time and for the same reason: that name is the text substring operator, and one name dispatched on column type is the ambiguity the generated clients exist to remove. A document column takes hasdoc, isnull and notnull and nothing else — there is no bare-value shorthand, and the ordering and pattern operators would answer rather than refuse, which is worse.
  • A vector column. schema.Vector("embedding", dim) stores a pgvector embedding, sqlb.Near yields the score, the ordering and an AtLeast threshold from one call rather than three that must agree, and RegisterVectorType puts the binary codec on the connection — a pgx API with no database/sql spelling, and one of ADR-0040’s arguments. The column is Hidden and not optionally so. There is no index kind and no REST search operation: a similarity search is an exact scan over the rows a filter already selected. ADR-0026 stages the index as a second decision and stays Exploring.

Fixed, most of them found by adopting sqlb over something that already existed. A VARCHAR(n) default round-tripped as an expression, so Diff proposed the same ALTER on every run and the drift gate stayed red for a reason that was not real. A schema package under internal/ could not be read by the generator. attgenerated was misread after the flip, so every column of every imported database looked generated. A rejected write arrived as “none of the result columns map to T”. And sqlb generate’s scratch directory survived an interrupted compile, into somebody’s git add -A.

The rest is evidence rather than surface. ADR-0026’s physical claims about pgvector are measured now instead of read out of documentation, and a fourth was added: the planner may decline the ANN index, which makes the silent under-return conditional on statistics nobody watches. ADR-0041 decides computed fields, including the per-viewer tier a static SQL string cannot express, and builds none of it. example/recipes is 86 Go example functions, one point each, whose printed output is compared on every go test — so a recipe describing an API that changed fails the build instead of misleading the next reader.

See the driver, which says what the break bought and what it cost, in both directions.

2026-07-30 · tag

No API change. What this release carries is a decision, the seam that makes it buildable, and the test coverage that was holding it up.

ADR-0040 decides that the engine will depend on pgx and that database/sql stops being the contract — a break to Executor that lands before 1.0 or not at all. Nothing of it is built yet. Read the driver before pinning: the interface every terminal call takes is going to change, and this is the release that says so in advance rather than the one that does it.

The enabling refactor is here: the scanners read an internal rowSource interface instead of *sql.Rows, which is correct under either answer and turns the eventual migration into an adapter rather than a rewrite of scan and mutate.

pgtype values — pgtype.Date, pgtype.Timestamptz, pgtype.UUID — are now covered in both directions including NULLs, with compile-time assertions that fail the build if a pgx release ever drops sql.Scanner or driver.Valuer. That path is load-bearing for adopting sqlb over existing sqlc structs and was previously tested only with sql.NullTime.

The go directive drops from 1.25.7 to 1.25.0. It was patch-pinned by go mod init rather than by any requirement, and pinning it forced every consumer onto that exact toolchain.

Also: a documentation pass that closed the open ends across the ADRs and the six review reports, and a nested rest module that was built and reverted within the day — huma stays the default HTTP path, in the same module. ADR-0007 records why.

2026-07-30 · tag

The first release with a transaction handle, and the first that a consuming application can depend on without a local replace.

v0.1.0 predates db.go entirely: sqlb.DB and sqlb.New — the handle every data layer takes — landed after it, along with array columns, codegen type overrides, a JSON filter tree, schema-impact diffing, and a fix for an expansion carrying its target’s scope onto the join.

Cut for the studio-apps port, which could not compile against v0.1.0.

2026-07-27 · tag

The first tagged release.

Pre-1.0. Semantic versioning applies from v1.0.0; until then a minor bump may break a surface listed as moving, and the release notes carry the mechanical edit that fixes it.

Frozen, because other systems couple to them:

  • ExecutorQueryContext and ExecContext, nothing more
  • The filter grammar — the URL syntax and its operator names
  • Already-applied migrations are never reinterpreted

Expected to move, named in advance:

  • Hook registration, when the transaction-scoped handle scopes the registry that sqlb.On[T]() reaches today
  • Terminal call signatures, when Go 1.27 allows methods to declare type parameters — additive, the functions stay
  • ?expand, which is refused at startup until the joins land

See compatibility.md.