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
@@ -0,0 +1,29 @@
import HummingbirdFluent
/// A registrar declaring every migration against a `Fluent` service.
///
/// Built once around the application's `Fluent` service and called as a function `await migrate()`
/// during startup, before the migrations are applied.
public struct PrepareDB {
// MARK: Initializers
/// Creates a registrar for the migrations for a `Fluent` service.
public init() {}
// MARK: Methods
/// Registers every migration against the `Fluent` service, in order.
///
/// This is the single place migrations are declared: add each new migration here, in the order it must
/// run (migrations are applied in registration order and are append-only). Registering does not apply
/// them the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do that.
public func callAsFunction(
for fluent: Fluent
) async {
await fluent.migrations.add([
CreateExampleRecord()
])
}
}
@@ -0,0 +1,49 @@
import HummingbirdFluent
import SQLKit
/// A readiness probe reporting whether the database behind a `Fluent` service is reachable.
///
/// Built once around the application's `Fluent` service and called as a function whenever a fresh
/// answer is needed typically from a readiness endpoint: `let ready = await probe()`.
public struct Probe: Sendable {
// MARK: Properties
/// The `Fluent` service whose default database is probed.
private let fluent: Fluent
// MARK: Initializers
/// Creates a probe for the default database of the given `Fluent` service.
/// - Parameter fluent: the `Fluent` service whose default database is probed.
public init(
fluent: Fluent
) {
self.fluent = fluent
}
// MARK: Methods
/// Reports whether the database behind the `Fluent` service is reachable.
///
/// Runs a trivial `SELECT 1` against the default database the cheapest statement both the MySQL/MariaDB
/// and SQLite backends understand so a readiness check does not depend on any particular schema or model.
/// Any failure (connection refused, authentication error, pool exhausted) is reported as not reachable
/// rather than thrown, so callers can map it straight onto a readiness response. A default database that
/// is not an SQL database is likewise reported as not reachable.
/// - Returns: `true` when the database answers the probe, `false` otherwise.
public func callAsFunction() async -> Bool {
guard let database = fluent.db() as? any SQLDatabase else {
return false
}
do {
try await database.raw("SELECT 1").run()
return true
} catch {
return false
}
}
}
@@ -0,0 +1,78 @@
import FluentMySQLDriver
import FluentSQLiteDriver
import HummingbirdFluent
import Logging
/// A factory building the `Fluent` service the application persists through.
///
/// Built once around the driver the executable picks at startup and called as a function to produce
/// the configured service: `let fluent = service()`.
public struct Service: Sendable {
// MARK: Properties
/// The persistence backend to register.
private let driver: Driver
/// The logger the database emits through.
private let logger: Logger
// MARK: Initializers
/// Creates a factory for a `Fluent` service backed by the given driver.
/// - Parameters:
/// - driver: the persistence backend to register.
/// - logger: the logger the database emits through.
public init(
driver: Driver,
logger: Logger
) {
self.driver = driver
self.logger = logger
}
// MARK: Methods
/// Builds a `Fluent` service configured for the driver.
///
/// The selected backend is registered as the *default* database, so repositories resolve it with a plain
/// `fluent.db()` and stay agnostic of which driver is in use. The returned service is not yet running; add
/// it to the application's service group (`app.addServices(_:)`) so it starts and shuts its connection pool
/// down alongside the server.
/// - Returns: the configured `Fluent` service, ready to be added to the service group.
public func callAsFunction() -> Fluent {
let fluent = Fluent(
logger: logger
)
switch driver {
case .mysql(let configuration):
fluent.databases.use(
.mysql(
configuration: .init(
hostname: configuration.host,
port: configuration.port,
username: configuration.username,
password: configuration.password,
database: configuration.name,
tlsConfiguration: configuration.tls.tlsConfiguration
),
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop
),
as: .mysql,
isDefault: true
)
case .inMemory:
// A single connection keeps every query pointed at the same in-memory store,
// rather than each pooled connection getting its own private database.
fluent.databases.use(
.sqlite(.memory, maxConnectionsPerEventLoop: 1),
as: .sqlite,
isDefault: true
)
}
return fluent
}
}