Nobody hand-writes a data service. You annotate a proto RPC with the SQL it should run,
and a custom toolchain in soundverse-proto emits the
entire Go Connect handler — pool selection, argument binding, row scanning, Redis
cache-aside, error mapping, tracing, and structured logs. This page explains the two
programs that do it and the guarantees they bake in.
Two programs cooperate. The first is a real protoc plugin; the second is a plain Go
program that stitches the results together:
protoc-gen-soundverse-db
A protoc plugin at
cmd/protoc-gen-soundverse-db/main.go.
It reads the DB annotations off each method/field and writes one
*.soundverse_db.go Connect handler per data service. It fails the build on a
dangerous or unmappable annotation.
gen-db-registry
A plain Go program at
cmd/gen-db-registry/main.go.
It walks the generated files with go/ast and emits one aggregate
RegisterAllDBServices(...) that a consuming Go service calls once to mount every
handler.
They are steps 4 and 5 of the five-step codegen pipeline; the plugin must be built into
./bin before the DB template runs. The exact ordered commands live in
Run proto codegen locally. Everything they emit lands under
gen/ and is .gitignored — it is published to soundverse-proto-go, never committed.
All of codegen is driven by a handful of custom proto extensions in
proto/soundverse_proto/db/v1/db_options.proto.
Method options describe the RPC; the field options map a message field to a SQL column.
The full table (with extension numbers and the error-code map) lives in the
proto contract reference; the ones that matter here:
Annotation
Applies to
What it does
target_db
method
Pool key (e.g. "prod"). Selects which pgxpool.Pool from the DBPools map runs the query.
sql_query
method
The literal SQL the handler executes. Columns must be explicit (no SELECT *); mutations need a WHERE.
require_internal_auth
method
bool. When true the handler rejects any request whose bearer token differs from InternalSecret.
cache_ttl_seconds
method
int32. > 0 turns on read-through Redis caching of the proto-marshalled response.
cache_key
method
Template like tools:active:{environment}; {name} placeholders bind to request fields.
invalidates_keys
method
Repeated key templates; the handler DELs them after a successful write.
db_column
field
Maps a message field to a SQL result column for scanning, e.g. [(db_column) = "created_at"].
db_enum_text
field
Marks an enum request field whose column is a Postgres ENUM: send the lowercase label instead of the int (UNSPECIFIED → "").
Here is the simplest read RPC from
generation/task/v1/task.proto.
The row message GenerationTask tags every field with a db_column; the RPC carries
target_db, sql_query and require_internal_auth; and the request’s first field is a
RequestContext.
soundverse_proto.common.v1.RequestContextcontext=1; // SKIPPED — not $1
stringtask_id=2; // this is $1
}
messageGetTaskResponse { GenerationTasktask=1; }
From these annotations the plugin emits a GetTask method on
GenerationDataServiceDBImpl that runs, in order:
Open a span + arm the log. Every generated method starts an OpenTelemetry span
(GenerationDataService/GetTask) and defers one structured slog line recording
cache status and cache-get / query / cache-set timings — covering every return path,
including errors.
Check auth (because require_internal_auth = true): read the Authorization
header, strip the Bearer prefix, and reject with CodeUnauthenticated anything that
isn’t exactlys.InternalSecret. An empty configured secret also rejects.
Optionally read cache — skipped here since GetTask has no cache_ttl_seconds.
When set, the handler GETs the marshalled response from Redis (key prefixed by
s.CachePrefix) and returns on a hit.
Pick the pools.DBPools["prod"]. A missing/nil pool returns CodeInternal“database pool … is not configured” — this is a request-time failure, not a
build-time one (see the mounting section).
Bind args and query. The request’s scalar fields bind to $N (here task_id →
$1), then QueryRow runs and scans each projected column into the response through
the db_column map. Because GetTaskResponse is a single nested message
(GenerationTask task), the scanner fills that nested message; a repeated field
instead triggers a Query + row-loop that appends each row.
Map the error faithfully. Every DB error passes through dbErrToConnect, which
translates pgx/Postgres errors into the right Connect code instead of a flat
Internal.
This is the single most important trap in the codebase. The plugin binds request fields to
$N in renderQueryArgs, with one non-obvious rule:
This exact off-by-one once silently broke tool registration: RegisterTool,
GetActiveTools, and two Upsert* RPCs all started at $2, so a freshly booted worker
could never register its tool, claimed no tasks, and chat surfaced a NotFound“no rows
in result set”. The fix renumbered everything from $1. Because the guardrails cannot
catch this class of bug, the discipline is enforced by convention: never nest a filter or
page message in a List* request. Put the cursor and filters as flat scalars
(after_created_at, after_id, page_limit) directly on the request — exactly as
ListUserTasks does. The db_enum_text args are the one deliberate transform: an enum
request field so marked is sent as its lowercase label via a generated int→label map, not
the raw int.
The plugin is opinionated and refuses to generate dangerous or broken code. These checks —
in projectedColumnsFromSQL and buildColumnsForMessage — abort the whole buf generate
with a loud, friendly error:
No SELECT * / RETURNING * / table.*. A wildcard projection silently breaks the
row scanner the moment a column is added in production.
No WHERE-less UPDATE / DELETE. That would rewrite or wipe the entire table.
Every projected column must have a matching db_column field. If your SELECT
asks for a column the response message can’t hold, generation stops and tells you to add
the field or drop the column.
Only supported scan types.string, bool, int32/64, float, bytes, enum,
google.protobuf.Timestamp, and their repeated forms. Anything else (a nested-message
column, a map) fails fast.
After the per-service files exist, gen-db-registry walks
gen/go/.../*.soundverse_db.go, finds each generated ...DBImpl type with go/ast, and
emits one file: gen/go/soundverse_proto/registry/db_registry.go. It exposes a single
mounting function, build-provenance accessors (Version(), SourceCommit(),
SourceRef(), fed by the PROTO_GO_* env vars at publish time), and
DiscoveredRegistries() — the list of mounted DB-backed service names.
cfg.InternalRPCSecret is the value behind the INTERNAL_RPC_SECRET env var — the same
secret every require_internal_auth handler checks the bearer token against. See
Configuring .env.
cfg.CacheKeyPrefix() returns {env}:database: (env from the ENVIRONMENT var, default
local), mirroring the fleet-wide {env}:{namespace}:... Redis convention.
After mounting, main.go fails fast: it asserts the "prod" pool exists and Pings
every configured pool, so the service crash-loops on bad wiring rather than booting green
and 500-ing on first traffic.
core-database serves these over cleartext HTTP/2 (h2c) on its internal listener, so both
Connect-Web (HTTP/1.1) and gRPC (h2c) clients hit the same port without TLS at this layer.
Next to each annotated proto sits a
schema.sql
holding the real Postgres DDL — schemas, enums, tables, indexes. When you touch a
DB-backed RPC, four things must always agree:
the sql_query column list,
the db_column field annotations on the row message,
the schema.sql table definition,
the request fields bound to $N.
The ::type casts in your SQL must match the live columns — payload::text works because
payload is jsonb; NULLIF($1,'')::uuid works because id is a UUID.