v0.6.0
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.Eventsmounts an SSE
stream through huma's sse package, so it lands in the OpenAPI
document typed rather than as untyped text;rest.Brokeris the
in-process source behind arest.Sourceseam 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'sBeforeQueryscope 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,nhasallandnhasdocexist
because the URL grammar conjoins by design and has nowhere to put
anot— 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 toNOT (…), so a NULL column matches
neitherhasnornhas, exactly asninalready behaved. -
ON CONFLICT DO UPDATEassigns an expression. An upsert could
only copy the proposed row, soupdated_at = now()had to come
from the application clock and a counter could not be written at
all.OnConflictSettakes any expression, and a column reference
inside one has to say which row it means —Excludedor
Current— becausecount = count + 1reads 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 adatecolumn answered 500:
json_build_objectserialises a date as "2026-07-01" and the Go
field is atime.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. -
?searchcan reach past the row. ASearchablecomputed
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 anExternalRefinstead
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;POSTandPATCHreject 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 takesHandles()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.