diff --git a/.gitignore b/.gitignore
index 6ca7d7a..a140df4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,7 +51,7 @@ playground.xcworkspace
.env
!.env.local
-## Local MariaDB data files
+## Local PostgreSQL data files
Services/Website/Tests/DB/
# Fastlane
diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md
index b724c15..36494f9 100644
--- a/Packages/Infrastructure/README.md
+++ b/Packages/Infrastructure/README.md
@@ -2,7 +2,6 @@
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding.
## Overview
-The package provides, grouped by role:
| Role | Types |
| --- | --- |
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
@@ -10,14 +9,16 @@ The package provides, grouped by role:
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies |
+| Analytics | `Analytics`, the tracker script `Attribute`s it derives, and the `origin` its preconnect hint targets |
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
| Contexts | `LocalizedRequestContext` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
## Design rules
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
-- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, `socialCard`, and `structuredData` values its other head tags render, each omitted unless the page provides it. A `SocialCard` and a `StructuredData` node take their URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing them.
-- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. The open schema.org vocabularies extend the same way: the package declares only the `Property.Name` and `Node.Kind` constants every service shares, and a service adds the ones its own node shapes need.
+- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure building a page for a locale, and a `Page` conformer's `metadata` plus its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive fully formed and absolute; composing them stays with the page.
+- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready representation — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — and `Page` applies it without knowing the vocabulary. Page ``scripts`` and the tracker render as `defer`red head tags, with a `preconnect` hint for the tracker's cross-origin host.
+- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `*+Defaults` extensions are the pattern. The open schema.org vocabularies extend the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, and a service adds its own.
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
## Layout
@@ -32,7 +33,7 @@ Sources/
│ ├── Middlewares/ the five HTTP middlewares
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
-│ └── Types/ SocialCard and StructuredData, with their nested types in SocialCard/ and StructuredData/
+│ └── Types/ Analytics, SocialCard, StructuredData (the latter two nesting their own types in SocialCard/ and StructuredData/)
└── Internal/
├── Extensions/ implementation details (the String separators)
└── Types/ implementation details (FNV1aHash)
@@ -43,8 +44,8 @@ Tests/
```
## Testing
-Every suite carries a tag naming the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, 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 — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, 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 `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms).
+- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
index 30c25e1..a2f4d6a 100644
--- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
+++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
@@ -4,8 +4,8 @@ import Foundation
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
///
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
-/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the
-/// metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body.
+/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, the analytics
+/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as the body.
public protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
@@ -18,13 +18,16 @@ public protocol Page: HTMLDocument, Sendable {
// MARK: Properties
+ /// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
+ var analytics: Analytics? { get }
+
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
var assetVersion: String? { get }
/// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag.
var canonicalURL: String? { get }
- /// The page's markup, rendered before the ``scripts``.
+ /// The page's markup, rendered as the document body.
@HTMLBuilder
var content: Content { get }
@@ -35,7 +38,10 @@ public protocol Page: HTMLDocument, Sendable {
@HTMLBuilder
var metadata: Metadata { get }
- /// The scripts loaded at the end of the document body, in order.
+ /// The scripts loaded from the document head, in order.
+ ///
+ /// Rendered as `defer`red tags: the downloads start while the head is parsed, and the scripts still execute in order only after the
+ /// document is fully parsed — the same semantics end-of-body tags would give, minus the late download start.
var scripts: [any Asset] { get }
/// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
@@ -64,34 +70,38 @@ public extension Page {
nil
}
- /// The page ``content`` followed by its ``scripts``.
+ /// The page ``content``; the ``scripts`` load deferred from the ``head``.
@HTMLBuilder
var body: some HTML {
content
-
- for file in scripts {
- script(.src(file.urlPath(
- for: .js,
- version: assetVersion
- ))) {}
- }
}
- /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
- /// (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
+ /// The viewport declaration, the ``analytics`` origin preconnect hint, the ``summary``, ``canonicalURL``, and ``socialCard``
+ /// tags, the ``structuredData`` script and the ``analytics`` tracker script (when provided), and the ``metadata`` followed by the
+ /// ``stylesheets`` links and the deferred ``scripts`` tags, placed in the document head.
///
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup,
/// and HTML5 allows only one.
///
/// The structured data is an inert data block — browsers never execute it, so a site's `Content-Security-Policy` does not apply to it —
- /// that search engines read for the organization's name, logo, and profiles.
+ /// that search engines read for the organization's name, logo, and profiles. The analytics tracker, by contrast, is an executable script the
+ /// policy must allow, and it is `defer`red so it never delays the page render; each behavior flag renders its `data-` attribute only when enabled.
+ /// When recorder mode is on, the session recorder script follows the tracker script, deferred as well and carrying only the website id.
@HTMLBuilder
var head: some HTML {
meta(
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
-
+
+ // Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
+ if let origin = analytics?.origin {
+ link(
+ .rel("preconnect"),
+ .href(origin)
+ )
+ }
+
if let summary {
meta(
.name(.description),
@@ -127,6 +137,30 @@ public extension Page {
}
}
+ if let analytics {
+ script(
+ .defer,
+ .src(analytics.scriptURL)
+ ) {}
+ .attributes(contentsOf: analytics.attributes.map {
+ .custom(
+ name: $0.name,
+ value: $0.value
+ )
+ })
+
+ if let recorderScriptURL = analytics.recorderScriptURL {
+ script(
+ .defer,
+ .src(recorderScriptURL),
+ .custom(
+ name: "data-website-id",
+ value: analytics.websiteID
+ )
+ ) {}
+ }
+ }
+
metadata
for file in stylesheets {
@@ -138,8 +172,23 @@ public extension Page {
))
)
}
+
+ for file in scripts {
+ script(
+ .defer,
+ .src(file.urlPath(
+ for: .js,
+ version: assetVersion
+ ))
+ ) {}
+ }
}
+ /// The analytics tracker is omitted unless the page provides one.
+ var analytics: Analytics? {
+ nil
+ }
+
/// The social card is omitted unless the page provides one.
var socialCard: SocialCard? {
nil
diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift
new file mode 100644
index 0000000..8839c63
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift
@@ -0,0 +1,136 @@
+import Foundation
+
+/// The analytics tracker a page embeds: where the script loads from, which site it reports as, which domains it reports from, and how it behaves.
+///
+/// A page carries it as an optional value so the ``Page`` scaffolding renders the tracker's deferred `"#))
+ #expect(html.contains(#""#))
#expect(html.contains("Stub content"))
}
@@ -38,13 +38,17 @@ struct PageTests {
}
@Test
- func `renders the scripts after the content`() throws {
+ func `renders the scripts deferred in the head, after the stylesheets`() throws {
let html = StubPage().render()
- let content = try #require(html.range(of: "Stub content"))
+ let stylesheet = try #require(html.range(of: "/css/stub.css"))
let script = try #require(html.range(of: "/js/stub.js"))
+ let content = try #require(html.range(of: "Stub content"))
- #expect(content.lowerBound < script.lowerBound)
+ // Deferred head scripts start downloading during head parsing but still execute, in order, only after the
+ // document is parsed — the semantics end-of-body tags gave, minus the late download start.
+ #expect(stylesheet.lowerBound < script.lowerBound)
+ #expect(script.lowerBound < content.lowerBound)
}
@Test
@@ -122,6 +126,75 @@ struct PageTests {
))
}
+ @Test
+ func `omits the analytics tracker by default`() {
+ let html = StubPage().render()
+
+ #expect(!html.contains("data-website-id"))
+ #expect(!html.contains(#"rel="preconnect""#))
+ }
+
+ @Test
+ func `renders the analytics tracker when provided`() throws {
+ let html = StubPage(analytics: .init(
+ scriptURL: "https://analytics.example.com/script",
+ websiteID: "0000-website-id",
+ domains: "example.com"
+ )).render()
+
+ #expect(html.contains(#""#))
+
+ // The preconnect hint warms the tracker origin's connection before the parser reaches the script tag.
+ let preconnect = try #require(html.range(of: #""#))
+ let script = try #require(html.range(of: #""#))
+
+ let tracker = try #require(html.range(of: #""#))
+ #expect(!html.contains("data-do-not-track"))
+ #expect(!html.contains("data-performance"))
+ }
+
@Test
func `appends the version token to the asset URLs`() {
let html = StubPage(assetVersion: "0123456789abcdef").render()
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift
new file mode 100644
index 0000000..6b14d1d
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift
@@ -0,0 +1,123 @@
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "Analytics type",
+ .tags(.type)
+)
+struct AnalyticsTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `lists the website id and domains with every behavior flag on by default`() {
+ let analytics = Analytics(
+ scriptURL: "https://analytics.example.com/script",
+ websiteID: "id-123",
+ domains: "example.com"
+ )
+
+ #expect(analytics.attributes.map(\.name) == [
+ "data-website-id",
+ "data-domains",
+ "data-exclude-hash",
+ "data-do-not-track",
+ "data-performance",
+ ])
+ #expect(analytics.attributes.map(\.value) == [
+ "id-123",
+ "example.com",
+ "true",
+ "true",
+ "true",
+ ])
+ }
+
+ @Test
+ func `derives its origin from the script URL`() {
+ let analytics = Analytics(
+ scriptURL: "https://analytics.example.com/script",
+ websiteID: "id-123",
+ domains: "example.com"
+ )
+
+ #expect(analytics.origin == "https://analytics.example.com")
+ }
+
+ @Test
+ func `keeps an explicit port in its origin`() {
+ let analytics = Analytics(
+ scriptURL: "http://localhost:3000/script",
+ websiteID: "id-123",
+ domains: "localhost"
+ )
+
+ #expect(analytics.origin == "http://localhost:3000")
+ }
+
+ @Test
+ func `carries no origin for a script URL without a scheme or host`() {
+ let analytics = Analytics(
+ scriptURL: "/script",
+ websiteID: "id-123",
+ domains: "example.com"
+ )
+
+ #expect(analytics.origin == nil)
+ }
+
+ @Test
+ func `carries no recorder script URL by default`() {
+ let analytics = Analytics(
+ scriptURL: "https://analytics.example.com/script",
+ websiteID: "id-123",
+ domains: "example.com"
+ )
+
+ #expect(analytics.recorderScriptURL == nil)
+ }
+
+ @Test
+ func `derives its recorder script URL from the origin when recorder mode is on`() {
+ let analytics = Analytics(
+ scriptURL: "https://analytics.example.com/script",
+ websiteID: "id-123",
+ domains: "example.com",
+ recorder: true
+ )
+
+ #expect(analytics.recorderScriptURL == "https://analytics.example.com/recorder.js")
+ }
+
+ @Test
+ func `carries no recorder script URL when no origin can be derived`() {
+ let analytics = Analytics(
+ scriptURL: "/script",
+ websiteID: "id-123",
+ domains: "example.com",
+ recorder: true
+ )
+
+ #expect(analytics.recorderScriptURL == nil)
+ }
+
+ @Test
+ func `omits the disabled behavior flags`() {
+ let analytics = Analytics(
+ scriptURL: "https://analytics.example.com/script",
+ websiteID: "id-123",
+ domains: "example.com",
+ excludeHash: true,
+ doNotTrack: false,
+ performance: false
+ )
+
+ #expect(analytics.attributes.map(\.name) == [
+ "data-website-id",
+ "data-domains",
+ "data-exclude-hash",
+ ])
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
index ffc755e..d719f1d 100644
--- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
+++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
@@ -7,6 +7,9 @@ struct StubPage: Page {
// MARK: Properties
+ /// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
+ let analytics: Analytics?
+
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
@@ -34,6 +37,8 @@ struct StubPage: Page {
/// default) to leave them unversioned.
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
/// to omit it.
+ /// - analytics: the analytics tracker rendered as a deferred script in the document head,
+ /// or `nil` (the default) to omit it.
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
/// (the default) to omit them.
/// - structuredData: the structured data rendered as a JSON-LD script in the document
@@ -44,10 +49,12 @@ struct StubPage: Page {
locale: Locale = .init(identifier: "en"),
assetVersion: String? = nil,
canonicalURL: String? = nil,
+ analytics: Analytics? = nil,
socialCard: SocialCard? = nil,
structuredData: StructuredData? = nil,
summary: String? = nil
) {
+ self.analytics = analytics
self.assetVersion = assetVersion
self.canonicalURL = canonicalURL
self.locale = locale
diff --git a/Packages/Localization/README.md b/Packages/Localization/README.md
index e0a1c40..5128cc4 100644
--- a/Packages/Localization/README.md
+++ b/Packages/Localization/README.md
@@ -2,7 +2,6 @@
The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
## Overview
-The package provides, grouped by role:
| Role | Types |
| --- | --- |
| Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale |
@@ -11,10 +10,10 @@ The package provides, grouped by role:
| Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) |
## Design rules
-- **The String Catalog is the single source of truth.** Supported languages, the default language, and every string come from the bundle's `Localizable.xcstrings`. Adding a language is a translation-only change — once a locale exists in the catalog, `LanguageList` and `Negotiate` pick it up with no code change.
+- **The String Catalog is the single source of truth.** Supported languages, the default language, and every string come from the bundle's `Localizable.xcstrings`, so adding a language is a translation-only change.
- **The locale is always explicit.** A server has no single "current" locale, so every lookup names the locale to resolve in; nothing reads process-wide locale state.
-- **Raw `.xcstrings` parsing, for Linux parity.** The catalog is decoded from its JSON rather than through Foundation's compiled-catalog APIs, which are unavailable or non-functional on Linux. Consumers must `.copy` the catalog resource verbatim (not `.process` it) so it ships as raw JSON on every platform. Only simple `stringUnit` values are decoded; plural and device variations are not represented.
-- **Resolution never fails.** A missing entry falls back to the source-language string, then to the key itself; a missing or undecodable catalog degrades the language list to the default. Check `catalogState` once at startup and warn when it is not `.loaded`, before visitors ever see raw keys.
+- **Raw `.xcstrings` parsing, for Linux parity.** The catalog is decoded from its JSON rather than through Foundation's compiled-catalog APIs, which are unavailable or non-functional on Linux. Consumers must `.copy` the resource verbatim (never `.process` it) so it ships as raw JSON everywhere. Only simple `stringUnit` values are decoded — plural and device variations are not.
+- **Resolution never fails.** A missing entry falls back to the source-language string, then to the key itself; a missing or undecodable catalog degrades the language list to the default. Check `catalogState` at startup and warn when it is not `.loaded`.
- **Method structs.** `Localize` and `Negotiate` hold their lifetime-fixed configuration (the bundle) in `init` and take only per-call inputs in `callAsFunction`.
- **One decoded catalog per bundle.** Catalogs are immutable at runtime, so every `Localize`, `Negotiate`, and `LanguageList` bound to the same bundle and table shares one cached `StringCatalog`.
@@ -36,9 +35,9 @@ Tests/
```
## Testing
-Every suite carries a tag naming the kind of API it exercises — `.method` or `.type`, 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 — `.method` or `.type`, 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 `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
+- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation and Synchronization only.
diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift
index 5d83c1f..85dba7c 100644
--- a/Packages/Persistence/Package.swift
+++ b/Packages/Persistence/Package.swift
@@ -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",
diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md
index 571fdac..c3cb5e8 100644
--- a/Packages/Persistence/README.md
+++ b/Packages/Persistence/README.md
@@ -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.
diff --git a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
index 60f053b..a86c51b 100644
--- a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
+++ b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift
@@ -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.
///
diff --git a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
index be6f492..faf5777 100644
--- a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
+++ b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift
@@ -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()))
}
}
diff --git a/Packages/Persistence/Sources/Public/Methods/Probe.swift b/Packages/Persistence/Sources/Public/Methods/Probe.swift
index 8abbc8b..9282a5f 100644
--- a/Packages/Persistence/Sources/Public/Methods/Probe.swift
+++ b/Packages/Persistence/Sources/Public/Methods/Probe.swift
@@ -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
}
}
diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift
index a8953f2..51cad79 100644
--- a/Packages/Persistence/Sources/Public/Methods/Service.swift
+++ b/Packages/Persistence/Sources/Public/Methods/Service.swift
@@ -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:
diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift
index 8867228..1a771f8 100644
--- a/Packages/Persistence/Sources/Public/Types/Configuration.swift
+++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift
@@ -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
}
}
diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
index 0cd7d4e..37a20c6 100644
--- a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
+++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift
@@ -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")
+ )
+ }
+
}
diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
index 6f44afa..c01ab7e 100644
--- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
+++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift
@@ -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"))
diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
index 3bc57e5..ad6d38e 100644
--- a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
+++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift
@@ -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)
)
)
}()
diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift
deleted file mode 100644
index 27973c7..0000000
--- a/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift
+++ /dev/null
@@ -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
- }
-
- }
-
-}
diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift
new file mode 100644
index 0000000..0df114d
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift
@@ -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
+ }
+
+ }
+
+}
diff --git a/Packages/Persistence/Tests/Utils/Fakes/SilentPostgresServer.swift b/Packages/Persistence/Tests/Utils/Fakes/SilentPostgresServer.swift
new file mode 100644
index 0000000..d1498c1
--- /dev/null
+++ b/Packages/Persistence/Tests/Utils/Fakes/SilentPostgresServer.swift
@@ -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()
+ }
+
+}
diff --git a/Packages/Utility/README.md b/Packages/Utility/README.md
index 168ee32..5f62ff0 100644
--- a/Packages/Utility/README.md
+++ b/Packages/Utility/README.md
@@ -2,7 +2,6 @@
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
## Overview
-The package provides, grouped by role:
| Role | Types |
| --- | --- |
| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form |
@@ -23,9 +22,9 @@ Tests/
```
## Testing
-Every suite carries a tag naming the kind of API it exercises — `.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 — `.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`, `Localization`, and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
+- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation only.
diff --git a/Services/Website/.env.local b/Services/Website/.env.local
index 1913441..3835e3e 100644
--- a/Services/Website/.env.local
+++ b/Services/Website/.env.local
@@ -37,25 +37,28 @@ LOG_LEVEL=debug
# --- Persistence ----------------------------------------------------------------
-# Persistence driver: inMemory (default, no infrastructure) or mysql.
+# Persistence driver: inMemory (default, no infrastructure) or postgres.
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`.
+# PostgreSQL connection, used when DATABASE_DRIVER=postgres.
DATABASE_HOST=localhost
-# Port of the database to connect to.
-DATABASE_PORT=3306
-
# Name of the database to connect to.
DATABASE_NAME=site
-# Username of the database to connect as.
-DATABASE_USERNAME=site
-
# Provide the real password via the environment or a secret — never commit it.
DATABASE_PASSWORD=site
-# TLS posture when connecting: off | prefer | require (use `require` in production).
+# Maximum pooled connections per event loop, one loop per core — an 8-core host
+# can open 8 times this, and every replica that many again.
+DATABASE_POOL_MAX_PER_EVENT_LOOP=4
+
+# Port of the database to connect to.
+DATABASE_PORT=5432
+
+# TLS posture when connecting: off | prefer | require. Plaintext is the usual
+# choice over a private container network; use `require` across one you share.
DATABASE_TLS=off
+
+# Username of the database to connect as.
+DATABASE_USERNAME=site
\ No newline at end of file
diff --git a/Services/Website/Makefile b/Services/Website/Makefile
index 831c03f..7caf706 100644
--- a/Services/Website/Makefile
+++ b/Services/Website/Makefile
@@ -73,11 +73,11 @@ db-mount: ## Start the local database instance
@docker compose \
--profile database up \
--detach \
- --wait mariadb
+ --wait postgres
.PHONY: db-migrate
db-migrate: ## Run the migrations against the local database instance
- @DATABASE_DRIVER=mysql \
+ @DATABASE_DRIVER=postgres \
DATABASE_HOST=127.0.0.1 \
DATABASE_TLS=off \
swift run \
@@ -88,22 +88,27 @@ db-migrate: ## Run the migrations against the local database instance
db-shell: ## Open a SQL shell on the local database instance
@docker compose \
--profile database \
- exec mariadb \
- mariadb \
- --user=$(or $(DATABASE_USERNAME),site) \
- --password=$(or $(DATABASE_PASSWORD),site) \
- $(or $(DATABASE_NAME),site)
+ exec \
+ --env PGPASSWORD=$(or $(DATABASE_PASSWORD),site) \
+ postgres \
+ psql \
+ --username=$(or $(DATABASE_USERNAME),site) \
+ --dbname=$(or $(DATABASE_NAME),site)
.PHONY: db-unmount
-db-unmount: ## Stop and remove the local database instance (keeps the data volume)
+db-unmount: ## Stop and remove the local database instance while keeping its data
@docker compose \
- --profile database down mariadb
+ --profile database down postgres
.PHONY: db-reset
-db-reset: ## Stop and remove the local database instance and delete its data volume
+db-reset: ## Stop and remove the local database instance, then delete its data
@docker compose \
- --profile database down mariadb \
+ --profile database down postgres \
--volumes
+ @if [ -d Tests/DB ]; then \
+ rm -rf Tests/DB; \
+ mkdir -p Tests/DB; \
+ fi
# --- Assets minification ------------------------------------------------------
diff --git a/Services/Website/README.md b/Services/Website/README.md
index d397119..922ca9a 100644
--- a/Services/Website/README.md
+++ b/Services/Website/README.md
@@ -5,14 +5,15 @@ The **Site** 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 `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
-- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, and the JSON-LD structured-data script (see [Page metadata](#page-metadata)).
+- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
- 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).
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
+- Embeds a cookieless [Umami](https://umami.is) tracker on the pages that provide one, configured through the `analytics.*` keys (see [Analytics](#analytics)).
- 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.
+- 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 PostgreSQL server, selected by a single configuration key.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
@@ -26,11 +27,11 @@ Two SwiftPM targets:
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages (`IndexPage`, `NotFoundPage`) and their shared `Page` defaults, the `StaticFile` asset catalog, the request context, the String Catalog, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
-The `Website` executable depends on four local packages:
-- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
-- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard` and `StructuredData` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
-- `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.
-- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
+The `Website` executable depends on four local packages, each under `Packages/`:
+- `Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
+- `Infrastructure` — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable.
+- `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.
+- `Utility` — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
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).
@@ -50,21 +51,21 @@ HealthController (GET /health → liveness, GET /health/rea
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
### Page metadata
-Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
+Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
-Four of those are optional and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
+Five of those are optional and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
| Property | Renders as | Notes |
| --- | --- | --- |
| `summary` | `` | The page's one-line description. |
| `canonicalURL` | `` | Absolute URL. |
| `socialCard` | Open Graph + Twitter `` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
| `structuredData` | `