Merge branch 'setup' into template

This commit is contained in:
2026-08-13 02:37:43 +02:00
37 changed files with 1249 additions and 474 deletions
+21 -9
View File
@@ -21,8 +21,8 @@ let package = Package(
from: "2.0.0"
),
.package(
url: "https://github.com/vapor/fluent-mysql-driver.git",
from: "4.8.0"
url: "https://github.com/vapor/fluent-postgres-driver.git",
from: "2.12.0"
),
.package(
url: "https://github.com/vapor/fluent-sqlite-driver.git",
@@ -33,12 +33,16 @@ let package = Package(
from: "3.36.0"
),
.package(
url: "https://github.com/vapor/mysql-nio.git",
from: "1.7.0"
url: "https://github.com/vapor/postgres-nio.git",
from: "1.33.0"
),
.package(
url: "https://github.com/apple/swift-nio.git",
from: "2.65.0"
from: "2.81.0"
),
.package(
url: "https://github.com/apple/swift-nio-ssl.git",
from: "2.25.0"
),
],
targets: [
@@ -50,8 +54,8 @@ let package = Package(
package: "hummingbird-fluent"
),
.product(
name: "FluentMySQLDriver",
package: "fluent-mysql-driver"
name: "FluentPostgresDriver",
package: "fluent-postgres-driver"
),
.product(
name: "FluentSQLiteDriver",
@@ -61,6 +65,14 @@ let package = Package(
name: "SQLKit",
package: "sql-kit"
),
.product(
name: "PostgresNIO",
package: "postgres-nio"
),
.product(
name: "NIOSSL",
package: "swift-nio-ssl"
),
],
path: "Sources"
),
@@ -69,8 +81,8 @@ let package = Package(
dependencies: [
.byName(name: "Persistence"),
.product(
name: "MySQLNIO",
package: "mysql-nio"
name: "PostgresNIO",
package: "postgres-nio"
),
.product(
name: "NIOCore",
+17 -21
View File
@@ -1,26 +1,25 @@
# Persistence
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a MySQL/MariaDB backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
## Overview
The package provides, grouped by role:
| Role | Types |
| --- | --- |
| Backend selection | `Driver` (`mysql` or `inMemory`), `Configuration` (the MySQL/MariaDB connection parameters), `TLS` (the connection's TLS posture) |
| Backend selection | `Driver` (`postgres` or `inMemory`), `Configuration` (the PostgreSQL connection parameters), `TLS` (the connection's TLS posture) |
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` |
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
## Design rules
- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. See the Website service's `ConfigReader+Properties` for the mapping.
- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping.
- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use.
- **Migrations are declared once, and append-only.** `PrepareDB` is the single place migrations are registered, in the order they must run; alter the schema by adding a new migration, never by editing one that has already run. Registering does not apply them the in-memory backend is migrated on startup, while a shared MySQL/MariaDB database is migrated out of band (the executable's migrate-and-exit mode), so multiple booting instances never race.
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types; repositories map them to `Sendable` value-type snapshots (e.g. `Example`) before returning, and the models themselves stay internal to the package.
- **Readiness never throws.** `Probe` runs a `SELECT 1` — the cheapest statement both backends understand, independent of any schema — and maps every failure to `false`, so callers translate it straight into a readiness response.
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop so every query reaches the same in-memory database, rather than each pooled connection getting its own private one.
- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend migrates on startup, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race.
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Example`) instead.
- **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint.
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one.
- **Method structs.** `Service`, `PrepareDB`, and `Probe` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
> **Note:** the `prefer` TLS posture is enforced by the driver itself: a supplied TLS configuration upgrades the connection only when the server advertises TLS, and continues in plaintext otherwise (pinned by a test against a fake server that offers no TLS). `require` currently maps to the same configuration and therefore behaves like `prefer` — the refusal when the server offers no TLS is not yet enforced.
> **Note:** the driver itself enforces both TLS postures — `prefer` upgrades only when the server advertises TLS and continues in plaintext otherwise, `require` refuses a server that offers none. Tests pin both against a fake plaintext-only server.
## Layout
Sources are split by visibility, then by kind, one type per file:
@@ -37,24 +36,21 @@ Sources/
Tests/
├── Cases/ the test suites, mirroring the Sources/ layout
└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the
plaintext-only fake MySQL server, and the suite Tag constants
plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants
```
## Testing
The suite runs against the in-memory backend by default, so `swift test` needs no database. The MySQL/MariaDB integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`, `MYSQL_TEST_NAME`, `MYSQL_TEST_USERNAME`, and `MYSQL_TEST_PASSWORD`); it reverts its migrations afterwards, so the shared database is left as it was found:
The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found:
```sh
# in-memory only
swift test
# or
# with the local MariaDB up (make db-mount):
MYSQL_TEST_HOST=127.0.0.1 swift test
swift test # in-memory only
POSTGRES_TEST_HOST=127.0.0.1 swift test # against the local PostgreSQL (make db-mount)
```
Outside the application's service group, a built `Fluent` service must be shut down explicitly — even on failure — or its connection pool asserts on `deinit`; the suites' `do`/`catch` pattern around `fluent.shutdown()` is the shape to follow.
Outside the application's service group, a built `Fluent` service must be shut down explicitly — even on failure — or its connection pool asserts on `deinit`; the suites' `do`/`catch` around `fluent.shutdown()` is the shape to follow.
Every suite carries a tag naming the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
Every suite carries a tag for the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling `Infrastructure` and `Localization` packages (the services deploy to Linux containers; the packages carry no UI platforms).
- Package dependencies: `hummingbird-fluent`, `fluent-mysql-driver`, `fluent-sqlite-driver`, and `sql-kit`; the test target additionally depends on `mysql-nio` and `swift-nio` for the TLS fallback test.
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- Package dependencies: `hummingbird-fluent`, `fluent-postgres-driver`, `fluent-sqlite-driver`, `sql-kit`, `postgres-nio`, and `swift-nio-ssl`; the test target additionally depends on `swift-nio` for the TLS fallback tests' fake server.
@@ -4,10 +4,10 @@
/// 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.
/// A PostgreSQL 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)
case postgres(Configuration)
/// An ephemeral, in-process SQLite database held entirely in memory.
///
@@ -1,9 +1,10 @@
import NIOSSL
import PostgresNIO
/// 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``.
/// The executable derives a posture from its `database.tls` configuration and passes it along as part of ``Configuration``; the PostgreSQL driver
/// receives the resulting connection TLS mode through ``postgresTLS()``.
public enum TLS: Sendable {
/// Connect without TLS, in plaintext.
@@ -13,28 +14,27 @@ public enum TLS: Sendable {
case prefer
/// Connect only over TLS, refusing the connection when the server offers none.
///
/// - Important: the refusal is not yet enforced until it is, `require` behaves like ``prefer`` and silently falls back to plaintext when the
/// server offers no TLS.
case require
}
// MARK: - Properties
// MARK: - Methods
extension TLS {
/// The NIO TLS configuration passed to the MySQL driver for this posture.
/// The connection TLS mode passed to the PostgreSQL driver for this posture.
///
/// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``.
/// Returns `.disable` for ``off`` (connect in plaintext) and the default client configuration for ``prefer`` and ``require``. The driver enforces
/// both semantics natively: `prefer` upgrades to TLS only when the server advertises support and continues in plaintext otherwise, while `require`
/// refuses the connection when the server offers no TLS.
///
/// - Note: the driver gives a supplied configuration ``prefer`` semantics natively it upgrades to TLS only when the server advertises support,
/// and continues in plaintext otherwise so `prefer` is fully enforced. `require` maps to the same configuration and therefore currently
/// behaves like ``prefer``: the refusal when the server offers no TLS is not yet enforced.
var tlsConfiguration: TLSConfiguration? {
/// - Throws: an error when the TLS context cannot be built from the default client configuration.
/// - Returns: the connection TLS mode for this posture.
func postgresTLS() throws -> PostgresConnection.Configuration.TLS {
switch self {
case .off: nil
default: .makeClientConfiguration()
case .off: .disable
case .prefer: .prefer(try NIOSSLContext(configuration: .makeClientConfiguration()))
case .require: .require(try NIOSSLContext(configuration: .makeClientConfiguration()))
}
}
@@ -12,37 +12,66 @@ public struct Probe: Sendable {
/// The `Fluent` service whose default database is probed.
private let fluent: Fluent
/// The longest the probe waits for the database's answer before reporting it as not reachable.
private let timeout: Duration
// MARK: Initializers
/// Creates a probe for the default database of the given `Fluent` service.
/// - Parameter fluent: the `Fluent` service whose default database is probed.
/// - Parameters:
/// - fluent: the `Fluent` service whose default database is probed.
/// - timeout: the longest the probe waits for the database's answer before reporting it as not reachable.
public init(
fluent: Fluent
fluent: Fluent,
timeout: Duration = .seconds(2)
) {
self.fluent = fluent
self.timeout = timeout
}
// 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
/// Runs a trivial `SELECT 1` against the default database the cheapest statement both the PostgreSQL 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.
/// reported as not reachable rather than thrown, and an answer that does not arrive within the timeout is likewise reported as not reachable so
/// a database that hangs yields a prompt "not ready" instead of a hanging readiness endpoint. A default database that is not an SQL database is
/// also reported as not reachable.
/// - Returns: `true` when the database answers the probe in time, `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
// The query is raced against the deadline from unstructured tasks whose first answer wins: a structured group
// would await the query child before returning, so a database that hangs mid-query the very failure the
// deadline exists for would hang the probe with it. The loser is cancelled and abandoned; a late answer lands
// in a finished stream and is dropped.
let (answers, continuation) = AsyncStream.makeStream(of: Bool.self)
let query = Task {
do {
try await database
.raw("SELECT 1")
.run()
continuation.yield(true)
} catch {
continuation.yield(false)
}
}
let deadline = Task {
try? await Task.sleep(for: timeout)
continuation.yield(false)
}
var answer = answers.makeAsyncIterator()
let isReachable = await answer.next() ?? false
query.cancel()
deadline.cancel()
return isReachable
}
}
@@ -1,17 +1,26 @@
import FluentMySQLDriver
import FluentPostgresDriver
import FluentSQLiteDriver
import HummingbirdFluent
import Logging
import PostgresNIO
/// 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: Enumerations
/// The persistence backend resolved at construction, with the PostgreSQL TLS mode already built.
private enum Backend {
case postgres(Configuration, PostgresConnection.Configuration.TLS)
case inMemory
}
// MARK: Properties
/// The persistence backend to register.
private let driver: Driver
/// The resolved persistence backend to register.
private let backend: Backend
/// The logger the database emits through.
private let logger: Logger
@@ -19,14 +28,27 @@ public struct Service: Sendable {
// MARK: Initializers
/// Creates a factory for a `Fluent` service backed by the given driver.
///
/// The TLS context for the PostgreSQL backend is built here, once the factory holds only resolved configuration, so producing the
/// service afterwards cannot fail.
/// - Parameters:
/// - driver: the persistence backend to register.
/// - logger: the logger the database emits through.
/// - Throws: an error when the TLS context for the PostgreSQL backend cannot be built.
public init(
driver: Driver,
logger: Logger
) {
self.driver = driver
) throws {
switch driver {
case .postgres(let configuration):
self.backend = .postgres(
configuration,
try configuration.tls.postgresTLS()
)
case .inMemory:
self.backend = .inMemory
}
self.logger = logger
}
@@ -43,21 +65,22 @@ public struct Service: Sendable {
logger: logger
)
switch driver {
case .mysql(let configuration):
switch backend {
case .postgres(let configuration, let tls):
fluent.databases.use(
.mysql(
.postgres(
configuration: .init(
hostname: configuration.host,
port: configuration.port,
username: configuration.username,
password: configuration.password,
database: configuration.name,
tlsConfiguration: configuration.tls.tlsConfiguration
tls: tls
),
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop
maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop,
connectionPoolTimeout: .init(configuration.poolTimeout)
),
as: .mysql,
as: .psql,
isDefault: true
)
case .inMemory:
@@ -1,4 +1,4 @@
/// The connection parameters for the MySQL/MariaDB backend.
/// The connection parameters for the PostgreSQL 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 {
@@ -17,6 +17,9 @@ public struct Configuration: Sendable {
/// The password the connection authenticates with.
let password: String
/// The longest a query waits for a pooled connection to become available before failing.
let poolTimeout: Duration
/// The port the database server listens on.
let port: Int
@@ -28,7 +31,7 @@ public struct Configuration: Sendable {
// MARK: Initializers
/// Creates a set of MySQL/MariaDB connection parameters.
/// Creates a set of PostgreSQL connection parameters.
/// - Parameters:
/// - host: the host the database server is reached at.
/// - port: the port the database server listens on.
@@ -37,6 +40,7 @@ public struct Configuration: Sendable {
/// - 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.
/// - poolTimeout: the longest a query waits for a pooled connection to become available before failing.
public init(
host: String,
port: Int,
@@ -44,15 +48,17 @@ public struct Configuration: Sendable {
username: String,
password: String,
tls: TLS,
maxConnectionsPerEventLoop: Int
maxConnectionsPerEventLoop: Int,
poolTimeout: Duration
) {
self.host = host
self.port = port
self.name = name
self.username = username
self.password = password
self.tls = tls
self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop
self.name = name
self.password = password
self.poolTimeout = poolTimeout
self.port = port
self.tls = tls
self.username = username
}
}
@@ -1,8 +1,7 @@
import Logging
import MySQLNIO
import NIOCore
import NIOPosix
import NIOSSL
import PostgresNIO
import Testing
@testable import Persistence
@@ -13,46 +12,81 @@ import Testing
)
struct TLSTests {
// MARK: Properties tests
// MARK: Methods 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()))
}
@Test
func `prefer falls back to plaintext when the server offers no TLS`() async throws {
// The fake server never advertises `CLIENT_SSL`, so this connection can only succeed by downgrading to
// plaintext pinning the driver behavior the `prefer` posture relies on.
let server = try await PlaintextMySQLServer.start()
let tlsConfiguration = try #require(TLS.prefer.tlsConfiguration)
let connection = try await MySQLConnection.connect(
to: .init(ipAddress: "127.0.0.1", port: server.port),
username: "loud",
database: "loud",
tlsConfiguration: tlsConfiguration,
logger: Logger(label: "test"),
on: MultiThreadedEventLoopGroup.singleton.any()
).get()
func `off connects in plaintext`() async throws {
// With TLS disabled the client skips the `SSLRequest` and sends its startup message directly,
// which the fake server answers in plaintext.
let server = try await PlaintextPostgresServer.start()
let connection = try await connect(to: server, tls: .off)
let isConnected = !connection.isClosed
try await connection.close().get()
try await connection.close()
try await server.stop()
#expect(isConnected)
}
@Test
func `prefer falls back to plaintext when the server offers no TLS`() async throws {
// The fake server refuses the `SSLRequest`, so this connection can only succeed by downgrading
// to plaintext pinning the driver behavior the `prefer` posture relies on.
let server = try await PlaintextPostgresServer.start()
let connection = try await connect(to: server, tls: .prefer)
let isConnected = !connection.isClosed
try await connection.close()
try await server.stop()
#expect(isConnected)
}
@Test
func `require refuses the connection when the server offers no TLS`() async throws {
// The fake server refuses the `SSLRequest`, so the driver must fail the connection instead of
// downgrading pinning the refusal the `require` posture promises.
let server = try await PlaintextPostgresServer.start()
let error = await #expect(throws: PSQLError.self) {
_ = try await connect(to: server, tls: .require)
}
try await server.stop()
#expect(error?.code == .sslUnsupported)
}
}
// MARK: - Helpers
private extension TLSTests {
/// Opens a connection to the given fake server with the given TLS posture.
/// - Parameters:
/// - server: the fake server to connect to.
/// - tls: the TLS posture to connect with.
/// - Returns: the open connection, to be closed by the caller.
func connect(
to server: PlaintextPostgresServer,
tls: TLS
) async throws -> PostgresConnection {
try await PostgresConnection.connect(
on: MultiThreadedEventLoopGroup.singleton.any(),
configuration: .init(
host: "127.0.0.1",
port: server.port,
username: "loud",
password: "loud",
database: "loud",
tls: tls.postgresTLS()
),
id: 1,
logger: Logger(label: "test")
)
}
}
@@ -15,7 +15,7 @@ struct ProbeTests {
@Test
func `reports a reachable database`() async throws {
let service = Service(
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
@@ -33,8 +33,8 @@ struct ProbeTests {
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(
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: 1,
@@ -42,7 +42,8 @@ struct ProbeTests {
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
@@ -57,6 +58,47 @@ struct ProbeTests {
#expect(!isReachable)
}
@Test
func `reports a hanging database as unreachable within its timeout`() async throws {
// The silent server accepts the TCP connection and never answers, so the probe's query can only
// ever be resolved by its deadline without one, it would wait out the driver's own connect
// timeout (10 seconds) instead.
let server = try await SilentPostgresServer.start()
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: server.port,
name: "hanging",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
)
let fluent = service()
let probe = Probe(
fluent: fluent,
timeout: .milliseconds(100)
)
let clock = ContinuousClock()
let start = clock.now
let isReachable = await probe()
let elapsed = clock.now - start
try await fluent.shutdown()
try await server.stop()
#expect(!isReachable)
// Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's
// 10-second connect timeout only the deadline can answer this fast.
#expect(elapsed < .seconds(5))
}
@Test
func `reports a default database that is not an SQL database`() async throws {
let fluent = Fluent(logger: Logger(label: "test"))
@@ -15,7 +15,7 @@ struct ServiceTests {
@Test
func `registers an SQLite database as the default for the in-memory driver`() async throws {
let service = Service(
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
@@ -31,19 +31,20 @@ struct ServiceTests {
}
@Test
func `registers a MySQL database as the default for the mysql driver`() async throws {
func `registers a PostgreSQL database as the default for the postgres 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(
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: 3306,
port: 5432,
name: "site",
username: "site",
password: "site",
tls: .off,
maxConnectionsPerEventLoop: 1
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
@@ -56,12 +57,12 @@ struct ServiceTests {
let dialect = try #require(database?.dialect)
#expect(dialect.name == "mysql")
#expect(dialect.name == "postgresql")
}
@Test
func `builds a usable in-memory database`() async throws {
let service = Service(
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
@@ -87,12 +88,12 @@ struct ServiceTests {
}
@Test(
"mysql: migrate, insert, read back",
.enabled(if: mysqlDriver != nil)
"postgres: migrate, insert, read back",
.enabled(if: postgresDriver != nil)
)
func mysqlRoundTrip() async throws {
func postgresRoundTrip() async throws {
try await roundTrip(
driver: mysqlDriver!,
driver: postgresDriver!,
revertAfter: true
)
}
@@ -116,11 +117,11 @@ private extension ServiceTests {
revertAfter: Bool = false
) async throws {
let prepareDB = PrepareDB()
let service = Service(
let service = try Service(
driver: driver,
logger: Logger(label: "test")
)
let fluent = service()
do {
@@ -147,25 +148,26 @@ private extension ServiceTests {
}
/// 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? = {
/// The PostgreSQL driver built from the `POSTGRES_TEST_*` environment variables, or `nil` when the gate
/// variable `POSTGRES_TEST_HOST` is unset in which case the PostgreSQL integration test is skipped, so
/// the suite stays runnable with no database available.
private let postgresDriver: Persistence.Driver? = {
let environment = ProcessInfo.processInfo.environment
guard let host = environment["MYSQL_TEST_HOST"] else {
guard let host = environment["POSTGRES_TEST_HOST"] else {
return nil
}
return .mysql(
return .postgres(
.init(
host: host,
port: environment["MYSQL_TEST_PORT"].flatMap(Int.init) ?? 3306,
name: environment["MYSQL_TEST_NAME"] ?? "site",
username: environment["MYSQL_TEST_USERNAME"] ?? "site",
password: environment["MYSQL_TEST_PASSWORD"] ?? "site",
port: environment["POSTGRES_TEST_PORT"].flatMap(Int.init) ?? 5432,
name: environment["POSTGRES_TEST_NAME"] ?? "site",
username: environment["POSTGRES_TEST_USERNAME"] ?? "site",
password: environment["POSTGRES_TEST_PASSWORD"] ?? "site",
tls: .off,
maxConnectionsPerEventLoop: 2
maxConnectionsPerEventLoop: 2,
poolTimeout: .seconds(10)
)
)
}()
@@ -1,162 +0,0 @@
import NIOCore
import NIOPosix
/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake.
///
/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and
/// it answers the client's handshake response with a bare OK packet so a client asking for TLS can only
/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
/// downgrades to plaintext rather than refusing the connection.
final class PlaintextMySQLServer {
// MARK: Properties
/// The port the server listens on, assigned by the system at bind time.
let port: Int
/// The listening channel the server accepts connections through.
private let channel: Channel
// MARK: Initializers
private init(
channel: Channel,
port: Int
) {
self.channel = channel
self.port = port
}
// MARK: Functions
/// Starts a server on the loopback interface, on a system-assigned port.
/// - Returns: the running server, ready to be connected to at ``port``.
static func start() async throws -> PlaintextMySQLServer {
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
.childChannelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(Handler())
}
}
.bind(host: "127.0.0.1", port: 0)
.get()
guard let port = channel.localAddress?.port else {
throw ChannelError.unknownLocalAddress
}
return .init(
channel: channel,
port: port
)
}
/// Stops the server, closing its listening channel.
func stop() async throws {
try await channel.close().get()
}
}
// MARK: - Handlers
private extension PlaintextMySQLServer {
/// Greets a freshly accepted connection, accepts whatever authentication response arrives,
/// and closes on anything after that (e.g. a `COM_QUIT`).
final class Handler: ChannelInboundHandler {
// MARK: Type aliases
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
// MARK: Properties
/// Whether the client's handshake response has already been answered with an OK packet.
private var didAuthenticate = false
// MARK: Functions
func channelActive(context: ChannelHandlerContext) {
context.writeAndFlush(
wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)),
promise: nil
)
}
func channelRead(
context: ChannelHandlerContext,
data: NIOAny
) {
guard didAuthenticate else {
didAuthenticate = true
context.writeAndFlush(
wrapOutboundOut(Self.ok(allocator: context.channel.allocator)),
promise: nil
)
return
}
context.close(promise: nil)
}
// MARK: Helpers
/// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet.
///
/// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`,
/// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` deliberately **not** `CLIENT_SSL`,
/// so the client cannot upgrade the connection to TLS.
private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer {
var payload = allocator.buffer(capacity: 80)
payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version
payload.writeNullTerminatedString("8.0.0") // server version
payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id
payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1
payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler
payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION
payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8)
payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit)
payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH
payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length
payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved
payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2
payload.writeNullTerminatedString("mysql_native_password") // auth plugin name
return framed(payload, sequence: 0, allocator: allocator)
}
/// A bare OK packet, framed as the reply to the client's handshake response.
private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer {
var payload = allocator.buffer(capacity: 8)
payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings
return framed(payload, sequence: 2, allocator: allocator)
}
/// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte.
private static func framed(
_ payload: ByteBuffer,
sequence: UInt8,
allocator: ByteBufferAllocator
) -> ByteBuffer {
var packet = allocator.buffer(capacity: payload.readableBytes + 4)
var payload = payload
packet.writeInteger(UInt8(payload.readableBytes & 0xff))
packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff))
packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff))
packet.writeInteger(sequence)
packet.writeBuffer(&payload)
return packet
}
}
}
@@ -0,0 +1,169 @@
import NIOCore
import NIOPosix
/// A fake PostgreSQL server speaking just enough of the wire protocol to complete a plaintext startup.
///
/// It answers the client's `SSLRequest` with `'N'` (no SSL) and the subsequent startup message with
/// `AuthenticationOk`, `BackendKeyData`, and `ReadyForQuery` so a client asking for TLS can only end up
/// connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver
/// downgrades to plaintext rather than refusing the connection, and what the `require` test connects to,
/// proving the driver refuses the connection instead of downgrading.
final class PlaintextPostgresServer {
// MARK: Properties
/// The port the server listens on, assigned by the system at bind time.
let port: Int
/// The listening channel the server accepts connections through.
private let channel: Channel
// MARK: Initializers
private init(
channel: Channel,
port: Int
) {
self.channel = channel
self.port = port
}
// MARK: Functions
/// Starts a server on the loopback interface, on a system-assigned port.
/// - Returns: the running server, ready to be connected to at ``port``.
static func start() async throws -> PlaintextPostgresServer {
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
.childChannelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(Handler())
}
}
.bind(host: "127.0.0.1", port: 0)
.get()
guard let port = channel.localAddress?.port else {
throw ChannelError.unknownLocalAddress
}
return .init(
channel: channel,
port: port
)
}
/// Stops the server, closing its listening channel.
func stop() async throws {
try await channel.close().get()
}
}
// MARK: - Handlers
private extension PlaintextPostgresServer {
/// Refuses the client's `SSLRequest`, accepts whatever startup message arrives, and closes on anything
/// after that (e.g. a `Terminate`).
///
/// Unlike MySQL, the PostgreSQL client speaks first, so nothing is written on `channelActive`.
final class Handler: ChannelInboundHandler {
// MARK: Type aliases
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
// MARK: Enumerations
/// The startup phases the connection moves through.
private enum State {
case awaitingSSLRequest
case awaitingStartup
case established
}
// MARK: Constants
/// The magic code identifying an `SSLRequest` message.
private static let sslRequestCode: Int32 = 80877103
// MARK: Properties
/// The startup phase the connection is currently in.
private var state: State = .awaitingSSLRequest
// MARK: Functions
func channelRead(
context: ChannelHandlerContext,
data: NIOAny
) {
let buffer = unwrapInboundIn(data)
switch state {
case .awaitingSSLRequest:
// Peek past the Int32 length at the Int32 code: an `SSLRequest` is refused with a bare
// 'N', while a direct startup message (a client connecting with TLS disabled) is
// answered straight away.
guard buffer.getInteger(at: buffer.readerIndex + 4, as: Int32.self) == Self.sslRequestCode else {
completeStartup(context: context)
return
}
state = .awaitingStartup
var refusal = context.channel.allocator.buffer(capacity: 1)
refusal.writeInteger(UInt8(ascii: "N"))
context.writeAndFlush(
wrapOutboundOut(refusal),
promise: nil
)
case .awaitingStartup:
completeStartup(context: context)
case .established:
context.close(promise: nil)
}
}
/// Answers a startup message and marks the connection established.
private func completeStartup(context: ChannelHandlerContext) {
state = .established
context.writeAndFlush(
wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)),
promise: nil
)
}
// MARK: Helpers
/// The reply completing a plaintext startup: `AuthenticationOk`, `BackendKeyData`, and
/// `ReadyForQuery` in a single flush.
///
/// `BackendKeyData` is not optional filler the client requires it before `ReadyForQuery` by
/// default and fails the connection when it is missing.
private static func startupResponse(allocator: ByteBufferAllocator) -> ByteBuffer {
var buffer = allocator.buffer(capacity: 32)
buffer.writeInteger(UInt8(ascii: "R")) // AuthenticationOk
buffer.writeInteger(Int32(8))
buffer.writeInteger(Int32(0))
buffer.writeInteger(UInt8(ascii: "K")) // BackendKeyData
buffer.writeInteger(Int32(12))
buffer.writeInteger(Int32(1)) // process id
buffer.writeInteger(Int32(0)) // secret key
buffer.writeInteger(UInt8(ascii: "Z")) // ReadyForQuery
buffer.writeInteger(Int32(5))
buffer.writeInteger(UInt8(ascii: "I")) // idle
return buffer
}
}
}
@@ -0,0 +1,54 @@
import NIOCore
import NIOPosix
/// A fake server accepting connections and never answering.
///
/// A client connecting to it completes the TCP handshake and then waits forever for the first protocol byte the shape of a database that hangs rather
/// than refuses. This is what the probe's deadline test connects to, proving the probe answers within its timeout instead of hanging alongside the server.
final class SilentPostgresServer {
// MARK: Properties
/// The port the server listens on, assigned by the system at bind time.
let port: Int
/// The listening channel the server accepts connections through.
private let channel: Channel
// MARK: Initializers
private init(
channel: Channel,
port: Int
) {
self.channel = channel
self.port = port
}
// MARK: Functions
/// Starts a server on the loopback interface, on a system-assigned port.
/// - Returns: the running server, ready to be connected to at ``port``.
static func start() async throws -> SilentPostgresServer {
let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
.bind(host: "127.0.0.1", port: 0)
.get()
guard let port = channel.localAddress?.port else {
throw ChannelError.unknownLocalAddress
}
return .init(
channel: channel,
port: port
)
}
/// Stops the server, closing its listening channel.
func stop() async throws {
try await channel
.close()
.get()
}
}