Move the store to sqlc
Overview
Replace the hand-written pgx queries in internal/store/pg with code that sqlc generates from SQL files and the goose migrations. sqlc becomes the default for every query; hand-written pgx stays allowed only for a query sqlc cannot express, with a comment saying why. Nothing above the store changes: store.Store, store.Scoped and every sub-store interface keep their signatures, and the phase 2 tests carry over unchanged as the proof that behaviour did not move. This phase reverses the phase 2 decision “hand-written SQL, no sqlc” on purpose, to learn sqlc in a real codebase while the store is still small (five query files) and before phase 4 copies its pattern. GitHub issue #26.
Requirements
Functional
- Every query in
internal/store/pgruns through sqlc-generated code, or is a documented escape hatch. The phase 2 queries are all expressible, so this phase is expected to ship with none. - Optional predicates use
sqlc.narg: the member-only filter onusers,api_keysandaudit_events, and thesince,actionandtarget_idfilters on audit events. The statement text is fixed; only parameters vary. - The tenant rule from phase 2 is unchanged and now lives in SQL files: every statement on a tenant-owned table takes
tenant_idas a required parameter, bound only from the scope insidescoped. No generated method is called with a tenant id from anywhere else. ResolveKeystays the single tenant-less query on a tenant-owned table, still reachable only throughstore.AuthLookup.audit_eventsstays append-only: the audit query file and its generated Go have an INSERT and a SELECT and nothing else.
Non-functional
- The generated code lives in package
internal/store/pgitself (sqlcout: internal/store/pg,package: pg). A separate generated package would be a twenty-first package and break the package cap inplan.md. - sqlc is pinned in the
Makefileand run withgo run github.com/sqlc-dev/sqlc/cmd/sqlc@<version>. It is not added togo.mod, so its dependency tree never reaches the module graph or the binary. - Types:
sql_package: pgx/v5,emit_pointers_for_null_types: true,emit_db_tags: true, overrides mappinguuidto the Go 1.27 stdlibuuid.UUID(*uuid.UUIDwhen nullable),timestamptztotime.Time(*time.Timewhen nullable),jsonbtojson.RawMessage, and column overridesusers.roletostore.Roleandtenants.kindtostore.TenantKind. Nopgtypetype appears in a generated struct. make generateregenerates;make sqlc-checkrunssqlc diffandsqlc vetand fails when the committed code is stale. There is no CI yet; phase 16’s CI job runsmake sqlc-check.- The roadmap tooling accepts a decimal phase number:
build_plan_html.goandplan-template.htmltreat2.1as a phase, the build order lists it, and the Hugo site shows it between phases 2 and 3.
Architecture
migrations/*.sql ──(schema)──┐
├── sqlc generate ──> internal/store/pg/{db,models,*.sql}.go
internal/store/pg/queries/*.sql ┘ (package pg: Queries, DBTX, row structs)
store.For(ctx) ──> scoped{q, scope} ──> New(s.q).ListUsers(ctx, ListUsersParams{TenantID: s.scope.TenantID, ...})
│
querier (pool or tx) — unchanged-- internal/store/pg/queries/users.sql (shape, not final text)
-- name: ListUsers :many
select id, tenant_id, email, role, status, created_at
from users
where tenant_id = @tenant_id
and (sqlc.narg('only_user')::uuid is null or id = sqlc.narg('only_user'))
order by created_at;// internal/store/pg/user.go (shape)
func (us userStore) List(ctx context.Context) ([]store.User, error) {
rows, err := New(us.s.q).ListUsers(ctx, ListUsersParams{
TenantID: us.s.scope.TenantID,
OnlyUser: us.s.ownUserID(), // nil for owner and admin
})
return mapRows(rows, toUser), mapErr(err)
}querierkeepsBegin, soInTxis unchanged; it satisfies the generatedDBTX, soNew(s.q)works on the pool and inside a transaction alike.- Each hand-written file keeps its sub-store type and becomes a thin mapper: build params from the scope, call the generated method, convert the generated row struct to the
storetype, map errors withmapErr. ownRowsOnly()becomesownUserID() *uuid.UUID, returning the scope’s user id for a member and nil otherwise, which is exactly what thesqlc.nargpredicate needs.
Instrumentation
No new spans or metrics. sqlc puts -- name: ListUsers :many at the head of each statement it sends, so the db.query.text attribute on otelpgx spans now starts with the query name (the span name stays the SQL verb), which makes a slow query in Tempo searchable by name. The learning doc shows this.
Related Code Files
Create
sqlc.yaml— engine, schema (migrations), queries, output package and the type overrides.internal/store/pg/queries/tenants.sql,internal/store/pg/queries/users.sql,internal/store/pg/queries/api_keys.sql,internal/store/pg/queries/audit.sql,internal/store/pg/queries/auth.sql.internal/store/pg/db.go,internal/store/pg/models.go, and one*.sql.goper query file — generated, committed, never edited by hand.docs/learning/02.1-sqlc-store.md.
Modify
internal/store/pg/tenant.go,internal/store/pg/user.go,internal/store/pg/apikey.go,internal/store/pg/audit.go,internal/store/pg/auth.go,internal/store/pg/scoped.go— replace SQL literals and scans with generated calls and mappers.internal/store/pg/audit_test.go— the append-only check readsqueries/audit.sqlandaudit.sql.goinstead ofaudit.go.Makefile—generateandsqlc-checktargets, sqlc version pin..golangci.yml— exclude generated files if a linter flags them.docs/tech-stack.md,docs/roadmap.md,docs/system-architecture.md,docs/learning/README.md,README.md— record sqlc and the reason; list the new lesson.plans/260922-0123-hango-agent-harness-roadmap/build_plan_html.go,plans/260922-0123-hango-agent-harness-roadmap/plan-template.html— accept decimal phase numbers.plans/260922-0123-hango-agent-harness-roadmap/phase-16-hardening-rls-ci-release.md— CI runsmake sqlc-check.
Implementation Steps
- Pin sqlc in the
Makefile(SQLC := go run github.com/sqlc-dev/sqlc/cmd/[email protected], latest release) and addgenerateandsqlc-check. Confirm it builds on this machine (it compilespg_querywith cgo the first time). - Write
sqlc.yamlwith the options and overrides above. Runsqlc compileagainst the goose migration to confirm it reads the Up section and ignores Down. - Write
queries/tenants.sql: create, get, get by slug, list, platform, user tenant, lock (for no key update). Generate, then rewritetenant.goas mappers. Run the tenant tests. - Write
queries/users.sqlandqueries/api_keys.sql, including theinsert ... select ... from usersguard on key creation, the owner-key count join, and revoke with the optional member predicate. Rewriteuser.go,apikey.goandscoped.go(ownUserID). Run the tenancy tests. - Write
queries/audit.sqlwith append and a list that usessqlc.nargfor the member,since,actionandtarget_idfilters and a bound limit. Rewriteaudit.goand point the append-only test at the query file and generated file. - Write
queries/auth.sqlforResolveKeyand rewriteauth.go. - Delete the now-unused helpers (
scanTenant,collect, column constants) only when nothing references them. If a query truly cannot be expressed, keep it hand-written with a comment naming the sqlc limit; record it in the learning doc. - Run
make generatetwice and confirm no diff; runmake sqlc-check; rungo build ./... && go vet ./... && go test -race ./... && go test -short ./... && golangci-lint run. - Teach
build_plan_html.godecimal phase numbers (parse\d+(\.\d+)?, store as float64, print with%g) and check the template’s phase lookups still match. Add phase 2.1 toplan.mdand regenerateplan.html. Build the Hugo site and confirm phase 2.1 and its lesson sit between phases 2 and 3. - Update the docs listed above, write the learning doc, and add
make sqlc-checkto phase 16’s CI step.
Learning Doc Outline
docs/learning/02.1-sqlc-store.md must contain:
- Concept: what sqlc reads (schema from migrations, queries from
.sqlfiles), what it generates, and what it checks at generate time that hand-written SQL only finds in tests. - Design choices: sqlc as the default with a narrow escape hatch instead of a split hybrid; generated code in the
pgpackage to respect the package cap; type overrides instead ofpgtype;go runwith a pinned version instead of ago.modtool dependency. - Where tenancy lives now:
tenant_idis a required parameter in SQL, the scoped handle is still the only caller, and the cross-tenant tests did not change. - Limits met in practice: optional filters with
sqlc.narg, whysqlc.nargcannot tell “unset” from “set to NULL”, one row struct per query shape, type inference that needs a::typecast, and what an escape hatch would look like. - How to debug this layer: a stale generated file is caught by
make sqlc-check; a slow query is found in Tempo by the-- name:comment at the start ofdb.query.text; a type mismatch shows up atsqlc generate, not at run time. - goclaw comparison:
database/sqlplus sqlx with hand-written SQL, chosen because goclaw also supports SQLite; hango is Postgres-only, so it can use native pgx and sqlc. - Exercises: add a column to
usersand follow the compile errors; write the audit list with a runtime sort column and see why it needs an escape hatch. - Time budget: 6h implementation + 1h learning doc.
Success Criteria
-
make generateproduces no diff on a clean tree, andmake sqlc-checkpasses. - No SQL string literal remains in
internal/store/pgoutsidequeries/and generated files, apart from documented escape hatches (expected: none). -
go build ./... && go vet ./... && go test -race ./...pass with Docker running, andgo test -short ./...passes. - The phase 2 tests in
internal/store/pg,internal/serverandinternal/apppass with no edit other than the audit append-only check’s file paths. -
golangci-lint runreports 0 issues. -
go list -deps ./cmd/hangolists no sqlc orpg_querypackage, andgo.moddoes not require sqlc. - No generated struct field has a
pgtypetype. -
plan.htmlregenerates with phase 2.1 in the phases table and build order, and the Hugo site lists phase 2.1 and its lesson between phases 2 and 3. -
docs/tech-stack.mdrecords sqlc and the reason, anddocs/learning/02.1-sqlc-store.mdis written.
Evidence, 2026-10-15: make generate run twice leaves no diff and make sqlc-check passes. go build ./..., go vet ./... and go test -race -count=1 ./... pass with Docker; all 12 tests in internal/store/pg ran against Postgres 17 and passed. go test -short ./... passes and golangci-lint run reports 0 issues. go list -deps ./cmd/hango has no sqlc or pg_query package, go.mod has no sqlc, and rg pgtype internal/store/pg finds nothing. The only backtick literal left in the hand-written store files is the {} audit detail default. go run build_plan_html.go lists 21 phases, and the Hugo build orders the plan sidebar 1, 2, 2.1, 3.
Deviations, decided during implementation:
- Site weights became phase × 10 (phase 2 is 20, phase 2.1 is 21) for every phase file and learning doc. Hugo weights are whole numbers, and with phases 2 and 2.1 both at weight 2 Hugo broke the tie by title and put 2.1 first.
docs/docs-site.mdandplan.mdstate the new rule. - Mappers are Go struct conversions (
store.User(u)) instead oftoUserfunctions. Renamestrace_llm_content: TraceLLMContentandapi_key_id: APIKeyID, plus aliasingkey_hash as hashinResolveAPIKey, make the generated structs field-identical to the store types. The audit event keeps a field-by-field mapper because NULL references becomeuuid.Nil(). - The append-only test checks every statement in
queries/*.sql, comments stripped, and fails on one that namesaudit_eventswith UPDATE, DELETE or TRUNCATE. It also checks the raw literals inaudit.go, where an escape hatch would go. It does not read the generatedaudit.sql.go, which is derived from the query files. Code review pointed out that a purge could be added to any query file, so the check covers all of them; a probe file with adelete from audit_eventsmade it fail. make generatedeletesinternal/store/pg/*.sql.gobefore running sqlc, because sqlc never removes output andsqlc diffdoes not flag a leftover file from a renamed query file..golangci.ymlneeded no change: the linters already skip files markedCode generated ... DO NOT EDIT.plan-template.htmlneeded one regex change, so links tophase-02.1-*.mdopen the phase modal.- Issue #26 differs from this spec in three places. Transactions keep phase 2’s
querier, passed toNew(s.q), instead ofQueries.WithTx, becauseInTxalready swaps a pool for a transaction andWithTxwould add a second path. There is no identifier allowlist, because no query needed an escape hatch; the first one adds it. The CI check is deferred to phase 16, which has no CI before it, and its step now runsmake sqlc-check. - The query files have no file-level comments, because sqlc attaches the top comment of a file to the first query’s Go doc. Each note sits on the query it describes.
make generaterunssqlc compilebefore deleting the old generated files, so a typo in a query leaves the committed code in place.- Phase 4’s store steps were rewritten for sqlc: query files, the
sqlc.nargmember predicate, and anunnestinsert for message batches.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
A generated method is called outside scoped with a hand-picked tenant id | Low x Critical | Review finds New( outside the sub-store files | Generated code is in package pg, so only pg can reach a pool to call it; the boundary test still forbids pgx elsewhere |
sqlc.narg filters make the planner pick a worse plan | Low x Low | Slow ListAuditEvents span | Audit list is index-led by tenant_id, occurred_at; revisit with explain if a span shows it |
| sqlc’s cgo build fails on a machine or CI image | Medium x Low | make generate fails to build sqlc | Generated code is committed, so building and testing hango never needs sqlc; only regeneration does |
| sqlc misreads a later migration (plpgsql, RLS policies in phase 16) | Medium x Medium | sqlc generate errors on a migration | Fix the statement or add the table shape to a sqlc-only schema file; record it as a sqlc limit |
| Decimal phase number breaks the plan page | Medium x Low | build_plan_html.go errors or the page misorders | Step 9 changes the parser and checks the page before closing the phase |
Rollback
Revert the phase commit. The schema does not change, so no migration runs in either direction, and the phase 2 hand-written store returns exactly as it was.