feat(db): wire database tier into the composition root (S04 Step 3)

What

Step 3 of the database composition-root plan: wire the database tier into the running binary. The composition root now builds the app database client and the migration runner, runs migrations in the background, gates /-/readiness on them, removes the dead Step-1 runtime-DSN path, and aligns the S04 spec with the shipped behaviour.

Depends on

  • !360 (merged) (plan amendment) must merge first. It reconciles the Step 3 plan text with the design here (background migration, the defaultBootDeps seam, the client-returning tier orchestration, the file split). Merging this MR before !360 (merged) would put code on main that contradicts the plan on main.

Behaviour

  • Database tier wiring. assembleApp resolves dependencies through a constructor-injection seam (defaultBootDeps/bootOverride/withFatal) and delegates tier wiring to wireServices, which calls wireDatabase (database clients + readiness checks) then mounts the OCI tier on the returned *postgres.Client.
  • Background migrations, readiness-gated. The runner is wrapped in an asyncMigrationRunner whose Start returns immediately. /-/liveness stays up; /-/readiness reports the migrations check at 503 until the pass completes, then 200. A failed pass cancels the app context (context.WithCancelCause) and exits non-zero, so Kubernetes restarts and retries. Concurrent replicas serialise on goose's PostgreSQL session advisory lock; each gates its own readiness until it has confirmed the schema under the lock.
  • Missing database is fatal. No PostgreSQL stanza in the LabKit infrastructure config surfaces infrastructure.ErrNotConfigured, wrapped with the composition: prefix, and aborts boot (exit 1).
  • Dead-path removal. Deletes the Step-1 runtime DatabaseConfig/AssembleDSN path (internal/config/database.go, the Config.Database field, the proto database field — reserved 3/database, TLSConfig retained) now that connectivity comes from the infrastructure config. mountOCI is deleted; the client is built in the composition root and passed to wireOCI (resolves !155 (merged) note 3406432697).

Tests

Unit (go test -race -short) and integration (-tags=integration, testcontainers PostgreSQL) both pass locally; the full module -tags=integration -race suite is green, including the migration package's advisory-lock contention tests.

Step-3 acceptance coverage

Plan acceptance Tests
Readiness reports migrations + database keys at "ok" TestBuildApp_DatabaseReadinessKeysPresent (stub), TestLifecycle_DatabaseIntegration (live)
End-to-end: boots, migrations applied, /-/readiness 200, clean SIGTERM TestLifecycle_DatabaseIntegration
Existing lifecycle suite green via assembleAppForTest(stubbedDatabase()) runApp, TestBuildApp_HappyPath, TestBuildApp_MissingConfigFile, TestBuildApp_InvalidYAML, all TestLifecycle_*
Missing database fatal: ErrNotConfigured wrapped with composition prefix TestDefaultBootDeps_MissingDatabaseIsFatal, TestDefaultBootDeps_MissingDatabaseRunnerIsFatal
App client built in composition root, passed to wireOCI (note 3406432697) TestWireOCI_ProductionEntryPoint (signature pin); mountOCI deleted
Background runner: non-blocking Start, ready transition, fatal-on-failure, shutdown-not-fatal TestAsyncMigrationRunner_NonBlockingStartAndReadyTransition, ..._FailureInvokesOnFatal, ..._ShutdownCancelsWithoutFatal, ..._Name
Migration failure → non-zero exit classification TestRunOutcome

S04 spec acceptance (owned elsewhere, listed for completeness)

AC-2 (migrations apply / lock serialisation), AC-5 (HasPending) exercised end-to-end here via TestLifecycle_DatabaseIntegration; AC-3/4/6 (Down/Status/out-of-order) and AC-16/17 (layout, simple protocol) are owned by earlier S04 MRs and Steps 1–2. AC-13/14/15 (build-tag split, testcontainers provisioning, per-package isolation) satisfied by main_integration_test.go + the .gitlab-ci.yml test:integration go-list extension.

Out of scope / follow-up

  • Dependency-aware readiness for other dependencies (#58) — only this hookup's two checks (migrations, database) are gated here.
  • The migration client's postgres.WithConnectionPool(false) (session-pinned, PgBouncer-safe advisory lock) remains a pending LabKit TODO in datastore.NewMigrationClient.

🤖 Generated with Claude Code

Merge request reports

Loading
Loading