feat(config): runtime DatabaseConfig + DSN assembly (S04 Step 1)

Summary

Step 1 of the database composition-root plan. Converts the DatabaseConfig proto into a runtime config.DatabaseConfig, assembles the LabKit postgres.Client DSN from the split parameters, and rejects database.tls.server_name at config-load because the current LabKit release exposes no DSN equivalent (sslservername does not exist).

This MR ships parsing and assembly only. Step 2 (migration runner) and Step 3 (composition root wiring) follow in sibling MRs.

What's in this MR

  • internal/config/database.go (new): DatabaseConfig, DatabasePoolConfig, TLSConfig runtime types; databaseFromProto converter wired into Load; AssembleDSN builds a postgres:// URL via net/url (password percent-encoded, TLS file paths encoded as sslrootcert/sslcert/sslkey only when populated); *UnsupportedTLSFieldError for the server_name rejection.
  • internal/config/config.go: adds Database DatabaseConfig to Config and calls databaseFromProto in Load.
  • internal/config/database_test.go (new): nine targeted tests covering the converter happy path, both pool-duration error paths, missing-stanza zero-value contract, server_name rejection, and four AssembleDSN shape tests.
  • proto/.../config.proto + gen/.../config.pb.go: doc-comment-only update on TLSConfig.server_name flagging the database-path limitation. No field changes; the regenerated .pb.go diff is limited to the leading comment.
  • Fixtures: extends happy_path.yaml with a database: stanza, adds invalid_database_pool_duration.yaml as a typed-error witness, renames the prior server-only fixture to no_database.yaml for the missing-stanza test.

Acceptance (plan Step 1)

Criterion Pinned by
Populated database: yields full Config.Database TestDatabaseFromProto_HappyPath, TestLoad_HappyPath
Invalid pool duration returns *DurationParseError TestDatabaseFromProto_InvalidMaxLifetime, TestDatabaseFromProto_InvalidMaxIdleTime, TestLoad_DatabaseInvalidPoolDuration
Missing database: yields zero DatabaseConfig TestDatabaseFromProto_AbsentBlockYieldsZeroValue
AssembleDSN parsable by pgx.ParseConfig, sslmode and TLS paths encoded, password percent-encoded TestAssembleDSN_NoTLS, TestAssembleDSN_WithTLSPaths, TestAssembleDSN_PasswordEscaping
tls.server_name populated returns *UnsupportedTLSFieldError TestDatabaseFromProto_RejectsServerNameAsUnsupported
Empty host short-circuits to preserve downstream ErrDSNRequired TestAssembleDSN_EmptyHostReturnsEmpty

Test plan

  • go build ./...
  • go vet ./internal/config/...
  • go test -race -short -count=1 ./internal/config/... (passes; database.go at 97.3% coverage)
  • golangci-lint run ./internal/config/... at v2.11.0 (skill-pinned) and v2.12.0 (CI-pinned): 0 issues

Plan reference

  • Plan: docs/plans/2026-05-26-database-composition-root.md (Step 1)
  • Spec: docs/specs/S04-database.md (Configuration section)

Merge request reports

Loading
Loading