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` | `