Database setup for the Website service (#13)

This PR contains the work done to introduce a _Fluent_-based persistence layer for the Website service, selectable at runtime alongside the existing in-memory default, plus the local dev tooling and docs to support it.

To provide further details about the work:

* Persistence package
  * The `Driver` and `TLS` enumerations
  * The `Configuration` type
  * The `Service` factory that builds the  service
  * `PrepareDB` for migrations registration
  * The `Probe` for readiness checks.

* App integration
  *  Builds the driver, registers migrations, and attaches `Fluent` to the service lifecycle so it starts/stops with the HTTP server.
  * Migrate-on-boot is gated to the in-memory backend; MySQL/MariaDB is migrated out of band via --database-migrate so shared databases never race on startup.
  * The `ConfigReader+Properties` extension maps database.* config keys onto the driver.

* Library
  * Added database configuration constants.
  * The `HealthController` controller gains a readiness probe: `GET /health/ready` checks whether the database is reachable, separate from the existing liveness check.

* Others
  * Updated the `docker-compose` files to support a database service behind a database profile, and hardened for local development
  * New database targets on the `Makefile` file and overall documentation updated
  * Updated the `.env.local`, `Dockerfile`, and `README` files to document the persistence workflow, config keys, and local DB commands

Reviewed-on: rock-n-code/loud-amsterdam#13
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-07-11 09:15:58 +00:00
committed by javier
parent 9774454bba
commit dc6b22e648
45 changed files with 1791 additions and 254 deletions
@@ -1,36 +1,45 @@
import Hummingbird
import NIOCore
import Persistence
/// Serves the website's health-check route.
/// Serves the website's health-check routes.
///
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
/// (or a sub-group) by the application that composes it:
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
///
/// ```swift
/// router.addRoutes(HealthController<AppRequestContext>().routes)
/// router.addRoutes(HealthController<AppRequestContext>(probe: probe).routes)
/// ```
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the
/// context of the router the routes are added to.
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
/// whether the service's database is reachable. The two are kept distinct so an orchestrator can restart on liveness failure but only withhold traffic on
/// readiness failure.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct HealthController<Context: RequestContext>: Sendable {
// MARK: Properties
/// The JSON payload returned for every health check.
private let payload: String
/// The probe consulted for the readiness check, or `nil` when only liveness is served.
private let probe: Probe?
// MARK: Initializers
/// Creates a health controller.
public init() {
self.payload = #"{"status":"ok"}"#
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
/// route is served.
public init(
probe: Probe? = nil
) {
self.probe = probe
}
// MARK: Computed
/// The routes served by the controller.
///
/// Serves a `GET` request for the health path (`/health`) with a static JSON status payload.
/// Serves a `GET` request for the liveness path (`/health`) with a static JSON status payload, and
/// when a `Probe` was supplied a `GET` request for the readiness path (`/health/ready`)
/// that consults the probe.
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
@@ -39,6 +48,13 @@ public struct HealthController<Context: RequestContext>: Sendable {
use: check
)
if probe != nil {
routes.get(
.Health.ready,
use: ready
)
}
return routes
}
@@ -50,10 +66,12 @@ private extension HealthController {
// MARK: Methods
/// Handles a request for the health check.
/// Handles a request for the liveness check.
///
/// Returns a constant JSON body built directly per request the payload is a tiny literal with no
/// rendering step, so there is nothing to pre-render or cache.
/// rendering step, so there is nothing to pre-render or cache. It reports only that the process is up,
/// with no dependency check, so an orchestrator restarts the process only when the process itself is
/// unresponsive.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
@@ -63,8 +81,50 @@ private extension HealthController {
request: Request,
context: some RequestContext
) -> Response {
Response(
json(
status: .ok,
payload: .Payload.live
)
}
/// Handles a request for the readiness check.
///
/// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's
/// database is reachable, or `503 Service Unavailable` otherwise, so a load balancer withholds
/// traffic from an instance that cannot yet serve it without restarting the process.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: a `200 OK` response when ready, or `503 Service Unavailable` when not.
@Sendable
func ready(
request: Request,
context: some RequestContext
) async -> Response {
guard await probe?() == true else {
return json(
status: .serviceUnavailable,
payload: .Payload.unavailable
)
}
return json(
status: .ok,
payload: .Payload.ready
)
}
/// Builds a JSON response carrying the given status and payload.
/// - Parameters:
/// - status: the HTTP status of the response.
/// - payload: the JSON body of the response.
/// - Returns: the configured JSON response.
func json(
status: HTTPResponse.Status,
payload: String
) -> Response {
Response(
status: status,
headers: [.contentType: "application/json"],
body: .init(byteBuffer: .init(string: payload))
)
@@ -72,12 +132,24 @@ private extension HealthController {
}
// MARK: - Constants
// MARK: - RouterPath+Constants
private extension RouterPath {
/// A namespace for the ``HealthController`` route paths.
enum Health {
/// The path of the health-check endpoint.
/// The path of the liveness endpoint.
static let check: RouterPath = "/health"
/// The path of the readiness endpoint.
static let ready: RouterPath = "/health/ready"
}
}
// MARK: - String+Constants
private extension String {
enum Payload {
static let live = #"{"status":"ok"}"#
static let ready = #"{"status":"ready"}"#
static let unavailable = #"{"status":"unavailable"}"#
}
}