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,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Persistence"
BuildableName = "Persistence"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PersistenceTests"
BuildableName = "PersistenceTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Persistence"
BuildableName = "Persistence"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+67
View File
@@ -0,0 +1,67 @@
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "Persistence",
platforms: [
.macOS(.v15),
],
products: [
.library(
name: "Persistence",
targets: [
"Persistence"
]
)
],
dependencies: [
.package(
url: "https://github.com/hummingbird-project/hummingbird-fluent.git",
from: "2.0.0"
),
.package(
url: "https://github.com/vapor/fluent-mysql-driver.git",
from: "4.8.0"
),
.package(
url: "https://github.com/vapor/fluent-sqlite-driver.git",
from: "4.9.0"
),
.package(
url: "https://github.com/vapor/sql-kit.git",
from: "3.36.0"
),
],
targets: [
.target(
name: "Persistence",
dependencies: [
.product(
name: "HummingbirdFluent",
package: "hummingbird-fluent"
),
.product(
name: "FluentMySQLDriver",
package: "fluent-mysql-driver"
),
.product(
name: "FluentSQLiteDriver",
package: "fluent-sqlite-driver"
),
.product(
name: "SQLKit",
package: "sql-kit"
),
],
path: "Sources"
),
.testTarget(
name: "PersistenceTests",
dependencies: [
.byName(name: "Persistence")
],
path: "Tests"
),
]
)
@@ -0,0 +1,28 @@
import FluentKit
/// Creates and drops the `example_records` table backing ``ExampleRecord``.
///
/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a
/// domain model is defined. Migrations are append-only in production add a new migration to alter the
/// schema rather than editing one that has already run.
struct CreateExampleRecord: AsyncMigration {
// MARK: Methods
/// Creates the `example_records` table with an `id` primary key and a required `name` column.
/// - Parameter database: the database the schema change is applied to.
func prepare(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.id()
.field("name", .string, .required)
.create()
}
/// Drops the `example_records` table, reverting ``prepare(on:)``.
/// - Parameter database: the database the schema change is applied to.
func revert(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.delete()
}
}
@@ -0,0 +1,45 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `example_records` row.
///
/// This is reference scaffolding: it demonstrates the model migration repository pattern the rest of
/// the package is built around, and is what the tests exercise. Replace it with the first real domain model
/// (paired with its own migration and repository) once one is defined.
///
/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never
/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the
/// conformance is declared `@unchecked Sendable`.
final class ExampleRecord: Model, @unchecked Sendable {
// MARK: Properties
/// The name of the backing table.
static let schema = "example_records"
/// The row's primary key, assigned on first save.
@ID(key: .id)
var id: UUID?
/// The row's name column.
@Field(key: "name")
var name: String
// MARK: Initializers
/// Creates an empty record, as required by FluentKit to hydrate query results.
init() {}
/// Creates a record with the given values.
/// - Parameters:
/// - id: the primary key, or `nil` to have one assigned on save.
/// - name: the value of the name column.
init(
id: UUID? = nil,
name: String
) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,63 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries.
///
/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference
/// types that must not escape the database's execution context.
public struct Example: Sendable, Equatable {
// MARK: Properties
/// The record's primary key, or `nil` if it has never been saved.
public let id: UUID?
/// The record's name.
public let name: String
}
/// Reads and writes ``ExampleRecord`` rows through the default database.
///
/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the
/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning so no
/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``;
/// replace it with the first real repository once a domain model is defined.
public struct ExampleRepository: Sendable {
// MARK: Properties
/// The service providing the default database the repository reads and writes through.
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(fluent: Fluent) {
self.fluent = fluent
}
// MARK: Methods
/// Inserts a record with the given name.
/// - Parameter name: the name of the record to insert.
/// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier.
public func create(name: String) async throws -> Example {
let record = ExampleRecord(name: name)
try await record.save(on: fluent.db())
return Example(id: record.id, name: record.name)
}
/// Fetches every record, ordered by name.
/// - Returns: a `Sendable` snapshot of each record, sorted by name.
public func all() async throws -> [Example] {
try await ExampleRecord.query(on: fluent.db())
.sort(\.$name)
.all()
.map { Example(id: $0.id, name: $0.name) }
}
}
@@ -0,0 +1,20 @@
/// The persistence backend the service runs against.
///
/// The executable picks a driver at startup and hands it to ``Service``, which registers the
/// matching database as the default one. Repositories resolve that default and stay agnostic
/// of which backend is in use.
public enum Driver: Sendable {
/// A MySQL/MariaDB server, reached with the given connection parameters.
///
/// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the
/// connection is opened with.
case mysql(Configuration)
/// An ephemeral, in-process SQLite database held entirely in memory.
///
/// Nothing is written to disk, and all data is lost when the service stops intended for
/// local development and tests.
case inMemory
}
@@ -0,0 +1,42 @@
import NIOSSL
/// The TLS posture used when connecting to the database.
///
/// The executable derives a posture from its `database.tls` configuration and passes it along as
/// part of ``Configuration``; the MySQL driver receives the resulting `TLSConfiguration` through
/// ``tlsConfiguration``.
public enum TLS: Sendable {
/// Connect without TLS, in plaintext.
case off
/// Connect over TLS when the server offers it, falling back to plaintext otherwise.
case prefer
/// Connect only over TLS, refusing the connection when the server offers none.
case require
}
// MARK: - Properties
extension TLS {
/// The NIO TLS configuration passed to the MySQL driver for this posture.
///
/// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for
/// ``prefer`` and ``require``.
///
/// - Note: `prefer` and `require` currently map to the same client configuration both enable TLS.
/// The distinction (fall back to plaintext vs. fail when the server offers no TLS) is not yet
/// enforced here; tighten this mapping if that guarantee becomes required.
var tlsConfiguration: TLSConfiguration? {
switch self {
case .off:
return nil
case .prefer, .require:
return .makeClientConfiguration()
}
}
}
@@ -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
}
}
@@ -0,0 +1,60 @@
/// The connection parameters for the MySQL/MariaDB backend.
///
/// The executable builds this from its `database.*` configuration; the package itself reads no
/// configuration, so these values arrive as plain data.
public struct Configuration: Sendable {
// MARK: Properties
/// The host the database server is reached at.
let host: String
/// The maximum number of pooled connections opened per event loop.
let maxConnectionsPerEventLoop: Int
/// The name of the database to open.
let name: String
/// The password the connection authenticates with.
let password: String
/// The port the database server listens on.
let port: Int
/// The TLS posture used when connecting.
let tls: TLS
/// The username the connection authenticates as.
let username: String
// MARK: Initializers
/// Creates a set of MySQL/MariaDB connection parameters.
/// - Parameters:
/// - host: the host the database server is reached at.
/// - port: the port the database server listens on.
/// - name: the name of the database to open.
/// - username: the username the connection authenticates as.
/// - password: the password the connection authenticates with.
/// - tls: the TLS posture used when connecting.
/// - maxConnectionsPerEventLoop: the maximum number of pooled connections opened per event loop.
public init(
host: String,
port: Int,
name: String,
username: String,
password: String,
tls: TLS,
maxConnectionsPerEventLoop: Int
) {
self.host = host
self.port = port
self.name = name
self.username = username
self.password = password
self.tls = tls
self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop
}
}
@@ -0,0 +1,28 @@
import NIOSSL
import Testing
@testable import Persistence
@Suite("TLS enumeration")
struct TLSTests {
// MARK: Properties tests
@Test
func `off has no TLS configuration`() {
#expect(TLS.off.tlsConfiguration == nil)
}
@Test(arguments: [
TLS.prefer,
TLS.require
])
func `maps to the default client configuration`(
for tls: TLS
) throws {
let configuration = try #require(tls.tlsConfiguration)
#expect(configuration.bestEffortEquals(.makeClientConfiguration()))
}
}
@@ -0,0 +1,77 @@
import FluentKit
import HummingbirdFluent
import Logging
import NIOCore
import Testing
@testable import Persistence
@Suite("Probe method")
struct ProbeTests {
// MARK: Methods tests
@Test
func `reports a reachable database`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(isReachable)
}
@Test
func `reports an unreachable database`() async throws {
// Port 1 on the loopback interface has nothing listening, so the connection is refused
// immediately instead of timing out.
let service = Service(
driver: .mysql(
.init(
host: "127.0.0.1",
port: 1,
name: "unreachable",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1
)
),
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(!isReachable)
}
@Test
func `reports a default database that is not an SQL database`() async throws {
let fluent = Fluent(logger: Logger(label: "test"))
fluent.databases.use(
.init(make: { NotSQLConfiguration() }),
as: .init(string: "not-sql"),
isDefault: true
)
let probe = Probe(fluent: fluent)
let isReachable = await probe()
try await fluent.shutdown()
#expect(!isReachable)
}
}
@@ -0,0 +1,165 @@
import Foundation
import Logging
import SQLKit
import Testing
@testable import Persistence
@Suite("Service method")
struct ServiceTests {
// MARK: Methods tests
@Test
func `registers an SQLite database as the default for the in-memory driver`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
let database = fluent.db() as? any SQLDatabase
try await fluent.shutdown()
let dialect = try #require(database?.dialect)
#expect(dialect.name == "sqlite")
}
@Test
func `registers a MySQL database as the default for the mysql driver`() async throws {
// Resolving the default database opens no connection pooling is lazy so no server
// needs to be listening on the configured host and port.
let service = Service(
driver: .mysql(
.init(
host: "127.0.0.1",
port: 3306,
name: "loud",
username: "loud",
password: "loud",
tls: .off,
maxConnectionsPerEventLoop: 1
)
),
logger: Logger(label: "test")
)
let fluent = service()
let database = fluent.db() as? any SQLDatabase
try await fluent.shutdown()
let dialect = try #require(database?.dialect)
#expect(dialect.name == "mysql")
}
@Test
func `builds a usable in-memory database`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
do {
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test("in-memory: migrate, insert, read back")
func inMemoryRoundTrip() async throws {
try await roundTrip(driver: .inMemory)
}
@Test(
"mysql: migrate, insert, read back",
.enabled(if: mysqlDriver != nil)
)
func mysqlRoundTrip() async throws {
try await roundTrip(
driver: mysqlDriver!,
revertAfter: true
)
}
}
// MARK: - Helpers
private extension ServiceTests {
/// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after.
///
/// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that,
/// the test must shut it down explicitly even on failure or the pool asserts on `deinit`.
/// - Parameters:
/// - driver: the persistence backend to exercise.
/// - revertAfter: whether to revert the migrations afterwards; set for a shared database (the
/// in-memory database is discarded on shutdown, so it needs no revert).
func roundTrip(
driver: Persistence.Driver,
revertAfter: Bool = false
) async throws {
let service = Service(
driver: driver,
logger: Logger(label: "test")
)
let fluent = service()
do {
await registerMigrations(fluent)
try await fluent.migrate()
let repository = ExampleRepository(fluent: fluent)
let created = try await repository.create(name: "loud")
#expect(try await repository.all().contains(created))
if revertAfter {
try await fluent.revert()
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
/// The MySQL/MariaDB driver built from the `MYSQL_TEST_*` environment variables, or `nil` when the gate
/// variable `MYSQL_TEST_HOST` is unset in which case the MySQL integration test is skipped, so the suite
/// stays runnable with no database available.
private let mysqlDriver: Persistence.Driver? = {
let environment = ProcessInfo.processInfo.environment
guard let host = environment["MYSQL_TEST_HOST"] else {
return nil
}
return .mysql(
.init(
host: host,
port: environment["MYSQL_TEST_PORT"].flatMap(Int.init) ?? 3306,
name: environment["MYSQL_TEST_NAME"] ?? "loud",
username: environment["MYSQL_TEST_USERNAME"] ?? "loud",
password: environment["MYSQL_TEST_PASSWORD"] ?? "loud",
tls: .off,
maxConnectionsPerEventLoop: 2
)
)
}()
@@ -0,0 +1,13 @@
import FluentKit
struct NotSQLConfiguration: DatabaseConfiguration {
var middleware: [any AnyModelMiddleware] = []
func makeDriver(
for databases: Databases
) -> any DatabaseDriver {
NotSQLDriver()
}
}
@@ -0,0 +1,44 @@
import FluentKit
/// A Fluent database that is not an `SQLDatabase`, so the probe's downcast fails.
///
/// Every query succeeds, proving the probe reports "not reachable" because of the failed downcast
/// rather than a failing backend.
struct NotSQLDatabase: Database {
let context: DatabaseContext
var inTransaction: Bool { false }
func execute(
query: DatabaseQuery,
onOutput: @escaping @Sendable (any DatabaseOutput) -> Void
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func execute(
schema: DatabaseSchema
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func execute(
enum: DatabaseEnum
) -> EventLoopFuture<Void> {
context.eventLoop.makeSucceededVoidFuture()
}
func transaction<T>(
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
) -> EventLoopFuture<T> {
closure(self)
}
func withConnection<T>(
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
) -> EventLoopFuture<T> {
closure(self)
}
}
@@ -0,0 +1,13 @@
import FluentKit
struct NotSQLDriver: DatabaseDriver {
func makeDatabase(
with context: DatabaseContext
) -> any Database {
NotSQLDatabase(context: context)
}
func shutdown() {}
}