Add a field or RPC to a data service
The data tier is codegen-driven: there is no hand-written CRUD. A custom protoc
plugin reads SQL annotations off each RPC and emits a Connect handler that runs that SQL
against a pgx pool. So “add a column” means edit the proto + the schema, then
regenerate — never write a Go handler by hand.
-
Edit the
.proto. Open the right file (e.g.proto/soundverse_proto/generation/task/v1/task.proto). Tag each row field with a column, and give a new RPC its SQL:task.proto string status = 7 [(soundverse_proto.db.v1.db_column) = "status"];rpc GetTask(GetTaskRequest) returns (GetTaskResponse) {option (soundverse_proto.db.v1.target_db) = "prod"; // which pooloption (soundverse_proto.db.v1.sql_query) ="SELECT id, environment, tool_id, status ""FROM generation.tasks WHERE id = NULLIF($1, '')::uuid LIMIT 1";option (soundverse_proto.db.v1.require_internal_auth) = true;}For a Postgres
ENUMcolumn, use thedb_enum_textfield option (send the lowercase label, cast in SQL). -
Mind the
$Nrule. The request’s first field is always the sharedsoundverse_proto.common.v1.RequestContext, and it consumes no placeholder. So$1is the first scalar field after the context. InQueueTaskRequest,contextis field 1,environmentis field 2 →$1;priority(field 18) is bound last as$17. Off-by-one here silently binds the wrong column. -
Update the sibling
schema.sql. The DDL lives beside the proto (e.g.generation/task/v1/schema.sql), not in core-database — core-database runs no migrations. The compose Postgres init loads theseschema.sqlfiles read-only from thesoundverse-protosubmodule. YourRETURNING/SELECTcolumn order must line up with the order of thedb_column-annotated fields. -
Regenerate, in this exact order (mirrors
ci.yml):in soundverse-proto/ # build the custom DB plugin into ./bin firstcd cmd/protoc-gen-soundverse-db && go build -o ../../bin/protoc-gen-soundverse-db . && cd ../..buf generate # Go, Python(+stubs), Swift, Kotlinbuf generate --template buf.gen.connect.yaml # Connect handlers + Connect-ES TSbuf generate --template buf.gen.soundverse-db.yaml # SQL-backed Go DB bindingscd cmd/gen-db-registry && go run . && cd .. # aggregate db_registry.go -
Merge to
staging.staging-release.ymlregenerates and publishessoundverse-proto-go@stagingandsoundverse-proto-python@staging(plus Swift / Kotlin / Web). See the deploy cascade for what happens next. -
Bump the Go consumers by hand. In
core-database(andcore-mcpif it calls the RPC) run./update-proto.sh(it doesgo get …-go@staging && go mod tidy), commit, and redeploy. The regeneratedregistry/db_registry.gomounts the new handler viaRegisterAllDBServices(mux, dbPools, redisClient, internalSecret, cacheKeyPrefix). -
Python consumers auto-update. A PR to pick up the new SDK is opened automatically.
The four things that must agree
Section titled “The four things that must agree”For a row to round-trip correctly, these four must line up — the plugin scans field order:
- the
sql_querycolumn list (SELECT/RETURNING), - the
db_columnannotations on the message fields, - the
schema.sqltable definition, - the
$Nrequest-field binding order.
Related
Section titled “Related”- Run proto codegen locally — the regenerate step in detail
- Drive the proto → deploy cascade — what merging triggers
- How the DB codegen plugin works — the plugin internals
- Proto contract reference — every DB annotation