diff --git a/.gitignore b/.gitignore
index e33ea89..6ca7d7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,9 @@ playground.xcworkspace
.env
!.env.local
+## Local MariaDB data files
+Services/Website/Tests/DB/
+
# Fastlane
fastlane/report.xml
fastlane/Preview.html
diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift
index 99625f3..8778c92 100644
--- a/Packages/Localization/Package.swift
+++ b/Packages/Localization/Package.swift
@@ -7,8 +7,6 @@ let package = Package(
defaultLocalization: "en",
platforms: [
.macOS(.v15),
- .iOS(.v18),
- .tvOS(.v18),
],
products: [
.library(
diff --git a/Packages/Localization/Sources/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift
similarity index 100%
rename from Packages/Localization/Sources/Protocols/CatalogResolving.swift
rename to Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift
diff --git a/Packages/Localization/Sources/Types/StringCatalog.swift b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift
similarity index 100%
rename from Packages/Localization/Sources/Types/StringCatalog.swift
rename to Packages/Localization/Sources/Internal/Types/StringCatalog.swift
diff --git a/Packages/Localization/Sources/Methods/Localize.swift b/Packages/Localization/Sources/Public/Methods/Localize.swift
similarity index 100%
rename from Packages/Localization/Sources/Methods/Localize.swift
rename to Packages/Localization/Sources/Public/Methods/Localize.swift
diff --git a/Packages/Localization/Sources/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift
similarity index 100%
rename from Packages/Localization/Sources/Methods/Negotiate.swift
rename to Packages/Localization/Sources/Public/Methods/Negotiate.swift
diff --git a/Packages/Localization/Sources/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift
similarity index 100%
rename from Packages/Localization/Sources/Types/LanguageList.swift
rename to Packages/Localization/Sources/Public/Types/LanguageList.swift
diff --git a/Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift
similarity index 100%
rename from Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift
rename to Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift
diff --git a/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift
similarity index 100%
rename from Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift
rename to Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift
diff --git a/Packages/Localization/Tests/Cases/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift
similarity index 100%
rename from Packages/Localization/Tests/Cases/Types/LanguageListTests.swift
rename to Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift
diff --git a/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme b/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme
new file mode 100644
index 0000000..1bd174b
--- /dev/null
+++ b/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift
new file mode 100644
index 0000000..ca63107
--- /dev/null
+++ b/Packages/Persistence/Package.swift
@@ -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"
+ ),
+ ]
+)
diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift
new file mode 100644
index 0000000..ef06d99
--- /dev/null
+++ b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift
@@ -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()
+ }
+
+}
diff --git a/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift
new file mode 100644
index 0000000..350a8b1
--- /dev/null
+++ b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift
@@ -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
+ }
+
+}
diff --git a/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift
new file mode 100644
index 0000000..081dea1
--- /dev/null
+++ b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift
@@ -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) }
+ }
+
+}
diff --git a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
new file mode 100644
index 0000000..c52b9ef
--- /dev/null
+++ b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
@@ -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
+
+}
diff --git a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
new file mode 100644
index 0000000..1efaa16
--- /dev/null
+++ b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
@@ -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()
+ }
+ }
+
+}
diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift
new file mode 100644
index 0000000..43283b3
--- /dev/null
+++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift
@@ -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()
+ ])
+ }
+
+}
diff --git a/Packages/Persistence/Sources/Public/Methods/Probe.swift b/Packages/Persistence/Sources/Public/Methods/Probe.swift
new file mode 100644
index 0000000..cdbda36
--- /dev/null
+++ b/Packages/Persistence/Sources/Public/Methods/Probe.swift
@@ -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
+ }
+
+ }
+}
diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift
new file mode 100644
index 0000000..033adcf
--- /dev/null
+++ b/Packages/Persistence/Sources/Public/Methods/Service.swift
@@ -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
+ }
+
+}
diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift
new file mode 100644
index 0000000..a60408e
--- /dev/null
+++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift
@@ -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
+ }
+
+}
+
diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
new file mode 100644
index 0000000..aa12a00
--- /dev/null
+++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
@@ -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()))
+ }
+
+}
diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
new file mode 100644
index 0000000..92ff75e
--- /dev/null
+++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
@@ -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)
+ }
+
+}
diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
new file mode 100644
index 0000000..8066f4d
--- /dev/null
+++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
@@ -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
+ )
+ )
+}()
diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift
new file mode 100644
index 0000000..ed276e5
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift
@@ -0,0 +1,13 @@
+import FluentKit
+
+struct NotSQLConfiguration: DatabaseConfiguration {
+
+ var middleware: [any AnyModelMiddleware] = []
+
+ func makeDriver(
+ for databases: Databases
+ ) -> any DatabaseDriver {
+ NotSQLDriver()
+ }
+
+}
diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift
new file mode 100644
index 0000000..a612808
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift
@@ -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 {
+ context.eventLoop.makeSucceededVoidFuture()
+ }
+
+ func execute(
+ schema: DatabaseSchema
+ ) -> EventLoopFuture {
+ context.eventLoop.makeSucceededVoidFuture()
+ }
+
+ func execute(
+ enum: DatabaseEnum
+ ) -> EventLoopFuture {
+ context.eventLoop.makeSucceededVoidFuture()
+ }
+
+ func transaction(
+ _ closure: @escaping @Sendable (any Database) -> EventLoopFuture
+ ) -> EventLoopFuture {
+ closure(self)
+ }
+
+ func withConnection(
+ _ closure: @escaping @Sendable (any Database) -> EventLoopFuture
+ ) -> EventLoopFuture {
+ closure(self)
+ }
+
+}
diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift
new file mode 100644
index 0000000..82b8ff4
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift
@@ -0,0 +1,13 @@
+import FluentKit
+
+struct NotSQLDriver: DatabaseDriver {
+
+ func makeDatabase(
+ with context: DatabaseContext
+ ) -> any Database {
+ NotSQLDatabase(context: context)
+ }
+
+ func shutdown() {}
+
+}
diff --git a/Services/Website/.env.local b/Services/Website/.env.local
index a3d7faa..1987239 100644
--- a/Services/Website/.env.local
+++ b/Services/Website/.env.local
@@ -40,3 +40,28 @@ HTTP_SERVER_NAME=LoudWebsite
# Log verbosity: trace | debug | info | notice | warning | error | critical
LOG_LEVEL=info
+
+# --- Persistence ----------------------------------------------------------------
+
+# Persistence driver: inMemory (default, no infrastructure) or mysql.
+DATABASE_DRIVER=inMemory
+
+# MySQL/MariaDB connection, used when DATABASE_DRIVER=mysql.
+# `mariadb` is the local database's Compose service name; use 127.0.0.1 when
+# running the app directly with `swift run`.
+DATABASE_HOST=localhost
+
+# Port of the database to connect to.
+DATABASE_PORT=3306
+
+# Name of the database to connect to.
+DATABASE_NAME=loud-ams
+
+# Username of the database to connect as.
+DATABASE_USERNAME=loud-ams
+
+# Provide the real password via the environment or a secret — never commit it.
+DATABASE_PASSWORD=loud-ams
+
+# TLS posture when connecting: off | prefer | require (use `require` in production).
+DATABASE_TLS=off
diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile
index db51f45..ec87365 100644
--- a/Services/Website/Dockerfile
+++ b/Services/Website/Dockerfile
@@ -18,6 +18,7 @@ WORKDIR /build
# not change. The Website package depends on the local Localization package via
# a relative path, so its manifest must be present for resolution to succeed.
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
+COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
RUN swift package --package-path ./Services/Website resolve
diff --git a/Services/Website/Makefile b/Services/Website/Makefile
index a086c67..47d91c8 100644
--- a/Services/Website/Makefile
+++ b/Services/Website/Makefile
@@ -38,36 +38,84 @@ pkg-clean: ## Remove the Swift build artifacts
@swift package clean
.PHONY: pkg-reset
-pkg-reset: ## Resets the complete SPM cache/build folder
+pkg-reset: ## Reset the SPM cache and build folder
@swift package reset
.PHONY: pkg-outdated
-pkg-outdated: ## Lists the SPM package dependencies that can be updated
+pkg-outdated: ## List the SPM dependencies that can be updated
@swift package update --dry-run
.PHONY: pkg-update
-pkg-update: ## Updates the SPM package dependencies
+pkg-update: ## Update the SPM dependencies
@swift package update
# --- Local development --------------------------------------------------------
-.PHONY: img-build
-img-build: ## Build the local dev image
- @docker compose build
+.PHONY: site-run
+site-run: ## Run the website locally, rebuilding on source changes
+ @hb watch
-.PHONY: img-mount
-img-mount: ## Mount the service locally (build if needed)
- @docker compose up --build --detach
+.PHONY: site-mount
+site-mount: ## Mount the website locally
+ @docker compose up \
+ --build \
+ --detach
-.PHONY: img-unmount
-img-unmount: ## Unmount and remove the local service
+.PHONY: site-unmount
+site-unmount: ## Unmount and remove the local website
@docker compose down
@$(MAKE) img-remove
+# --- Local database -----------------------------------------------------------
+
+.PHONY: db-mount
+db-mount: ## Start the local database instance
+ @docker compose \
+ --profile database up \
+ --detach \
+ --wait mariadb
+
+.PHONY: db-migrate
+db-migrate: ## Run the migrations against the local database instance
+ @DATABASE_DRIVER=mysql \
+ DATABASE_HOST=127.0.0.1 \
+ DATABASE_TLS=off \
+ swift run \
+ Website \
+ --database-migrate
+
+.PHONY: db-shell
+db-shell: ## Open a SQL shell on the local database instance
+ @docker compose \
+ --profile database \
+ exec mariadb \
+ mariadb \
+ --user=$(or $(DATABASE_USERNAME),loud) \
+ --password=$(or $(DATABASE_PASSWORD),loud) \
+ $(or $(DATABASE_NAME),loud)
+
+.PHONY: db-unmount
+db-unmount: ## Stop and remove the local database instance (keeps the data volume)
+ @docker compose \
+ --profile database down mariadb
+
+.PHONY: db-reset
+db-reset: ## Stop and remove the local database instance and delete its data volume
+ @docker compose \
+ --profile database down mariadb \
+ --volumes
+
# --- Registry deployment ------------------------------------------------------
+.PHONY: img-check
+img-check: ## Check the production image builds
+ @docker build \
+ --platform linux/amd64 \
+ --file Dockerfile \
+ ../..
+
.PHONY: img-release
-img-release: ## Build the production (amd64) image, tag with version + latest, push both
+img-release: ## Build and push the production image into the container registry
@if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
exit 1; \
diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift
index b8e1f07..5f7eeea 100644
--- a/Services/Website/Package.swift
+++ b/Services/Website/Package.swift
@@ -23,6 +23,9 @@ let package = Package(
.package(
path: "../../Packages/Localization"
),
+ .package(
+ path: "../../Packages/Persistence"
+ ),
.package(
url: "https://github.com/elementary-swift/elementary.git",
from: "0.6.0"
@@ -52,6 +55,7 @@ let package = Package(
.executableTarget(
name: "Website",
dependencies: [
+ .byName(name: "Persistence"),
.byName(name: "WebsiteCore"),
.product(
name: "Configuration",
@@ -72,6 +76,7 @@ let package = Package(
name: "WebsiteCore",
dependencies: [
.byName(name: "Localization"),
+ .byName(name: "Persistence"),
.product(
name: "Configuration",
package: "swift-configuration"
diff --git a/Services/Website/README.md b/Services/Website/README.md
index 614ab5e..5bccb5c 100644
--- a/Services/Website/README.md
+++ b/Services/Website/README.md
@@ -5,24 +5,30 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteCore` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
-- Answers health checks at `GET /health` with a static JSON payload.
+- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
+- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Docker (optional) for the containerized run/deploy workflow.
+- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
## Architecture
Two SwiftPM targets:
| Target | Kind | Path | Role |
| --- | --- | --- | --- |
-| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds and runs the application. |
+| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
| `WebsiteCore` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
-`WebsiteCore` also depends on the local `Localization` package (`Packages/Localization`), which provides the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages.
+The `Website` executable depends on two local packages:
+- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteCore`).
+- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
+
+The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
```
@@ -33,7 +39,7 @@ LogRequestsMiddleware
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
-HealthController (GET /health → health check)
+HealthController (GET /health → liveness, GET /health/ready → readiness)
```
## Configuration
@@ -74,6 +80,21 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
| --- | --- | --- | --- |
| `log.level` | `LOG_LEVEL` | `info` | Minimum log level. |
+### Persistence
+| Config key | Environment variable | Default | Description |
+| --- | --- | --- | --- |
+| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `mysql` (MySQL/MariaDB). |
+| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
+| `database.host` | `DATABASE_HOST` | `localhost` | MySQL/MariaDB host. Ignored for `inMemory`. |
+| `database.port` | `DATABASE_PORT` | `3306` | MySQL/MariaDB port. Ignored for `inMemory`. |
+| `database.name` | `DATABASE_NAME` | `loud` | Database name. Ignored for `inMemory`. |
+| `database.username` | `DATABASE_USERNAME` | `loud` | Database username. Ignored for `inMemory`. |
+| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
+| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
+| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
+
+See [Persistence](#persistence-1) below for the workflow.
+
### Paths
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
@@ -101,12 +122,42 @@ swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
```sh
make pkg-build # swift build
-make img-mount # docker compose up --build --detach
-make img-unmount # docker compose down + remove the local image
+make site-run # run locally with hot reload (hb watch)
+make site-mount # docker compose up --build --detach
+make site-unmount # docker compose down + remove the local image
```
`make help` lists every available target.
+## Persistence
+The service persists data through Fluent and selects its backend at runtime with `database.driver`.
+
+### In-memory (default)
+With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
+
+### MySQL / MariaDB
+Set `DATABASE_DRIVER=mysql` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, a MySQL/MariaDB database is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
+```sh
+# Run the registered migrations against the configured database, then exit.
+swift run Website --database-migrate
+# In a container (production), against the managed database:
+docker compose -f docker-compose.yml run --rm website --database-migrate
+```
+
+A local MariaDB for development lives behind the `database` Compose profile (so a plain `docker compose up` still runs in-memory). Its data directory is bind-mounted to `Tests/DB` (git-ignored), so the database survives `db-unmount` and container restarts:
+```sh
+make db-mount # start MariaDB (docker compose --profile database up --wait mariadb)
+make db-migrate # run migrations against it
+make db-shell # open a SQL shell on it
+make db-unmount # stop and remove the container (data kept in Tests/DB)
+make db-reset # stop and remove the container
+DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
+```
+> **Note:** because the data lives in the bind-mounted `Tests/DB` folder rather than a named volume, `db-reset`'s `--volumes` flag does **not** clear it. To start from an empty database, delete `Tests/DB` by hand.
+
+### Health checks
+`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
+
## Testing
```sh
make pkg-test
@@ -115,8 +166,21 @@ make pkg-test
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteCoreTests` (the library unit tests).
+The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
+```sh
+cd ../../Packages/Persistence && swift test # in-memory only
+# With the local MariaDB up (make db-mount):
+cd ../../Packages/Persistence && MYSQL_TEST_HOST=127.0.0.1 swift test
+```
+
## Deployment
The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
+
+Verify the image builds for its `linux/amd64` target without tagging or publishing:
+```sh
+make img-check
+```
+
Build, tag, and push a release to the registry (an explicit version is required):
```sh
make img-release version=1.2.3
@@ -141,3 +205,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
| `LOG_LEVEL` | Runtime log level (default `info`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). |
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
+| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. |
+| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
+| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `require` in production). |
+
+Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift
deleted file mode 100644
index 3898109..0000000
--- a/Services/Website/Sources/App/App+build.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-import Configuration
-import Hummingbird
-import HummingbirdCompression
-import Logging
-import WebsiteCore
-
-/// Builds the website application.
-///
-/// Reads the log level, server name, static files location, minimum response size to compress, and
-/// security headers from the configuration, then assembles the router, server configuration, and logger.
-/// - Parameter reader: the configuration reader the values are read from.
-/// - Returns: the configured application, ready to run as a service.
-func application(
- reader: ConfigReader
-) async -> some ApplicationProtocol {
- let cacheControl = cacheControl(
- textMaxAge: reader.int(
- forKey: .Cache.maxAgeText,
- default: .Cache.maxAgeText
- ),
- imageMaxAge: reader.int(
- forKey: .Cache.maxAgeImage,
- default: .Cache.maxAgeImage
- ),
- defaultMaxAge: reader.int(
- forKey: .Cache.maxAgeDefault,
- default: .Cache.maxAgeDefault
- )
- )
- let compressionMinResponseSize = reader.int(
- forKey: .Compression.minResponseSize,
- default: .Compression.minResponseSize
- )
- let logLevel = reader.string(
- forKey: .Log.level,
- as: Logger.Level.self,
- default: .info
- )
- let serverName = reader.string(
- forKey: .HTTP.serverName,
- default: .Server.name
- )
- let staticFilesPath = reader.string(
- forKey: .Path.staticFiles,
- default: .Path.staticResources
- )
- let securityHeaders = securityHeaders(
- reader: reader
- )
-
- return Application(
- router: router(
- staticFilesPath: staticFilesPath,
- cacheControl: cacheControl,
- compressionMinResponseSize: compressionMinResponseSize,
- securityHeaders: securityHeaders,
- logLevel: logLevel
- ),
- configuration: ApplicationConfiguration(
- reader: reader.scoped(to: "http")
- ),
- logger: logger(
- serverName: serverName,
- logLevel: logLevel
- )
- )
-}
-
-// MARK: - Helpers
-
-// Request context used by application
-private typealias AppRequestContext = WebsiteRequestContext
-
-/// Builds the cache-control policy applied to the served static files.
-///
-/// Static files are public and validated by `FileMiddleware` through their `ETag` and
-/// `Last-Modified` headers, so each media type is given a `max-age` after which the browser
-/// revalidates. Text-based assets (CSS, JavaScript) are additionally marked `must-revalidate`
-/// since they change between deployments while keeping their filenames.
-/// - Parameters:
-/// - textMaxAge: the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text).
-/// - imageMaxAge: the max-age, in seconds, applied to image static files (ICO, PNG, SVG).
-/// - defaultMaxAge: the max-age, in seconds, applied to all other static files (e.g. the web manifest).
-/// - Returns: the configured cache-control policy.
-private func cacheControl(
- textMaxAge: Int,
- imageMaxAge: Int,
- defaultMaxAge: Int
-) -> CacheControl {
- .init([
- (.text, [.public, .maxAge(textMaxAge), .mustRevalidate]),
- (.image, [.public, .maxAge(imageMaxAge)]),
- (.init(type: .any), [.public, .maxAge(defaultMaxAge)]),
- ])
-}
-
-/// Builds the security-headers configuration applied to every response.
-///
-/// Each header value falls back to the hardened default in `String.Security` when the matching
-/// configuration key is unset. `Strict-Transport-Security` has no default: it is read as an optional
-/// and omitted entirely unless explicitly configured, so it stays off in plain-HTTP development and
-/// is enabled only behind TLS in production.
-/// - Parameter reader: the configuration reader the header values are read from.
-/// - Returns: the configured security-headers configuration.
-private func securityHeaders(
- reader: ConfigReader
-) -> SecurityHeadersMiddleware.Configuration {
- .init(
- contentSecurityPolicy: reader.string(
- forKey: .Security.contentSecurityPolicy,
- default: .Security.contentSecurityPolicy
- ),
- contentTypeOptions: reader.string(
- forKey: .Security.contentTypeOptions,
- default: .Security.contentTypeOptions
- ),
- frameOptions: reader.string(
- forKey: .Security.frameOptions,
- default: .Security.frameOptions
- ),
- referrerPolicy: reader.string(
- forKey: .Security.referrerPolicy,
- default: .Security.referrerPolicy
- ),
- permissionsPolicy: reader.string(
- forKey: .Security.permissionsPolicy,
- default: .Security.permissionsPolicy
- ),
- strictTransportSecurity: reader.string(
- forKey: .Security.strictTransportSecurity
- )
- )
-}
-
-/// Builds the application's logger.
-/// - Parameters:
-/// - serverName: the label applied to the logger.
-/// - logLevel: the minimum level the logger emits.
-/// - Returns: the configured logger.
-private func logger(
- serverName: String,
- logLevel: Logger.Level
-) -> Logger {
- var logger = Logger(label: serverName)
-
- logger.logLevel = logLevel
-
- return logger
-}
-
-/// Builds the application's router.
-///
-/// Registers the request-logging middleware, the security-headers middleware that stamps the given
-/// `securityHeaders` onto every response, the response-compression middleware that compresses
-/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the
-/// localization middleware that negotiates the request's language from its `Accept-Language` header,
-/// the not-found middleware that serves the error page, and the static file middleware that serves the
-/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then
-/// adds the `RootController` routes that render the landing page and the `HealthController` routes
-/// that serve the health check.
-///
-/// The security-headers middleware sits just inside request logging so it covers every response that
-/// reaches a client — the landing page, the compressed responses, the rendered error page, and the
-/// served static files.
-/// - Parameters:
-/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
-/// - cacheControl: the cache-control directives applied to the served static files.
-/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
-/// - securityHeaders: the security headers applied to every response.
-/// - logLevel: the level the request-logging middleware logs at.
-/// - Returns: the configured router.
-private func router(
- staticFilesPath: String,
- cacheControl: CacheControl,
- compressionMinResponseSize: Int,
- securityHeaders: SecurityHeadersMiddleware.Configuration,
- logLevel: Logger.Level
-) -> Router {
- let router = Router(context: AppRequestContext.self)
-
- router.addMiddleware {
- LogRequestsMiddleware(logLevel)
- SecurityHeadersMiddleware(
- configuration: securityHeaders
- )
- ResponseCompressionMiddleware(
- minimumResponseSizeToCompress: compressionMinResponseSize
- )
- LocalizationMiddleware()
- NotFoundMiddleware()
- FileMiddleware(
- staticFilesPath,
- cacheControl: cacheControl
- )
- }
-
- router.addRoutes {
- RootController().routes
- HealthController().routes
- }
-
- return router
-}
diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift
index cf3e8bf..4736863 100644
--- a/Services/Website/Sources/App/App.swift
+++ b/Services/Website/Sources/App/App.swift
@@ -2,8 +2,17 @@ import Configuration
import Hummingbird
import Logging
+/// The entry point of the website executable.
+///
+/// Loads the configuration, then either serves the website or — when the `database.migrate` flag is set — runs the registered migrations against the
+/// configured backend and exits.
@main
struct App {
+
+ /// Loads the configuration and runs the mode it selects.
+ ///
+ /// The configuration is read from the providers in precedence order: command-line arguments first, then process environment variables, then a
+ /// `.env` file when one is present, and finally the in-memory defaults (currently just the server name).
static func main() async throws {
let reader = try await ConfigReader(
providers: [
@@ -19,10 +28,22 @@ struct App {
]
)
+ // Migrate-and-exit mode runs the registered migrations against the configured backend and returns,
+ // so a shared database is migrated by a single deliberate invocation (`--database-migrate`) rather
+ // than by every booting instance.
+ guard !reader.migrate else {
+ try await migration(
+ reader: reader
+ )
+
+ return
+ }
+
let app = await application(
reader: reader
)
try await app.runService()
}
+
}
diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift
new file mode 100644
index 0000000..06a167b
--- /dev/null
+++ b/Services/Website/Sources/App/Extensions/App+Build.swift
@@ -0,0 +1,166 @@
+import Configuration
+import Hummingbird
+import HummingbirdCompression
+import Logging
+import Persistence
+import WebsiteCore
+
+/// Builds the website application.
+///
+/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
+/// the router, server configuration, and logger. It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
+/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of
+/// band (so a shared database is never migrated on boot).
+/// - Parameter reader: the configuration reader the values are read from.
+/// - Returns: the configured application, ready to run as a service.
+func application(
+ reader: ConfigReader
+) async -> some ApplicationProtocol {
+ let logger = logger(
+ serverName: reader.serverName,
+ logLevel: reader.logLevel
+ )
+ let persistence = Service(
+ driver: reader.driver,
+ logger: logger
+ )
+ let fluent = persistence()
+ let prepareDB = PrepareDB()
+
+ await prepareDB(for: fluent)
+
+ var app = Application(
+ router: router(
+ staticFilesPath: reader.staticFilesPath,
+ cacheControl: reader.cacheControl,
+ compressionMinResponseSize: reader.compressionMinResponseSize,
+ securityHeaders: reader.securityHeaders,
+ logLevel: reader.logLevel,
+ probe: Probe(fluent: fluent)
+ ),
+ configuration: ApplicationConfiguration(
+ reader: reader.scoped(to: "http")
+ ),
+ logger: logger
+ )
+
+ app.addServices(fluent)
+
+ // The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB
+ // backend is left untouched here: a shared database is migrated out of band to avoid multi-instance races.
+ if case .inMemory = reader.driver {
+ app.beforeServerStarts {
+ try await fluent.migrate()
+ }
+ }
+
+ return app
+}
+
+/// Runs every registered migration against the configured backend, then exits.
+///
+/// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the
+/// migrations, and shuts the database down — so a shared MySQL/MariaDB database is migrated by a single deliberate invocation rather than by every
+/// booting instance.
+/// - Parameter reader: the configuration reader the values are read from.
+func migration(
+ reader: ConfigReader
+) async throws {
+ let logger = logger(
+ serverName: reader.serverName,
+ logLevel: reader.logLevel
+ )
+ let service = Service(
+ driver: reader.driver,
+ logger: logger
+ )
+
+ let fluent = service()
+ let prepareDB = PrepareDB()
+
+ await prepareDB(for: fluent)
+
+ do {
+ try await fluent.migrate()
+ }
+ catch {
+ try? await fluent.shutdown()
+
+ throw error
+ }
+
+ try await fluent.shutdown()
+}
+
+// MARK: - Helpers
+
+/// The request context type the application serves its routes with.
+private typealias AppRequestContext = WebsiteRequestContext
+
+/// Builds the application's logger.
+/// - Parameters:
+/// - serverName: the label applied to the logger.
+/// - logLevel: the minimum level the logger emits.
+/// - Returns: the configured logger.
+private func logger(
+ serverName: String,
+ logLevel: Logger.Level
+) -> Logger {
+ var logger = Logger(label: serverName)
+
+ logger.logLevel = logLevel
+
+ return logger
+}
+
+/// Builds the application's router.
+///
+/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
+/// response-compression middleware that compresses responses larger than `minimumResponseSizeToCompress` when the client advertises support,
+/// the localization middleware that negotiates the request's language from its `Accept-Language` header, the not-found middleware that serves the
+/// error page, and the static file middleware that serves the contents of `staticFilesPath` (tagging responses with the given `cacheControl`
+/// directives), then adds the `RootController` routes that render the landing page and the `HealthController` routes that serve the health check.
+///
+/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client — the landing page, the compressed
+/// responses, the rendered error page, and the served static files.
+/// - Parameters:
+/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
+/// - cacheControl: the cache-control directives applied to the served static files.
+/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
+/// - securityHeaders: the security headers applied to every response.
+/// - logLevel: the level the request-logging middleware logs at.
+/// - probe: the probe consulted by the `HealthController` readiness route.
+/// - Returns: the configured router.
+private func router(
+ staticFilesPath: String,
+ cacheControl: CacheControl,
+ compressionMinResponseSize: Int,
+ securityHeaders: SecurityHeadersMiddleware.Configuration,
+ logLevel: Logger.Level,
+ probe: Probe
+) -> Router {
+ let router = Router(context: AppRequestContext.self)
+
+ router.addMiddleware {
+ LogRequestsMiddleware(logLevel)
+ SecurityHeadersMiddleware(
+ configuration: securityHeaders
+ )
+ ResponseCompressionMiddleware(
+ minimumResponseSizeToCompress: compressionMinResponseSize
+ )
+ LocalizationMiddleware()
+ NotFoundMiddleware()
+ FileMiddleware(
+ staticFilesPath,
+ cacheControl: cacheControl
+ )
+ }
+
+ router.addRoutes {
+ RootController().routes
+ HealthController(probe: probe).routes
+ }
+
+ return router
+}
diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
new file mode 100644
index 0000000..0ddb7a8
--- /dev/null
+++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
@@ -0,0 +1,182 @@
+import Configuration
+import Hummingbird
+import Logging
+import Persistence
+import WebsiteCore
+
+package extension ConfigReader {
+
+ // MARK: Type aliases
+
+ /// The request context type the application serves its routes with; the security headers configuration is generic over it.
+ typealias AppRequestContext = WebsiteRequestContext
+
+ // MARK: Computed
+
+ /// The `Cache-Control` policy applied to static files, grouped by media type.
+ ///
+ /// The max-ages are read from the `cache.maxAge.text`, `cache.maxAge.image`, and `cache.maxAge.default` keys. Text files (CSS,
+ /// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone.
+ var cacheControl: CacheControl {
+ let maxAgeDefault = int(
+ forKey: .Cache.maxAgeDefault,
+ default: .Cache.maxAgeDefault
+ )
+ let maxAgeImage = int(
+ forKey: .Cache.maxAgeImage,
+ default: .Cache.maxAgeImage
+ )
+ let maxAgeText = int(
+ forKey: .Cache.maxAgeText,
+ default: .Cache.maxAgeText
+ )
+
+ return .init([
+ (.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
+ (.image, [.public, .maxAge(maxAgeImage)]),
+ (.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
+ ])
+ }
+
+ /// The minimum response body size, in bytes, before a response is compressed — read from the `compression.minimumResponseSize` key.
+ var compressionMinResponseSize: Int {
+ int(
+ forKey: .Compression.minResponseSize,
+ default: .Compression.minResponseSize
+ )
+ }
+
+ /// The persistence backend the service runs against, derived from the `database.*` keys.
+ ///
+ /// When `database.driver` selects MySQL, the connection parameters are assembled from the `database.host`, `database.port`,
+ /// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`, and
+ /// `database.pool.maxPerEventLoop` keys. Any other driver value falls back to the in-memory database.
+ var driver: Driver {
+ switch string(
+ forKey: .Database.driver,
+ default: .Database.driver
+ ) {
+ case .Database.driverMySQL:
+ return .mysql(
+ .init(
+ host: string(
+ forKey: .Database.host,
+ default: .Database.host
+ ),
+ port: int(
+ forKey: .Database.port,
+ default: .Database.port
+ ),
+ name: string(
+ forKey: .Database.name,
+ default: .Database.name
+ ),
+ username: string(
+ forKey: .Database.username,
+ default: .Database.username
+ ),
+ password: string(
+ forKey: .Database.password,
+ default: ""
+ ),
+ tls: tls,
+ maxConnectionsPerEventLoop: int(
+ forKey: .Database.poolMaxPerEventLoop,
+ default: .Database.poolMaxPerEventLoop
+ )
+ )
+ )
+ default:
+ return .inMemory
+ }
+ }
+
+ /// The minimum log level the application emits at, read from the `log.level` key.
+ ///
+ /// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
+ var logLevel: Logger.Level {
+ string(
+ forKey: .Log.level,
+ as: Logger.Level.self,
+ default: .info
+ )
+ }
+
+ /// Whether the executable runs in migrate-and-exit mode instead of serving, read from the `database.migrate` flag; off by default.
+ var migrate: Bool {
+ bool(
+ forKey: .Database.migrate,
+ default: false
+ )
+ }
+
+ /// The security headers middleware configuration, built from the `security.*` keys.
+ ///
+ /// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity`
+ /// is set — the header is a commitment browsers cache, so it must be opted into for deployments actually served over HTTPS.
+ var securityHeaders: SecurityHeadersMiddleware.Configuration {
+ .init(
+ contentSecurityPolicy: string(
+ forKey: .Security.contentSecurityPolicy,
+ default: .Security.contentSecurityPolicy
+ ),
+ contentTypeOptions: string(
+ forKey: .Security.contentTypeOptions,
+ default: .Security.contentTypeOptions
+ ),
+ frameOptions: string(
+ forKey: .Security.frameOptions,
+ default: .Security.frameOptions
+ ),
+ referrerPolicy: string(
+ forKey: .Security.referrerPolicy,
+ default: .Security.referrerPolicy
+ ),
+ permissionsPolicy: string(
+ forKey: .Security.permissionsPolicy,
+ default: .Security.permissionsPolicy
+ ),
+ strictTransportSecurity: string(
+ forKey: .Security.strictTransportSecurity
+ )
+ )
+ }
+
+ /// The name the server reports in its `Server` response header, read from the `http.serverName` key.
+ var serverName: String {
+ string(
+ forKey: .HTTP.serverName,
+ default: .Server.name
+ )
+ }
+
+ /// The directory the static files are served from, read from the `path.staticFiles` key.
+ var staticFilesPath: String {
+ string(
+ forKey: .Path.staticFiles,
+ default: .Path.staticResources
+ )
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension ConfigReader {
+
+ // MARK: Properties
+
+ /// The TLS posture for the MySQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
+ /// other value falls back to `prefer`.
+ var tls: TLS {
+ switch string(
+ forKey: .Database.tls,
+ default: .Database.tls
+ ) {
+ case .Database.tlsOff: .off
+ case .Database.tlsRequire: .require
+ default: .prefer
+ }
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
index f465d38..ed37869 100644
--- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift
@@ -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().routes)
+/// router.addRoutes(HealthController(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: 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 {
let routes = RouteCollection(context: Context.self)
@@ -39,6 +48,13 @@ public struct HealthController: 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"}"#
}
}
diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift
index 06afdd4..9e3ac7d 100644
--- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift
@@ -15,6 +15,27 @@ extension AbsoluteConfigKey {
/// The absolute configuration key for the minimum response body size, in bytes, before compression is applied.
public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize)
}
+ /// A namespace for the persistence configuration keys, as absolute keys.
+ public enum Database {
+ /// The absolute configuration key selecting migrate-and-exit mode.
+ public static let migrate: AbsoluteConfigKey = .init(.Database.migrate)
+ /// The absolute configuration key for the persistence driver.
+ public static let driver: AbsoluteConfigKey = .init(.Database.driver)
+ /// The absolute configuration key for the MySQL/MariaDB host.
+ public static let host: AbsoluteConfigKey = .init(.Database.host)
+ /// The absolute configuration key for the MySQL/MariaDB port.
+ public static let port: AbsoluteConfigKey = .init(.Database.port)
+ /// The absolute configuration key for the database name.
+ public static let name: AbsoluteConfigKey = .init(.Database.name)
+ /// The absolute configuration key for the database username.
+ public static let username: AbsoluteConfigKey = .init(.Database.username)
+ /// The absolute configuration key for the database password.
+ public static let password: AbsoluteConfigKey = .init(.Database.password)
+ /// The absolute configuration key for the TLS posture used when connecting.
+ public static let tls: AbsoluteConfigKey = .init(.Database.tls)
+ /// The absolute configuration key for the maximum pooled connections per event loop.
+ public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop)
+ }
/// A namespace for the HTTP server configuration keys, as absolute keys.
public enum HTTP {
/// The absolute configuration key for the host the server binds to.
diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift
index 5d27088..cef6a42 100644
--- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift
@@ -15,6 +15,27 @@ extension ConfigKey {
/// The configuration key for the minimum response body size, in bytes, before compression is applied.
public static let minResponseSize: ConfigKey = "compression.minimumResponseSize"
}
+ /// A namespace for the persistence configuration keys.
+ public enum Database {
+ /// The configuration key selecting migrate-and-exit mode (run migrations, then exit) instead of serving.
+ public static let migrate: ConfigKey = "database.migrate"
+ /// The configuration key for the persistence driver (`inMemory` or `mysql`).
+ public static let driver: ConfigKey = "database.driver"
+ /// The configuration key for the MySQL/MariaDB host.
+ public static let host: ConfigKey = "database.host"
+ /// The configuration key for the MySQL/MariaDB port.
+ public static let port: ConfigKey = "database.port"
+ /// The configuration key for the database name.
+ public static let name: ConfigKey = "database.name"
+ /// The configuration key for the database username.
+ public static let username: ConfigKey = "database.username"
+ /// The configuration key for the database password.
+ public static let password: ConfigKey = "database.password"
+ /// The configuration key for the TLS posture used when connecting (`off`, `prefer`, or `require`).
+ public static let tls: ConfigKey = "database.tls"
+ /// The configuration key for the maximum pooled connections per event loop.
+ public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop"
+ }
/// A namespace for the HTTP server configuration keys.
public enum HTTP {
/// The configuration key for the host the server binds to.
diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift
index d6976ef..201068a 100644
--- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift
@@ -13,4 +13,11 @@ extension Int {
/// The default minimum response body size, in bytes, before compression is applied (1 KB).
public static let minResponseSize = 1_024
}
+ /// A namespace for the persistence's default configuration values.
+ public enum Database {
+ /// The default MySQL/MariaDB port.
+ public static let port = 3_306
+ /// The default maximum pooled connections per event loop.
+ public static let poolMaxPerEventLoop = 4
+ }
}
diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift
index c9620ca..23fa620 100644
--- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift
+++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift
@@ -1,4 +1,23 @@
extension String {
+ /// A namespace for the persistence's default configuration values and recognized tokens.
+ public enum Database {
+ /// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
+ public static let driver = "inMemory"
+ /// The driver token selecting the MySQL/MariaDB backend.
+ public static let driverMySQL = "mysql"
+ /// The default MySQL/MariaDB host.
+ public static let host = "localhost"
+ /// The default database name.
+ public static let name = "loud"
+ /// The default database username.
+ public static let username = "loud"
+ /// The default TLS posture token.
+ public static let tls = "prefer"
+ /// The TLS token disabling TLS.
+ public static let tlsOff = "off"
+ /// The TLS token requiring TLS.
+ public static let tlsRequire = "require"
+ }
/// A namespace for well-known path string constants.
public enum Path {
/// The directory, relative to the working directory, that the website's static files are served from.
diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift
index 8a968f9..e5ed3a7 100644
--- a/Services/Website/Tests/App/AppTests.swift
+++ b/Services/Website/Tests/App/AppTests.swift
@@ -49,6 +49,44 @@ struct AppTests {
}
}
+ @Test
+ func `health check to be served at the health path`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/health",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .ok)
+ #expect(response.headers[.contentType] == "application/json")
+ #expect(body == #"{"status":"ok"}"#)
+ }
+ }
+ }
+
+ @Test
+ func `readiness check to be served at the readiness path`() async throws {
+ // Live mode runs the application's service group, so the `Fluent` service starts before the
+ // request and shuts its connection pool down after — the router-only mode never would.
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.live) { client in
+ try await client.execute(
+ uri: "/health/ready",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .ok)
+ #expect(response.headers[.contentType] == "application/json")
+ #expect(body == #"{"status":"ready"}"#)
+ }
+ }
+ }
+
@Test(arguments: StaticFile.allCases)
func `static files to be served`(
staticFile file: StaticFile
diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift
index 5a5d3c1..5b3c547 100644
--- a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift
+++ b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift
@@ -1,6 +1,8 @@
import Hummingbird
import HummingbirdTesting
+import Logging
import NIOCore
+import Persistence
import Testing
@testable import WebsiteCore
@@ -8,21 +10,13 @@ import Testing
@Suite("HealthController controller")
struct HealthControllerTests {
- // MARK: Constants
-
- private let app: Application = .init(router: {
- let router = Router()
-
- router.addRoutes(HealthController().routes)
-
- return router
- }())
-
// MARK: Functional tests
@Test
func `serves the status payload at the health path`() async throws {
- try await app.test(.router) { client in
+ try await app(
+ probe: nil
+ ).test(.router) { client in
try await client.execute(
uri: "/health",
method: .get
@@ -36,4 +30,113 @@ struct HealthControllerTests {
}
}
+ @Test
+ func `serves ready at the readiness path when the database is reachable`() async throws {
+ let service = Service(
+ driver: .inMemory,
+ logger: Logger(label: "test")
+ )
+ let fluent = service()
+
+ do {
+ try await app(
+ probe: Probe(fluent: fluent)
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/health/ready",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .ok)
+ #expect(response.headers[.contentType] == "application/json")
+ #expect(body == #"{"status":"ready"}"#)
+ }
+ }
+ } catch {
+ try? await fluent.shutdown()
+
+ throw error
+ }
+
+ try await fluent.shutdown()
+ }
+
+ @Test
+ func `serves unavailable at the readiness path when the database is unreachable`() async throws {
+ // Port 1 on the loopback interface has nothing listening, so the probe's 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()
+
+ do {
+ try await app(
+ probe: Probe(fluent: fluent)
+ ).test(.router) { client in
+ try await client.execute(
+ uri: "/health/ready",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .serviceUnavailable)
+ #expect(response.headers[.contentType] == "application/json")
+ #expect(body == #"{"status":"unavailable"}"#)
+ }
+ }
+ } catch {
+ try? await fluent.shutdown()
+
+ throw error
+ }
+
+ try await fluent.shutdown()
+ }
+
+ @Test
+ func `does not serve the readiness path without a probe`() async throws {
+ try await app(probe: nil).test(.router) { client in
+ try await client.execute(
+ uri: "/health/ready",
+ method: .get
+ ) { response in
+ #expect(response.status == .notFound)
+ }
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension HealthControllerTests {
+
+ /// Builds a test application serving the ``HealthController`` routes for the given probe.
+ /// - Parameter probe: the probe supplied to the controller, or `nil` for liveness only.
+ /// - Returns: the configured test application.
+ func app(
+ probe: Probe?
+ ) -> some ApplicationProtocol {
+ Application(router: {
+ let router = Router()
+
+ router.addRoutes(HealthController(probe: probe).routes)
+
+ return router
+ }())
+ }
+
}
diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml
index 24390a3..c297e23 100644
--- a/Services/Website/docker-compose.override.yml
+++ b/Services/Website/docker-compose.override.yml
@@ -12,9 +12,42 @@ services:
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
platform: linux/arm64
build:
- # The build context is the repo root so the local Localization package
- # (referenced via ../../Packages/Localization) is inside the context.
context: ../..
dockerfile: Services/Website/Dockerfile
environment:
LOG_LEVEL: debug
+ DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
+ DATABASE_HOST: ${DATABASE_HOST:-localhost}
+ DATABASE_TLS: ${DATABASE_TLS:-off}
+
+ # Local development database, started only with the `database` profile so a plain
+ # `docker compose up` still runs the in-memory backend:
+ #
+ # docker compose --profile database up mariadb
+ mariadb:
+ image: mariadb:11
+ container_name: ${HOST_OWNER:-loud}-db
+ restart: unless-stopped
+ profiles:
+ - database
+ ports:
+ - "127.0.0.1:${DATABASE_PORT:-3306}:3306"
+ environment:
+ MARIADB_RANDOM_ROOT_PASSWORD: "yes"
+ MARIADB_DATABASE: ${DATABASE_NAME:-loud-ams}
+ MARIADB_USER: ${DATABASE_USERNAME:-loud-ams}
+ MARIADB_PASSWORD: ${DATABASE_PASSWORD:-loud-ams}
+ MARIADB_AUTO_UPGRADE: "1"
+ command:
+ - "--character-set-server=utf8mb4"
+ - "--collation-server=utf8mb4_unicode_ci"
+ security_opt:
+ - no-new-privileges:true
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+ volumes:
+ - ./Tests/DB:/var/lib/mysql
diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml
index d3dc25e..81c40d5 100644
--- a/Services/Website/docker-compose.yml
+++ b/Services/Website/docker-compose.yml
@@ -21,3 +21,13 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
+ # Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a
+ # managed MySQL/MariaDB database. Provide the password via the environment or a
+ # secret — never commit it.
+ DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql}
+ DATABASE_HOST: ${DATABASE_HOST:-localhost}
+ DATABASE_PORT: ${DATABASE_PORT:-3306}
+ DATABASE_NAME: ${DATABASE_NAME:-loud-ams}
+ DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams}
+ DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
+ DATABASE_TLS: ${DATABASE_TLS:-require}