Compare commits

...
20 Commits
Author SHA1 Message Date
javierandClaude Opus 5.5 3a3d2619ad Dated the demo cons in the Persistence package from the weekend after seeding, and re-dated the ones already seeded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:47:23 +01:00
javierandClaude Opus 5.5 e0524da525 Moved test database teardown into defer blocks and unified the one-decimal formatting in the Website service.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:41:55 +01:00
javierandClaude Opus 5.5 06953a1be0 Trimmed the documentation added by the template sync and the events test fix in the Website service.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:13:53 +01:00
javierandClaude Opus 5.5 c1a391b9f6 Pinned the clock of the events test in the Website library, so it no longer fails once the demo cons are past.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:07:57 +01:00
javierandClaude Opus 5.5 911424de24 Project updates from Template
This commit contains the latest updates from the generic Website template, which move the toolchain to Swift 6.4, gate analytics and structured data behind traits, and rework page routing:

- Updated the Swift toolchain for all the package dependencies and the Website service to version 6.4. (345b305)
- Adapted all the existing middlewares in the Infrastructure package to Swift 6.4. (26008df)
- Moved test resource teardown in the Persistence and the Website library into defer blocks. (27984a9)
- Gated analytics behind a trait and reworked structured data in the Infrastructure package. (c7d4769)
- Enabled the Analytics trait in the Website service. (f6a6bbd)
- Reworked page routing in the Website service. (d1cb2c1)
- Gated the structured data features in the Infrastructure package behind a trait. (f0a8791)
- Enabled the StructuredData trait of the Infrastructure package in the Website service. (bbfdeb3)
- Fixed the asset pin and a build warning in the Dockerfile of the Website service. (6ed0f9b)

The root README change in 345b305 was left out, as this project no longer carries that file. From d1cb2c1, the RootController rework was left out, as the prototype serves a session-aware landing page and routes its other pages through their own controllers; the view-event reporter was appended to the prototype's shared script instead.

The prototype code was adapted to the stricter Swift 6.4 settings: `String(format:)` was swapped for safe formatting, the NoIndex middleware takes a `@concurrent` next handler, and the tests flagged by warnings-as-errors were tightened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:55:43 +01:00
javier 326299fc35 Kept the prototype out of search engines in the Website service. 2026-09-21 17:36:39 +02:00
javier 359fe0d6ac Wired up the Website deployment for the POC prototype. 2026-09-21 16:43:54 +02:00
javier f2f445827b Implemented phase 6 for the POC prototype in the Website service. 2026-09-21 13:53:13 +02:00
javier 256d22df40 Added the catalogue models and seed to support phase 6 of the POC prototype in the Persistence package. 2026-09-21 13:52:52 +02:00
javier 31d2d98d49 Implemented phase 5 for the POC prototype in the Website service. 2026-09-21 13:32:05 +02:00
javier 89a7a7b8e5 Added the catalogue models and seed to support phase 5 of the POC prototype in the Persistence package. 2026-09-21 13:31:44 +02:00
javier 9403931109 Implemented phase 4 for the POC prototype in the Website service. 2026-09-21 12:55:35 +02:00
javier 95e0a455e8 Added the catalogue models and seed to support phase 4 of the POC prototype in the Persistence package. 2026-09-21 12:50:53 +02:00
javier 0dc3fe6bea Implemented phase 3 for the POC prototype in the Website service. 2026-09-21 12:11:25 +02:00
javier b1ceed7790 Added the catalogue models and seed to support phase 3 of the POC prototype in the Persistence package. 2026-09-21 12:11:01 +02:00
javier 43dcc03da6 Implemented phase 2 for the POC prototype in the Website service. 2026-09-21 11:44:58 +02:00
javier 09c7b8a1a7 Added the catalogue models and seed to support phase 2 of the POC prototype in the Persistence package. 2026-09-21 11:44:41 +02:00
javier ea89ea892a Implemented phase 1 for the POC prototype in the Website service. 2026-09-21 11:16:52 +02:00
javier 6d7ab4bcba Added the catalogue models and seed to support phase 1 of the POC prototype in the Persistence package. 2026-09-21 11:16:30 +02:00
javier 717df5208c Implemented phase 0 for the POC prototype in the Website service. 2026-09-21 10:22:55 +02:00
236 changed files with 28023 additions and 746 deletions

No files matched your search

+3 -1
View File
@@ -58,4 +58,6 @@ Services/Website/Tests/DB/
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/test_output
# Photos uploaded to listings while running locally
Services/Website/Resources/Static/uploads/
+23 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -15,6 +15,16 @@ let package = Package(
]
),
],
traits: [
.trait(
name: "Analytics",
description: "Analytics events, their element tagging, and the tracker script pages embed."
),
.trait(
name: "StructuredData",
description: "Schema.org structured data, rendered as a JSON-LD script."
),
],
dependencies: [
.package(
path: "../Localization"
@@ -82,3 +92,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+7 -5
View File
@@ -8,8 +8,8 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` |
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives |
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Structured data | Opt-in `StructuredData` trait: `StructuredData`, its graph types (`Node`, `Property`, `Value`, `Address`, `Organization`), the open `Name` and `Kind` vocabularies, and the site-wide `Organization`/`WebSite` initializer |
| Analytics | Opt-in `Analytics` trait: `Analytics`, the `Event`s a page reports (with `reporting(_:on:)` to tag an element, for a click or a view), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Responses | `CachedHTMLResponse` (rendered and gzipped once), `LocalizedHTMLCollectionResponse` (one of those per language) |
| Localization | The `Negotiate` extension resolving a request's language from its `lang` override, path prefix, and `Accept-Language` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
@@ -18,7 +18,7 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
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. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`.
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host.
- **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift.
- **Nodes are joined by `@id`, not repetition.** Identifiers come from helpers, never hand-spelled: `organizationID(forSiteURL:)` and `websiteID(forSiteURL:)` name the site-wide nodes, and `Organization.founder` takes one back. A service adds a helper for each node it owns.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites retroactively — the Website's `*+Defaults` are the pattern. The open schema.org vocabularies work the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, a service adds its own.
- **Method structs.** Single-operation types such as `FingerprintAssets` take lifetime-fixed configuration in `init` and per-call inputs in `callAsFunction`.
- **Wrap upstream, do not restate it.** Where a Hummingbird middleware is almost right, the package delegates to it and adds only the missing decision — `CompressionMiddleware` passes everything to `ResponseCompressionMiddleware` except responses that already name an encoding, which it would otherwise compress twice.
@@ -31,7 +31,7 @@ Sources/
├── Public/ public API
│ ├── Builders/ RouteCollectionBuilder
│ ├── Enumerations/ AssetExtension
│ ├── Extensions/ addController, Analytics.Event tagging, and the request-language negotiation, plus the default header names, rate limits, and header values
│ ├── Extensions/ addController, Analytics.Event tagging and reporting, and the request-language negotiation, plus the default header names, rate limits, and header values
│ ├── Methods/ FingerprintAssets
│ ├── Middlewares/ the seven HTTP middlewares
│ ├── Protocols/ Asset, Page, RouterController
@@ -49,7 +49,9 @@ Tests/
## Testing
Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, `.type` — declared in `Tests/Utils/Extensions/Tag+Constants.swift`, so a run can be sliced by kind. A new suite takes the tag matching its subject, or adds one when none fits.
Run `swift test --traits Analytics,StructuredData` to include the trait-gated suites.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms.
- Package dependencies: the local `Localization` package, `elementary`, `hummingbird`, and — for `CompressionMiddleware` and the responses' gzip — `hummingbird-compression` and `compress-nio`.
@@ -1,5 +1,3 @@
import Foundation
/// Hashes bytes with the FNV-1a 64-bit algorithm.
///
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive
@@ -25,10 +23,9 @@ struct FNV1aHash {
///
/// Reading it does not consume the running hash: more bytes can be combined afterwards.
var digest: String {
String(
format: "%016llx",
hash
)
let hex = String(hash, radix: 16)
return String(repeating: "0", count: 16 - hex.count) + hex
}
// MARK: Functions
@@ -1,3 +1,4 @@
#if Analytics
import Elementary
public extension Analytics.Event {
@@ -11,9 +12,12 @@ public extension Analytics.Event {
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
/// ```
///
/// - Parameter trigger: what reports the event; a click by default.
/// - Returns: one HTML attribute per event attribute, applied verbatim.
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
attributes.map {
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>(
on trigger: Trigger = .click
) -> [HTMLAttribute<Tag>] {
attributes(on: trigger).map {
.custom(
name: $0.name,
value: $0.value
@@ -22,3 +26,4 @@ public extension Analytics.Event {
}
}
#endif
@@ -5,6 +5,8 @@ public extension HTTPField.Name {
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `Sec-Fetch-Mode` field name (not provided as a standard `HTTPField.Name`).
static let secFetchMode = Self("Sec-Fetch-Mode")!
/// The `X-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
static let robotsTag = Self("X-Robots-Tag")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
@@ -0,0 +1,48 @@
#if Analytics
import Elementary
public extension MarkupContent where Self: _Attributed, Tag: MarkupTrait.AllowsAttributes {
// MARK: Methods
/// Tags the element with the event it reports to the analytics tracker.
///
/// ```swift
/// a(.href(url)) { "Listen" }
/// .reporting(.init(name: "playlist"))
/// ```
///
/// - Parameters:
/// - event: the event the element reports.
/// - trigger: what reports the event; a click by default.
/// - Returns: the element, carrying the event's ``Analytics/Event/attributes(on:)``.
func reporting(
_ event: Analytics.Event,
on trigger: Analytics.Event.Trigger = .click
) -> Self {
attributes(contentsOf: event.tagging(on: trigger))
}
}
public extension MarkupContent where Tag: MarkupTrait.AllowsAttributes {
// MARK: Methods
/// Tags the content with the event it reports to the analytics tracker.
///
/// The fallback for content that is not an element itself, mirroring Elementary's `attributes(contentsOf:)`.
/// - Parameters:
/// - event: the event the content reports.
/// - trigger: what reports the event; a click by default.
/// - Returns: the content, carrying the event's ``Analytics/Event/attributes(on:)``.
@_disfavoredOverload
func reporting(
_ event: Analytics.Event,
on trigger: Analytics.Event.Trigger = .click
) -> _AttributedContent<Self> {
attributes(contentsOf: event.tagging(on: trigger))
}
}
#endif
@@ -47,7 +47,7 @@ extension CompressionMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
let response = try await next(
request,
@@ -59,7 +59,7 @@ extension HTTPSRedirectMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
guard
isEnabled,
@@ -60,7 +60,7 @@ extension NotFoundMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
do {
return try await next(request, context)
@@ -57,7 +57,7 @@ extension RateLimitMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
let admission = buckets.admit(
client(
@@ -49,7 +49,7 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
var response: Response
@@ -39,7 +39,7 @@ extension TrailingSlashRedirectMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
let path = request.uri.path
let canonical = canonicalPath(of: path)
@@ -46,7 +46,7 @@ extension VaryMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
var response = try await next(
request,
@@ -6,6 +6,8 @@ import Foundation
/// 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, 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.
///
/// ``analytics`` requires the `Analytics` trait; ``structuredData``, the `StructuredData` trait.
public protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
@@ -18,8 +20,10 @@ public protocol Page: HTMLDocument, Sendable {
// MARK: Properties
#if Analytics
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
var analytics: Analytics? { get }
#endif
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
var assetVersion: String? { get }
@@ -48,8 +52,10 @@ public protocol Page: HTMLDocument, Sendable {
/// to omit them.
var socialCard: SocialCard? { get }
#if StructuredData
/// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
var structuredData: StructuredData? { get }
#endif
/// The stylesheets linked in the document head, in order.
var stylesheets: [any Asset] { get }
@@ -94,6 +100,7 @@ public extension Page {
.content("width=device-width, initial-scale=1")
)
#if Analytics
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
if let origin = analytics?.origin {
link(
@@ -101,6 +108,7 @@ public extension Page {
.href(origin)
)
}
#endif
if let summary {
meta(
@@ -128,6 +136,7 @@ public extension Page {
}
}
#if StructuredData
if let structuredData {
script(.custom(
name: "type",
@@ -136,7 +145,9 @@ public extension Page {
HTMLRaw(structuredData.payload)
}
}
#endif
#if Analytics
if let analytics {
script(
.defer,
@@ -160,6 +171,7 @@ public extension Page {
) {}
}
}
#endif
metadata
@@ -184,20 +196,24 @@ public extension Page {
}
}
#if Analytics
/// The analytics tracker is omitted unless the page provides one.
var analytics: Analytics? {
nil
}
#endif
/// The social card is omitted unless the page provides one.
var socialCard: SocialCard? {
nil
}
#if StructuredData
/// The structured data is omitted unless the page provides one.
var structuredData: StructuredData? {
nil
}
#endif
/// The summary is omitted unless the page provides one.
var summary: String? {
@@ -1,3 +1,4 @@
#if Analytics
import Foundation
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
@@ -114,3 +115,4 @@ public struct Analytics: Sendable {
}
}
#endif
@@ -1,3 +1,4 @@
#if Analytics
extension Analytics {
/// A name-value pair rendered as an HTML attribute.
///
@@ -29,3 +30,4 @@ extension Analytics {
}
}
#endif
@@ -1,8 +1,11 @@
#if Analytics
extension Analytics {
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
///
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
///
/// A ``Trigger/view`` renders under `data-view-event`, which a page script reports instead of the tracker.
public struct Event: Equatable, Sendable {
// MARK: Properties
@@ -34,22 +37,51 @@ extension Analytics {
// MARK: Computed
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
/// The event's attributes when clicked; see ``attributes(on:)``.
public var attributes: [Attribute] {
[.init(Constant.Name.prefix, value: name)]
attributes(on: .click)
}
// MARK: Functions
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
/// - Parameter trigger: what reports the event.
/// - Returns: one attribute for the name, then one per property.
public func attributes(on trigger: Trigger) -> [Attribute] {
[.init(trigger.prefix, value: name)]
+ properties.map {
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
.init("\(trigger.prefix)-\($0.name)", value: $0.value)
}
}
}
}
// MARK: - Constants
// MARK: - Types
private enum Constant {
enum Name {
/// The attribute name the tracker watches for, and the prefix each property renders under.
static let prefix = "data-umami-event"
extension Analytics.Event {
/// What reports an event.
public enum Trigger: Sendable {
/// A click, which the tracker records itself.
case click
/// The element first coming fully on screen, which a page script records.
case view
}
}
// MARK: - Helpers
private extension Analytics.Event.Trigger {
// MARK: Computed
/// The attribute name marking the event, and the prefix each property renders under.
var prefix: String {
switch self {
case .click: "data-umami-event"
case .view: "data-view-event"
}
}
}
#endif
@@ -45,6 +45,7 @@ extension SocialCard {
Tag(String(width), name: .imageWidth),
Tag(String(height), name: .imageHeight),
alt.map { Tag($0, name: .imageAlt) },
alt.map { Tag($0, name: .twitterImageAlt) },
]
return tags.compactMap { $0 }
@@ -68,6 +68,8 @@ extension SocialCard.Tag {
case title = "og:title"
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
case twitter = "twitter:card"
/// The `twitter:image:alt` tag, carrying the share image's text for assistive technologies.
case twitterImageAlt = "twitter:image:alt"
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
case type = "og:type"
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
@@ -84,7 +86,7 @@ public extension SocialCard.Tag.Name {
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
var attribute: SocialCard.Tag.Attribute {
self == .twitter ? .name : .property
rawValue.hasPrefix("twitter:") ? .name : .property
}
}
@@ -1,8 +1,9 @@
#if StructuredData
/// The structured data of a page, rendered as a JSON-LD script in the document head.
///
/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
/// shape directly, or uses ``init(organization:inLanguage:)`` for the site-wide pair every page shares.
///
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
/// page providing the data.
@@ -37,6 +38,15 @@ public struct StructuredData: Equatable, Sendable {
url + "#organization"
}
/// The `@id` of the `WebSite` node the site-wide initializer builds, for a page node's `isPartOf` to reference.
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
/// - Returns: the website node's `@id`.
public static func websiteID(
forSiteURL url: String
) -> String {
url + "#website"
}
// MARK: Computed
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
@@ -53,75 +63,18 @@ public struct StructuredData: Equatable, Sendable {
public extension StructuredData {
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
/// A property whose fact the data does not carry is left out.
/// Creates the site-wide structured data: the organization's node and the `WebSite` node it publishes, both with an `@id`.
/// - Parameters:
/// - name: the name of the organization and the site.
/// - url: the absolute URL the site is served at.
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
/// - description: what the organization does, or `nil` (the default) to omit its property.
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
/// - organization: the organization publishing the site.
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
init(
name: String,
url: String,
alternateName: String? = nil,
description: String? = nil,
areaServed: String? = nil,
email: String? = nil,
logo: String? = nil,
inLanguage: [String] = [],
profiles: [String] = [],
founder: String? = nil
organization: Organization,
inLanguage: [String] = []
) {
let id = Self.organizationID(forSiteURL: url)
var organization: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
]
if let alternateName {
organization.append(.init(.alternateName, value: .string(alternateName)))
}
if let description {
organization.append(.init(.description, value: .string(description)))
}
if let areaServed {
organization.append(.init(.areaServed, value: .string(areaServed)))
}
if let email {
organization.append(.init(.email, value: .string(email)))
}
if let logo {
organization.append(.init(.logo, value:.string(logo)))
}
if !profiles.isEmpty {
organization.append(.init(
.sameAs,
value: .array(profiles.map(Value.string))
))
}
if let founder {
organization.append(.init(.founder, value: .reference(founder)))
}
var website: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
.init(.name, value: .string(organization.name)),
.init(.url, value: .string(organization.url)),
.init(.publisher, value: .reference(Self.organizationID(forSiteURL: organization.url))),
]
if !inLanguage.isEmpty {
@@ -132,13 +85,10 @@ public extension StructuredData {
}
self.init(nodes: [
.init(
type: .organization,
id: id,
properties: organization
),
organization.node,
.init(
type: .website,
id: Self.websiteID(forSiteURL: organization.url),
properties: website
),
])
@@ -159,3 +109,4 @@ private extension StructuredData {
}
}
#endif
@@ -0,0 +1,57 @@
#if StructuredData
extension StructuredData {
/// A schema.org `PostalAddress`.
public struct Address: Equatable, Sendable {
// MARK: Properties
/// The ISO 3166-1 alpha-2 country code (e.g. `NL`).
public let country: String
/// The city.
public let locality: String
/// The postal code.
public let postalCode: String
/// The street and house number.
public let street: String
// MARK: Initializers
/// Creates a postal address.
/// - Parameters:
/// - street: the street and house number.
/// - postalCode: the postal code.
/// - locality: the city.
/// - country: the ISO 3166-1 alpha-2 country code (e.g. `NL`).
public init(
street: String,
postalCode: String,
locality: String,
country: String
) {
self.country = country
self.locality = locality
self.postalCode = postalCode
self.street = street
}
// MARK: Computed
/// The address's node.
var node: Node {
.init(
type: .postalAddress,
properties: [
.init(.streetAddress, value: .string(street)),
.init(.postalCode, value: .string(postalCode)),
.init(.addressLocality, value: .string(locality)),
.init(.addressCountry, value: .string(country)),
]
)
}
}
}
#endif
@@ -1,3 +1,4 @@
#if StructuredData
extension StructuredData {
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
public struct Node: Equatable, Sendable {
@@ -87,6 +88,9 @@ extension StructuredData.Node {
public extension StructuredData.Node.Kind {
/// An organization, e.g. the one publishing a website.
static let organization: Self = "Organization"
/// A postal address.
static let postalAddress: Self = "PostalAddress"
/// A website.
static let website: Self = "WebSite"
}
#endif
@@ -0,0 +1,137 @@
#if StructuredData
extension StructuredData {
/// A schema.org `Organization` publishing a site; a fact it does not carry is left out.
public struct Organization: Equatable, Sendable {
// MARK: Properties
/// The postal address.
public let address: Address?
/// The name it is also known by.
public let alternateName: String?
/// The area it serves.
public let areaServed: String?
/// What it does.
public let description: String?
/// The email address.
public let email: String?
/// The `@id` of the `Person` node founding it.
public let founder: String?
/// The ISO 8601 date it was founded.
public let foundingDate: String?
/// The ISO 6523 identifier.
public let iso6523Code: String?
/// The registered name.
public let legalName: String?
/// The absolute URL of the logo.
public let logo: String?
/// The name, shared by the site.
public let name: String
/// The absolute URLs of its public profiles.
public let profiles: [String]
/// The phone number.
public let telephone: String?
/// The absolute URL of the site.
public let url: String
/// The VAT number.
public let vatID: String?
// MARK: Initializers
/// Creates an organization.
/// - Parameters:
/// - name: the name, shared by the site.
/// - url: the absolute URL of the site.
/// - alternateName: the name it is also known by.
/// - legalName: the registered name.
/// - description: what it does.
/// - areaServed: the area it serves.
/// - email: the email address.
/// - telephone: the phone number.
/// - address: the postal address.
/// - vatID: the VAT number.
/// - iso6523Code: the ISO 6523 identifier.
/// - logo: the absolute URL of the logo.
/// - profiles: the absolute URLs of its public profiles.
/// - foundingDate: the ISO 8601 date it was founded.
/// - founder: the `@id` of the `Person` node founding it.
public init(
name: String,
url: String,
alternateName: String? = nil,
legalName: String? = nil,
description: String? = nil,
areaServed: String? = nil,
email: String? = nil,
telephone: String? = nil,
address: Address? = nil,
vatID: String? = nil,
iso6523Code: String? = nil,
logo: String? = nil,
profiles: [String] = [],
foundingDate: String? = nil,
founder: String? = nil
) {
self.address = address
self.alternateName = alternateName
self.areaServed = areaServed
self.description = description
self.email = email
self.founder = founder
self.foundingDate = foundingDate
self.iso6523Code = iso6523Code
self.legalName = legalName
self.logo = logo
self.name = name
self.profiles = profiles
self.telephone = telephone
self.url = url
self.vatID = vatID
}
// MARK: Computed
/// The organization's node.
var node: Node {
let properties: [Property?] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
alternateName.map { .init(.alternateName, value: .string($0)) },
legalName.map { .init(.legalName, value: .string($0)) },
description.map { .init(.description, value: .string($0)) },
areaServed.map { .init(.areaServed, value: .string($0)) },
email.map { .init(.email, value: .string($0)) },
telephone.map { .init(.telephone, value: .string($0)) },
address.map { .init(.address, value: .node($0.node)) },
vatID.map { .init(.vatID, value: .string($0)) },
iso6523Code.map { .init(.iso6523Code, value: .string($0)) },
logo.map { .init(.logo, value: .string($0)) },
profiles.isEmpty ? nil : .init(.sameAs, value: .array(profiles.map(Value.string))),
foundingDate.map { .init(.foundingDate, value: .string($0)) },
founder.map { .init(.founder, value: .reference($0)) },
]
return .init(
type: .organization,
id: StructuredData.organizationID(forSiteURL: url),
properties: properties.compactMap { $0 }
)
}
}
}
#endif
@@ -1,3 +1,4 @@
#if StructuredData
extension StructuredData {
/// A named property of a ``Node``, in the position it renders.
public struct Property: Equatable, Sendable {
@@ -69,6 +70,12 @@ extension StructuredData.Property {
// MARK: - Constants
public extension StructuredData.Property.Name {
/// The postal address of the thing a node describes.
static let address: Self = "address"
/// The country of a postal address.
static let addressCountry: Self = "addressCountry"
/// The city of a postal address.
static let addressLocality: Self = "addressLocality"
/// The name the thing a node describes is also known by.
static let alternateName: Self = "alternateName"
/// The area an organization serves.
@@ -79,16 +86,31 @@ public extension StructuredData.Property.Name {
static let email: Self = "email"
/// The person who founded an organization.
static let founder: Self = "founder"
/// The ISO 8601 date an organization was founded.
static let foundingDate: Self = "foundingDate"
/// The language a creative work — a site, a page — is published in.
static let inLanguage: Self = "inLanguage"
/// An organization's ISO 6523 identifier.
static let iso6523Code: Self = "iso6523Code"
/// An organization's registered name.
static let legalName: Self = "legalName"
/// The absolute URL of an organization's logo.
static let logo: Self = "logo"
/// The name of the thing a node describes.
static let name: Self = "name"
/// The postal code of a postal address.
static let postalCode: Self = "postalCode"
/// The organization publishing a website.
static let publisher: Self = "publisher"
/// The absolute URLs of the profiles that also identify the thing a node describes.
static let sameAs: Self = "sameAs"
/// The street and house number of a postal address.
static let streetAddress: Self = "streetAddress"
/// The phone number of the thing a node describes.
static let telephone: Self = "telephone"
/// The absolute URL of the thing a node describes.
static let url: Self = "url"
/// An organization's VAT number.
static let vatID: Self = "vatID"
}
#endif
@@ -1,3 +1,4 @@
#if StructuredData
extension StructuredData {
/// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node.
///
@@ -69,3 +70,4 @@ extension StructuredData.Value {
}
}
#endif
@@ -1,3 +1,4 @@
#if Analytics
import Elementary
import Testing
@@ -57,3 +58,4 @@ struct AnalyticsEventTaggingTests {
}
}
#endif
@@ -0,0 +1,68 @@
#if Analytics
import Elementary
import Testing
@testable import Infrastructure
@Suite(
"MarkupContent+Reporting extension",
.tags(.extension)
)
struct MarkupContentReportingTests {
// MARK: Methods tests
@Test
func `renders the same markup as applying the event's tagging`() {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
let reported = a(.href("/")) { "Listen" }
.reporting(event)
.render()
let tagged = a(.href("/")) { "Listen" }
.attributes(contentsOf: event.tagging())
.render()
#expect(reported == tagged)
#expect(reported == #"<a href="/" data-umami-event="playlist" data-umami-event-set="avc-xi">Listen</a>"#)
}
@Test
func `keeps the attributes chained before it`() {
let rendered = a(.href("https://example.com")) { "Listen" }
.attributes(.target(.blank), when: true)
.reporting(.init(name: "playlist"))
.render()
#expect(rendered == #"<a href="https://example.com" target="_blank" data-umami-event="playlist">Listen</a>"#)
}
@Test
func `tags a view under its own prefix`() {
let rendered = h2 { "KLM" }
.reporting(
.init(
name: "case_view",
properties: ["case": "klm"]
),
on: .view
)
.render()
#expect(rendered == #"<h2 data-view-event="case_view" data-view-event-case="klm">KLM</h2>"#)
}
@Test
func `reports from an SVG tag`() {
let rendered = SVG.path {}
.reporting(.init(name: "logo"))
.render()
#expect(rendered.contains(#"data-umami-event="logo""#))
}
}
#endif
@@ -101,6 +101,7 @@ struct PageTests {
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
}
#if StructuredData
@Test
func `omits the structured data script by default`() {
let html = StubPage().render()
@@ -111,21 +112,25 @@ struct PageTests {
@Test
func `renders the structured data script when provided`() {
let html = StubPage(structuredData: .init(
name: "Stub Site",
url: "https://stub.example/",
logo: "https://stub.example/logo.png",
profiles: ["https://social.example/stub"]
organization: .init(
name: "Stub Site",
url: "https://stub.example/",
logo: "https://stub.example/logo.png",
profiles: ["https://social.example/stub"]
)
)).render()
#expect(html.contains(
#"<script type="application/ld+json">"# +
#"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
#"{"@type":"WebSite","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
#"{"@type":"WebSite","@id":"https://stub.example/#website","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
#"</script>"#
))
}
#endif
#if Analytics
@Test
func `omits the analytics tracker by default`() {
let html = StubPage().render()
@@ -194,6 +199,7 @@ struct PageTests {
#expect(!html.contains("data-do-not-track"))
#expect(!html.contains("data-performance"))
}
#endif
@Test
func `appends the version token to the asset URLs`() {
@@ -1,3 +1,4 @@
#if Analytics
import Testing
@testable import Infrastructure
@@ -35,6 +36,21 @@ struct AnalyticsEventTests {
))
}
/// The tracker ignores `data-view-event`, so a view is never recorded as a click.
@Test
func `renders a view under its own prefix`() {
let event = Analytics.Event(
name: "case_view",
properties: ["case": "klm"]
)
#expect(event.attributes(on: .view) == [
.init("data-view-event", value: "case_view"),
.init("data-view-event-case", value: "klm"),
])
#expect(event.attributes == event.attributes(on: .click))
}
@Test
func `renders the name first, then the properties in the order given`() {
let event = Analytics.Event(
@@ -103,3 +119,4 @@ struct AnalyticsEventTests {
}
}
#endif
@@ -1,3 +1,4 @@
#if Analytics
import Testing
@testable import Infrastructure
@@ -133,3 +134,4 @@ struct AnalyticsTests {
}
}
#endif
@@ -40,6 +40,7 @@ struct SocialCardTests {
.init("2400", name: .imageWidth),
.init("1260", name: .imageHeight),
.init("An image.", name: .imageAlt),
.init("An image.", name: .twitterImageAlt),
.init("summary_large_image", name: .twitter),
])
}
@@ -70,6 +71,7 @@ struct SocialCardTests {
#expect(names.contains(.image))
#expect(!names.contains(.imageAlt))
#expect(!names.contains(.twitterImageAlt))
}
@Test
@@ -87,6 +89,7 @@ struct SocialCardTests {
@Test
func `keys a tag by the attribute its name dictates`() {
#expect(SocialCard.Tag.Name.twitter.attribute == .name)
#expect(SocialCard.Tag.Name.twitterImageAlt.attribute == .name)
#expect(SocialCard.Tag.Name.title.attribute == .property)
#expect(SocialCard.Tag("website", name: .type).attribute == .property)
}
@@ -1,3 +1,4 @@
#if StructuredData
import Foundation
import Testing
@@ -14,46 +15,80 @@ struct StructuredDataTests {
@Test
func `derives the full payload from complete data`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
logo: "https://site.example/logo.png",
inLanguage: ["en", "nl"],
profiles: [
"https://social.example/a-site",
"https://videos.example/a-site",
]
organization: .init(
name: "A Site",
url: "https://site.example/",
logo: "https://site.example/logo.png",
profiles: [
"https://social.example/a-site",
"https://videos.example/a-site",
]
),
inLanguage: ["en", "nl"]
)
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
#"{"@type":"WebSite","@id":"https://site.example/#website","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
)
}
@Test
func `carries the organization's alternate name, description, area, and address in that order`() {
func `carries the organization's optional facts in order`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
alternateName: "The Site",
description: "What the site is.",
areaServed: "A City",
email: "hello@site.example"
organization: .init(
name: "A Site",
url: "https://site.example/",
alternateName: "The Site",
legalName: "A Site B.V.",
description: "What the site is.",
areaServed: "A City",
email: "hello@site.example",
telephone: "+31000000000",
address: .init(
street: "A Street 1",
postalCode: "1000 AA",
locality: "A City",
country: "NL"
),
vatID: "NL000000000B01",
iso6523Code: "0106:00000000",
foundingDate: "2000"
)
)
#expect(data.payload.contains(
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","description":"What the site is.","areaServed":"A City","email":"hello@site.example""#
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","legalName":"A Site B.V.","description":"What the site is.","# +
#""areaServed":"A City","email":"hello@site.example","telephone":"+31000000000","# +
#""address":{"@type":"PostalAddress","streetAddress":"A Street 1","postalCode":"1000 AA","addressLocality":"A City","addressCountry":"NL"},"# +
#""vatID":"NL000000000B01","iso6523Code":"0106:00000000","foundingDate":"2000"}"#
))
}
@Test
func `omits the optional facts when the data carries none`() {
let payload = StructuredData(
organization: .init(
name: "A Site",
url: "https://site.example/"
)
).payload
for name in ["legalName", "telephone", "address", "vatID", "iso6523Code", "foundingDate"] {
#expect(!payload.contains(name))
}
}
@Test
func `references the organization's founder by identifier`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
profiles: ["https://social.example/a-site"],
founder: "https://site.example/who#person"
organization: .init(
name: "A Site",
url: "https://site.example/",
profiles: ["https://social.example/a-site"],
founder: "https://site.example/who#person"
)
)
// Last of the organization's properties, after the profiles.
@@ -65,8 +100,10 @@ struct StructuredDataTests {
@Test
func `omits the founder when the data carries none`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/"
organization: .init(
name: "A Site",
url: "https://site.example/"
)
)
#expect(!data.payload.contains("founder"))
@@ -76,8 +113,10 @@ struct StructuredDataTests {
func `derives the organization's identifier from the site URL`() {
let url = "https://site.example/"
let data = StructuredData(
name: "A Site",
url: url
organization: .init(
name: "A Site",
url: url
)
)
// Another page references the organization by this, so it must name the node the initializer actually builds.
@@ -85,16 +124,33 @@ struct StructuredDataTests {
#expect(data.payload.contains(##""@id":"\##(StructuredData.organizationID(forSiteURL: url))""##))
}
@Test
func `derives the website's identifier from the site URL`() {
let url = "https://site.example/"
let data = StructuredData(
organization: .init(
name: "A Site",
url: url
)
)
// A page's own node references the website by this, so it must name the node the initializer actually builds.
#expect(StructuredData.websiteID(forSiteURL: url) == "https://site.example/#website")
#expect(data.payload.contains(##""@type":"WebSite","@id":"\##(StructuredData.websiteID(forSiteURL: url))""##))
}
@Test
func `omits the properties of the facts minimal data does not carry`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/"
organization: .init(
name: "A Site",
url: "https://site.example/"
)
)
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
#"{"@type":"WebSite","@id":"https://site.example/#website","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
)
}
@@ -125,8 +181,10 @@ struct StructuredDataTests {
@Test
func `escapes the values it embeds in the payload`() {
let data = StructuredData(
name: #"A "Quoted" \ Site"#,
url: "https://site.example/</script>"
organization: .init(
name: #"A "Quoted" \ Site"#,
url: "https://site.example/</script>"
)
)
#expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
@@ -199,10 +257,12 @@ struct StructuredDataTests {
func `derives a payload that parses back to the facts it carries`() throws {
let name = "A \"Site\"\nwith \\ every <hazard>"
let data = StructuredData(
name: name,
url: "https://site.example/</script>",
logo: "https://site.example/logo.png",
profiles: ["https://social.example/a-site"]
organization: .init(
name: name,
url: "https://site.example/</script>",
logo: "https://site.example/logo.png",
profiles: ["https://social.example/a-site"]
)
)
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
@@ -215,3 +275,4 @@ struct StructuredDataTests {
}
}
#endif
@@ -7,8 +7,10 @@ struct StubPage: Page {
// MARK: Properties
#if Analytics
/// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
let analytics: Analytics?
var analytics: Analytics? = nil
#endif
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
@@ -22,8 +24,10 @@ struct StubPage: Page {
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
let socialCard: SocialCard?
#if StructuredData
/// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
let structuredData: StructuredData?
var structuredData: StructuredData? = nil
#endif
/// The summary rendered in the document head, or `nil` to omit it.
let summary: String?
@@ -37,29 +41,21 @@ 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
/// head, or `nil` (the default) to omit it.
/// - summary: the summary rendered in the document head, or `nil` (the default)
/// to omit it.
init(
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
self.socialCard = socialCard
self.structuredData = structuredData
self.summary = summary
}
@@ -99,3 +95,37 @@ struct StubPage: Page {
}
}
#if Analytics
// MARK: - Analytics
extension StubPage {
// MARK: Initializers
/// Creates a stub page embedding the given analytics tracker.
/// - Parameter analytics: the analytics tracker rendered as a deferred script in the document head.
init(analytics: Analytics) {
self.init()
self.analytics = analytics
}
}
#endif
#if StructuredData
// MARK: - Structured data
extension StubPage {
// MARK: Initializers
/// Creates a stub page with the given structured data.
/// - Parameter structuredData: the structured data to render.
init(structuredData: StructuredData) {
self.init()
self.structuredData = structuredData
}
}
#endif
+13 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -35,3 +35,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+1 -1
View File
@@ -38,6 +38,6 @@ Tests/
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`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- 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.
+13 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -97,3 +97,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+15 -10
View File
@@ -8,13 +8,18 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
| 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` within a deadline |
| Scaffolding | The internal `ExampleRecord` and `CreateExampleRecord`, and the public `Example` snapshot and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot, and keeps each account's `Passkey`s — one is only ever registered together with a new account; `CreateUser` seeds the prototype's demo accounts |
| Collecting | `CollectingRepository`, which keeps an account's `Want`s with the listings meeting each, its collection of owned issues, the `Series` catalogue, and the upcoming `Event`s with the shops trading at each |
| Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions |
| Catalogue | `CatalogueRepository`, which opens a shop from a `ShopDraft`, publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
## 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. 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` 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.
- **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 is migrated at launch, 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. `Listing`) instead.
- **Registration order is not creation order.** `AddTradeColumns`, `AddListingDeclarations`, and `AddShopRegistration` are registered ahead of `SeedCatalogue`, whose models write their columns; Fluent tracks migrations by name, so a database already past the seed still gains them.
- **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives.
- **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`.
@@ -26,21 +31,21 @@ Sources are split by visibility, then by kind, one type per file:
```
Sources/
├── Public/
│ ├── Enumerations/ Driver, TLS
│ ├── Enumerations/ Driver, TLS, ShopError, TradeError
│ ├── Methods/ Service, PrepareDB, Probe
│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Event, Conversation, Offer, Sale
│ ├── Repositories/ CatalogueRepository, AccountRepository, CollectingRepository, TradeRepository
│ └── Types/ Configuration
└── Internal/
├── Migrations/ CreateExampleRecord
├── Models/ ExampleRecord
└── Repositories/ ExampleRepository (returning the Example snapshot)
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, AddShopRegistration, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey, CreateCollecting, CreateCommunity
├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, WantRecord, SeriesRecord, EventRecord, EventShopRecord, OwnedIssueRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
└── Seeds/ Seed
Tests/
├── Cases/ the test suites, mirroring the Sources/ layout
└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the
plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants
```
> **Exception:** `Example` and `ExampleRepository` are `public` despite sitting under `Internal/`, so they are API a service can call. Move them to `Public/Models` and `Public/Repositories` when the first real domain model replaces them.
## Testing
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
@@ -53,6 +58,6 @@ Outside the application's service group, a built `Fluent` service must be shut d
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`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- 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.
@@ -0,0 +1,29 @@
import FluentKit
/// Adds the declared defects and the photos to listings.
///
/// Registered ahead of ``SeedCatalogue``, whose model writes both columns.
struct AddListingDeclarations: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
// One column per update: SQLite adds no more in a single statement.
try await database.schema(ListingRecord.schema)
.field("defects", .string)
.update()
try await database.schema(ListingRecord.schema)
.field("photos", .string)
.update()
}
func revert(on database: Database) async throws {
try await database.schema(ListingRecord.schema)
.deleteField("photos")
.update()
try await database.schema(ListingRecord.schema)
.deleteField("defects")
.update()
}
}
@@ -0,0 +1,30 @@
import FluentKit
/// Adds the seller kind and the business registration to shops.
///
/// Registered ahead of ``SeedCatalogue``, whose model writes the columns. One column per update: SQLite adds no more in a statement.
struct AddShopRegistration: AsyncMigration {
// MARK: Constants
private static let columns = ["kind", "kvk_number", "vat_id", "legal_name"]
// MARK: Methods
func prepare(on database: Database) async throws {
for column in Self.columns {
try await database.schema(ShopRecord.schema)
.field(.string(column), .string)
.update()
}
}
func revert(on database: Database) async throws {
for column in Self.columns.reversed() {
try await database.schema(ShopRecord.schema)
.deleteField(.string(column))
.update()
}
}
}
@@ -0,0 +1,29 @@
import FluentKit
/// Adds an owner to shops and a sold date to listings.
///
/// Registered ahead of ``SeedCatalogue``, whose models write both columns. The owner carries no foreign key: on a fresh database the
/// `users` table does not exist yet at this point.
struct AddTradeColumns: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(ShopRecord.schema)
.field("owner_id", .uuid)
.update()
try await database.schema(ListingRecord.schema)
.field("sold_at", .datetime)
.update()
}
func revert(on database: Database) async throws {
try await database.schema(ListingRecord.schema)
.deleteField("sold_at")
.update()
try await database.schema(ShopRecord.schema)
.deleteField("owner_id")
.update()
}
}
@@ -0,0 +1,41 @@
import FluentKit
/// Creates the `wants` table, and the `series` table seeded with the runs the demo listings belong to.
struct CreateCollecting: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(WantRecord.schema)
.id()
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
.field("title", .string, .required)
.field("min_grade", .double)
.field("max_price_cents", .int)
.field("slabbed_only", .bool, .required)
.field("alerts", .bool, .required)
.field("created_at", .datetime, .required)
.field("seen_at", .datetime, .required)
.create()
try await database.schema(SeriesRecord.schema)
.id()
.field("slug", .string, .required)
.field("name", .string, .required)
.field("publisher", .string, .required)
.field("years", .string, .required)
.field("issue_count", .int, .required)
.field("blurb", .string, .required)
.unique(on: "slug")
.create()
for series in Seed.series {
try await SeriesRecord(series).save(on: database)
}
}
func revert(on database: Database) async throws {
try await database.schema(SeriesRecord.schema).delete()
try await database.schema(WantRecord.schema).delete()
}
}
@@ -0,0 +1,49 @@
import FluentKit
/// Creates the `events`, `event_shops`, and `owned_issues` tables, seeded with the demo cons and the shops trading at them.
struct CreateCommunity: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(EventRecord.schema)
.id()
.field("slug", .string, .required)
.field("name", .string, .required)
.field("venue", .string, .required)
.field("starts_on", .datetime, .required)
.field("ends_on", .datetime, .required)
.unique(on: "slug")
.create()
try await database.schema(EventShopRecord.schema)
.id()
.field("event_id", .uuid, .required, .references(EventRecord.schema, "id", onDelete: .cascade))
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id", onDelete: .cascade))
.unique(on: "event_id", "shop_id")
.create()
try await database.schema(OwnedIssueRecord.schema)
.id()
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
.field("series_slug", .string, .required)
.field("issue", .int, .required)
.unique(on: "user_id", "series_slug", "issue")
.create()
for (event, shops) in Seed.events(after: .init()) {
let record = EventRecord(event)
try await record.save(on: database)
for shop in try await ShopRecord.query(on: database).filter(\.$slug ~~ shops).all() {
try await record.$shops.attach(shop, on: database)
}
}
}
func revert(on database: Database) async throws {
for schema in [OwnedIssueRecord.schema, EventShopRecord.schema, EventRecord.schema] {
try await database.schema(schema).delete()
}
}
}
@@ -1,28 +0,0 @@
import FluentKit
/// Creates and drops the `example_records` table backing ``ExampleRecord``.
///
/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a
/// domain model is defined. Migrations are append-only in production — add a new migration to alter the
/// schema rather than editing one that has already run.
struct CreateExampleRecord: AsyncMigration {
// MARK: Methods
/// Creates the `example_records` table with an `id` primary key and a required `name` column.
/// - Parameter database: the database the schema change is applied to.
func prepare(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.id()
.field("name", .string, .required)
.create()
}
/// Drops the `example_records` table, reverting ``prepare(on:)``.
/// - Parameter database: the database the schema change is applied to.
func revert(on database: Database) async throws {
try await database.schema(ExampleRecord.schema)
.delete()
}
}
@@ -0,0 +1,46 @@
import FluentKit
/// Creates and drops the `listings` table backing ``ListingRecord``.
struct CreateListing: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(ListingRecord.schema)
.id()
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id", onDelete: .cascade))
.field("slug", .string, .required)
.field("title", .string, .required)
.field("series_title", .string, .required)
.field("issue_label", .string, .required)
.field("publisher", .string, .required)
.field("year", .int, .required)
.field("format", .string, .required)
.field("printing", .string, .required)
.field("printing_detail", .string, .required)
.field("is_first_print", .bool, .required)
.field("grader", .string)
.field("grade", .double)
.field("condition", .string, .required)
.field("cert_number", .string)
.field("page_quality", .string)
.field("key_notes", .string)
.field("seller_notes", .string, .required)
.field("price_cents", .int, .required)
.field("shipping_cents", .int, .required)
.field("accepts_offers", .bool, .required)
.field("cover_tone", .string, .required)
.field("cover_title", .string, .required)
.field("cover_imprint", .string, .required)
.field("cover_number", .string, .required)
.field("listed_at", .datetime, .required)
.unique(on: "slug")
.create()
}
func revert(on database: Database) async throws {
try await database.schema(ListingRecord.schema)
.delete()
}
}
@@ -0,0 +1,25 @@
import FluentKit
/// Creates and drops the `passkeys` table backing ``PasskeyRecord``.
struct CreatePasskey: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(PasskeyRecord.schema)
.id()
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
.field("credential_id", .string, .required)
.field("public_key", .string, .required)
.field("sign_count", .int, .required)
.field("created_at", .datetime, .required)
.unique(on: "credential_id")
.create()
}
func revert(on database: Database) async throws {
try await database.schema(PasskeyRecord.schema)
.delete()
}
}
@@ -0,0 +1,33 @@
import FluentKit
/// Creates and drops the `shops` table backing ``ShopRecord``.
struct CreateShop: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(ShopRecord.schema)
.id()
.field("slug", .string, .required)
.field("name", .string, .required)
.field("city", .string, .required)
.field("country", .string, .required)
.field("since_year", .int, .required)
.field("is_verified", .bool, .required)
.field("rating", .double, .required)
.field("rated_sales", .int, .required)
.field("sold_count", .int, .required)
.field("handling_days", .int, .required)
.field("ships_note", .string, .required)
.field("returns_note", .string, .required)
.field("about", .string, .required)
.unique(on: "slug")
.create()
}
func revert(on database: Database) async throws {
try await database.schema(ShopRecord.schema)
.delete()
}
}
@@ -0,0 +1,82 @@
import FluentKit
/// Creates the conversations, offers, sales, and messages tables, and hands the demo shops to seller accounts, created here when missing.
struct CreateTrade: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(ConversationRecord.schema)
.id()
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id"))
.field("listing_id", .uuid, .references(ListingRecord.schema, "id"))
.field("created_at", .datetime, .required)
.field("buyer_read_at", .datetime, .required)
.field("seller_read_at", .datetime, .required)
.create()
try await database.schema(OfferRecord.schema)
.id()
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
.field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id"))
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
.field("amount_cents", .int, .required)
.field("status", .string, .required)
.field("created_at", .datetime, .required)
.field("expires_at", .datetime, .required)
.create()
try await database.schema(SaleRecord.schema)
.id()
.field("number", .int, .required)
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
.field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id"))
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
.field("seller_id", .uuid, .required, .references(UserRecord.schema, "id"))
.field("price_cents", .int, .required)
.field("asked_cents", .int, .required)
.field("shipping_cents", .int, .required)
.field("declared_grade", .string, .required)
.field("declared_page_quality", .string)
.field("declared_printing", .string, .required)
.field("declared_notes", .string, .required)
.field("agreed_at", .datetime, .required)
.field("paid_at", .datetime)
.field("payment_note", .string)
.field("sent_at", .datetime)
.field("shipping_note", .string)
.field("received_at", .datetime)
.field("buyer_rating", .int)
.field("seller_rating", .int)
.unique(on: "number")
.create()
try await database.schema(MessageRecord.schema)
.id()
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
.field("sender_id", .uuid, .required, .references(UserRecord.schema, "id"))
.field("kind", .string, .required)
.field("body", .string, .required)
.field("offer_id", .uuid, .references(OfferRecord.schema, "id"))
.field("sale_number", .int)
.field("created_at", .datetime, .required)
.create()
for (slug, seller) in Seed.owners {
let owner = try await UserRecord.query(on: database)
.filter(\.$email == seller.email)
.first() ?? UserRecord(email: seller.email, displayName: seller.displayName)
try await owner.save(on: database)
try await ShopRecord.query(on: database)
.filter(\.$slug == slug)
.set(\.$owner.$id, to: try owner.requireID())
.update()
}
}
func revert(on database: Database) async throws {
for schema in [MessageRecord.schema, SaleRecord.schema, OfferRecord.schema, ConversationRecord.schema] {
try await database.schema(schema).delete()
}
}
}
@@ -0,0 +1,26 @@
import FluentKit
/// Creates and drops the `users` table backing ``UserRecord``, seeded with the prototype's demo accounts.
struct CreateUser: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(UserRecord.schema)
.id()
.field("email", .string, .required)
.field("display_name", .string, .required)
.unique(on: "email")
.create()
for account in Seed.accounts {
try await UserRecord(email: account.email, displayName: account.displayName).save(on: database)
}
}
func revert(on database: Database) async throws {
try await database.schema(UserRecord.schema)
.delete()
}
}
@@ -0,0 +1,21 @@
import FluentKit
import Foundation
/// Re-dates the demo cons from the first weekend after it runs, for databases seeded when ``CreateCommunity`` used fixed dates.
struct RedateEvents: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
for (event, _) in Seed.events(after: .init()) {
try await EventRecord.query(on: database)
.filter(\.$slug == event.slug)
.set(\.$startsOn, to: event.startsOn)
.set(\.$endsOn, to: event.endsOn)
.update()
}
}
func revert(on database: Database) async throws {}
}
@@ -0,0 +1,27 @@
import FluentKit
/// Fills the catalogue with the prototype's demo shops and listings.
struct SeedCatalogue: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
for shop in Seed.shops {
let record = ShopRecord(shop)
try await record.save(on: database)
let shopID = try record.requireID()
for listing in Seed.listings where listing.shop.slug == shop.slug {
try await ListingRecord(listing, shopID: shopID).save(on: database)
}
}
}
func revert(on database: Database) async throws {
try await ListingRecord.query(on: database).delete()
try await ShopRecord.query(on: database).delete()
}
}
@@ -0,0 +1,103 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `events` row: a con where shops trade in person.
final class EventRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "events"
@ID(key: .id)
var id: UUID?
@Field(key: "slug")
var slug: String
@Field(key: "name")
var name: String
@Field(key: "venue")
var venue: String
@Field(key: "starts_on")
var startsOn: Date
@Field(key: "ends_on")
var endsOn: Date
@Siblings(through: EventShopRecord.self, from: \.$event, to: \.$shop)
var shops: [ShopRecord]
// MARK: Initializers
init() {}
init(
_ event: Event
) {
self.slug = event.slug
self.name = event.name
self.venue = event.venue
self.startsOn = event.startsOn
self.endsOn = event.endsOn
}
}
/// A FluentKit model of a single `event_shops` row: a shop trading at an event.
final class EventShopRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "event_shops"
@ID(key: .id)
var id: UUID?
@Parent(key: "event_id")
var event: EventRecord
@Parent(key: "shop_id")
var shop: ShopRecord
// MARK: Initializers
init() {}
}
/// A FluentKit model of a single `owned_issues` row: an issue of a series in an account's collection.
final class OwnedIssueRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "owned_issues"
@ID(key: .id)
var id: UUID?
@Parent(key: "user_id")
var user: UserRecord
@Field(key: "series_slug")
var seriesSlug: String
@Field(key: "issue")
var issue: Int
// MARK: Initializers
init() {}
init(
userID: UUID,
seriesSlug: String,
issue: Int
) {
self.$user.id = userID
self.seriesSlug = seriesSlug
self.issue = issue
}
}
@@ -0,0 +1,61 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `conversations` row: a buyer and a shop, talking about a listing or the shop at large.
final class ConversationRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "conversations"
@ID(key: .id)
var id: UUID?
@Parent(key: "buyer_id")
var buyer: UserRecord
@Parent(key: "shop_id")
var shop: ShopRecord
@OptionalParent(key: "listing_id")
var listing: ListingRecord?
@Field(key: "created_at")
var createdAt: Date
/// When the buyer last opened the conversation; what came later is unread to them.
@Field(key: "buyer_read_at")
var buyerReadAt: Date
/// When the seller last opened the conversation; what came later is unread to them.
@Field(key: "seller_read_at")
var sellerReadAt: Date
@Children(for: \.$conversation)
var messages: [MessageRecord]
@Children(for: \.$conversation)
var offers: [OfferRecord]
@Children(for: \.$conversation)
var sales: [SaleRecord]
// MARK: Initializers
init() {}
init(
buyerID: UUID,
shopID: UUID,
listingID: UUID?,
at date: Date
) {
self.$buyer.id = buyerID
self.$shop.id = shopID
self.$listing.id = listingID
self.createdAt = date
self.buyerReadAt = date
self.sellerReadAt = date
}
}
@@ -1,45 +0,0 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `example_records` row.
///
/// This is reference scaffolding: it demonstrates the model → migration → repository pattern the rest of
/// the package is built around, and is what the tests exercise. Replace it with the first real domain model
/// (paired with its own migration and repository) once one is defined.
///
/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never
/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the
/// conformance is declared `@unchecked Sendable`.
final class ExampleRecord: Model, @unchecked Sendable {
// MARK: Properties
/// The name of the backing table.
static let schema = "example_records"
/// The row's primary key, assigned on first save.
@ID(key: .id)
var id: UUID?
/// The row's name column.
@Field(key: "name")
var name: String
// MARK: Initializers
/// Creates an empty record, as required by FluentKit to hydrate query results.
init() {}
/// Creates a record with the given values.
/// - Parameters:
/// - id: the primary key, or `nil` to have one assigned on save.
/// - name: the value of the name column.
init(
id: UUID? = nil,
name: String
) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,192 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `listings` row.
///
/// The catalogue fields (series, issue, publisher) are folded into the listing; they move to tables of their own once the series screen needs them.
final class ListingRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "listings"
@ID(key: .id)
var id: UUID?
@Parent(key: "shop_id")
var shop: ShopRecord
@Field(key: "slug")
var slug: String
@Field(key: "title")
var title: String
@Field(key: "series_title")
var seriesTitle: String
@Field(key: "issue_label")
var issueLabel: String
@Field(key: "publisher")
var publisher: String
@Field(key: "year")
var year: Int
@Field(key: "format")
var format: String
@Field(key: "printing")
var printing: String
@Field(key: "printing_detail")
var printingDetail: String
@Field(key: "is_first_print")
var isFirstPrint: Bool
@OptionalField(key: "grader")
var grader: String?
@OptionalField(key: "grade")
var grade: Double?
@Field(key: "condition")
var condition: String
@OptionalField(key: "cert_number")
var certNumber: String?
@OptionalField(key: "page_quality")
var pageQuality: String?
@OptionalField(key: "key_notes")
var keyNotes: String?
@Field(key: "seller_notes")
var sellerNotes: String
@Field(key: "price_cents")
var priceCents: Int
@Field(key: "shipping_cents")
var shippingCents: Int
@Field(key: "accepts_offers")
var acceptsOffers: Bool
@Field(key: "cover_tone")
var coverTone: String
@Field(key: "cover_title")
var coverTitle: String
@Field(key: "cover_imprint")
var coverImprint: String
@Field(key: "cover_number")
var coverNumber: String
@Field(key: "listed_at")
var listedAt: Date
@OptionalField(key: "sold_at")
var soldAt: Date?
/// The declared defects, one per line.
@OptionalField(key: "defects")
var defects: String?
/// The URL paths of the photos, one per line, the front first.
@OptionalField(key: "photos")
var photos: String?
// MARK: Initializers
init() {}
/// Creates a record from a snapshot.
/// - Parameters:
/// - listing: the snapshot the record takes its values from.
/// - shopID: the identifier of the shop the listing belongs to.
init(
_ listing: Listing,
shopID: UUID
) {
self.$shop.id = shopID
self.slug = listing.slug
self.title = listing.title
self.seriesTitle = listing.seriesTitle
self.issueLabel = listing.issueLabel
self.publisher = listing.publisher
self.year = listing.year
self.format = listing.format
self.printing = listing.printing
self.printingDetail = listing.printingDetail
self.isFirstPrint = listing.isFirstPrint
self.grader = listing.grader
self.grade = listing.grade
self.condition = listing.condition
self.certNumber = listing.certNumber
self.pageQuality = listing.pageQuality
self.keyNotes = listing.keyNotes
self.sellerNotes = listing.sellerNotes
self.priceCents = listing.priceCents
self.shippingCents = listing.shippingCents
self.acceptsOffers = listing.acceptsOffers
self.coverTone = listing.cover.tone
self.coverTitle = listing.cover.title
self.coverImprint = listing.cover.imprint
self.coverNumber = listing.cover.number
self.listedAt = listing.listedAt
self.defects = listing.defects.isEmpty ? nil : listing.defects.joined(separator: "\n")
self.photos = listing.photos.isEmpty ? nil : listing.photos.joined(separator: "\n")
}
}
// MARK: - Extensions
extension ListingRecord {
// MARK: Computed
/// The `Sendable` snapshot of the record; its shop must be eager-loaded.
var snapshot: Listing {
.init(
slug: slug,
shop: shop.snapshot,
title: title,
seriesTitle: seriesTitle,
issueLabel: issueLabel,
publisher: publisher,
year: year,
format: format,
printing: printing,
printingDetail: printingDetail,
isFirstPrint: isFirstPrint,
grader: grader,
grade: grade,
condition: condition,
certNumber: certNumber,
pageQuality: pageQuality,
keyNotes: keyNotes,
sellerNotes: sellerNotes,
priceCents: priceCents,
shippingCents: shippingCents,
acceptsOffers: acceptsOffers,
cover: .init(
tone: coverTone,
title: coverTitle,
imprint: coverImprint,
number: coverNumber
),
listedAt: listedAt,
soldAt: soldAt,
defects: defects?.split(separator: "\n").map(String.init) ?? [],
photos: photos?.split(separator: "\n").map(String.init) ?? []
)
}
}
@@ -0,0 +1,58 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `messages` row: something said, an offer made, or an event stamped into a conversation.
final class MessageRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "messages"
@ID(key: .id)
var id: UUID?
@Parent(key: "conversation_id")
var conversation: ConversationRecord
/// Who wrote the message, made the offer, or caused the event.
@Parent(key: "sender_id")
var sender: UserRecord
@Field(key: "kind")
var kind: String
@Field(key: "body")
var body: String
@OptionalParent(key: "offer_id")
var offer: OfferRecord?
@OptionalField(key: "sale_number")
var saleNumber: Int?
@Field(key: "created_at")
var createdAt: Date
// MARK: Initializers
init() {}
init(
conversationID: UUID,
senderID: UUID,
kind: Conversation.Entry.Kind,
body: String,
offerID: UUID? = nil,
saleNumber: Int? = nil,
at date: Date
) {
self.$conversation.id = conversationID
self.$sender.id = senderID
self.kind = kind.rawValue
self.body = body
self.$offer.id = offerID
self.saleNumber = saleNumber
self.createdAt = date
}
}
@@ -0,0 +1,82 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `offers` row.
final class OfferRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "offers"
@ID(key: .id)
var id: UUID?
@Parent(key: "conversation_id")
var conversation: ConversationRecord
@Parent(key: "listing_id")
var listing: ListingRecord
@Parent(key: "buyer_id")
var buyer: UserRecord
@Field(key: "amount_cents")
var amountCents: Int
@Field(key: "status")
var status: String
@Field(key: "created_at")
var createdAt: Date
@Field(key: "expires_at")
var expiresAt: Date
// MARK: Initializers
init() {}
init(
conversationID: UUID,
listingID: UUID,
buyerID: UUID,
amountCents: Int,
at date: Date
) {
self.$conversation.id = conversationID
self.$listing.id = listingID
self.$buyer.id = buyerID
self.amountCents = amountCents
self.status = Offer.Status.open.rawValue
self.createdAt = date
self.expiresAt = date.addingTimeInterval(Offer.lifetime)
}
}
// MARK: - Extensions
extension OfferRecord {
// MARK: Methods
/// The `Sendable` snapshot of the saved record; its buyer, and its listing with its shop, must be eager-loaded.
/// - Parameter now: the moment an open offer's expiry is judged at.
func snapshot(
at now: Date
) throws -> Offer {
let stored = Offer.Status(rawValue: status) ?? .declined
return .init(
id: try requireID(),
conversationID: $conversation.id,
listing: listing.snapshot,
buyer: try buyer.snapshot,
amountCents: amountCents,
status: stored == .open && expiresAt <= now ? .expired : stored,
createdAt: createdAt,
expiresAt: expiresAt
)
}
}
@@ -0,0 +1,69 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `passkeys` row: a WebAuthn credential registered to an account.
final class PasskeyRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "passkeys"
@ID(key: .id)
var id: UUID?
@Parent(key: "user_id")
var user: UserRecord
/// The credential's identifier, base64url-encoded without padding.
@Field(key: "credential_id")
var credentialID: String
/// The credential's public key, COSE-encoded, as base64.
@Field(key: "public_key")
var publicKey: String
@Field(key: "sign_count")
var signCount: Int
@Field(key: "created_at")
var createdAt: Date
// MARK: Initializers
init() {}
init(
userID: UUID,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32,
at date: Date
) {
self.$user.id = userID
self.credentialID = credentialID
self.publicKey = Data(publicKey).base64EncodedString()
self.signCount = Int(signCount)
self.createdAt = date
}
}
// MARK: - Extensions
extension PasskeyRecord {
// MARK: Computed
/// The `Sendable` snapshot of the saved record; its user must be eager-loaded.
var snapshot: Passkey {
get throws {
.init(
credentialID: credentialID,
publicKey: Array(Data(base64Encoded: publicKey) ?? .init()),
signCount: UInt32(clamping: signCount),
user: try user.snapshot
)
}
}
}
@@ -0,0 +1,116 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `sales` row: the record of an agreement and of its handshake.
final class SaleRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "sales"
@ID(key: .id)
var id: UUID?
@Field(key: "number")
var number: Int
@Parent(key: "conversation_id")
var conversation: ConversationRecord
@Parent(key: "listing_id")
var listing: ListingRecord
@Parent(key: "buyer_id")
var buyer: UserRecord
@Parent(key: "seller_id")
var seller: UserRecord
@Field(key: "price_cents")
var priceCents: Int
@Field(key: "asked_cents")
var askedCents: Int
@Field(key: "shipping_cents")
var shippingCents: Int
@Field(key: "declared_grade")
var declaredGrade: String
@OptionalField(key: "declared_page_quality")
var declaredPageQuality: String?
@Field(key: "declared_printing")
var declaredPrinting: String
@Field(key: "declared_notes")
var declaredNotes: String
@Field(key: "agreed_at")
var agreedAt: Date
@OptionalField(key: "paid_at")
var paidAt: Date?
@OptionalField(key: "payment_note")
var paymentNote: String?
@OptionalField(key: "sent_at")
var sentAt: Date?
@OptionalField(key: "shipping_note")
var shippingNote: String?
@OptionalField(key: "received_at")
var receivedAt: Date?
@OptionalField(key: "buyer_rating")
var buyerRating: Int?
@OptionalField(key: "seller_rating")
var sellerRating: Int?
// MARK: Initializers
init() {}
}
// MARK: - Extensions
extension SaleRecord {
// MARK: Computed
/// The `Sendable` snapshot of the saved record; its buyer, its seller, and its listing with its shop must be eager-loaded.
var snapshot: Sale {
get throws {
.init(
number: number,
conversationID: $conversation.id,
listing: listing.snapshot,
buyer: try buyer.snapshot,
seller: try seller.snapshot,
priceCents: priceCents,
askedCents: askedCents,
shippingCents: shippingCents,
declared: .init(
grade: declaredGrade,
pageQuality: declaredPageQuality,
printing: declaredPrinting,
notes: declaredNotes
),
agreedAt: agreedAt,
paidAt: paidAt,
paymentNote: paymentNote,
sentAt: sentAt,
shippingNote: shippingNote,
receivedAt: receivedAt,
buyerRating: buyerRating,
sellerRating: sellerRating
)
}
}
}
@@ -0,0 +1,67 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `series` row: a run of issues in the catalogue.
final class SeriesRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "series"
@ID(key: .id)
var id: UUID?
@Field(key: "slug")
var slug: String
@Field(key: "name")
var name: String
@Field(key: "publisher")
var publisher: String
@Field(key: "years")
var years: String
@Field(key: "issue_count")
var issueCount: Int
@Field(key: "blurb")
var blurb: String
// MARK: Initializers
init() {}
init(
_ series: Series
) {
self.slug = series.slug
self.name = series.name
self.publisher = series.publisher
self.years = series.years
self.issueCount = series.issueCount
self.blurb = series.blurb
}
}
// MARK: - Extensions
extension SeriesRecord {
// MARK: Computed
/// The `Sendable` snapshot of the record.
var snapshot: Series {
.init(
slug: slug,
name: name,
publisher: publisher,
years: years,
issueCount: issueCount,
blurb: blurb
)
}
}
@@ -0,0 +1,131 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `shops` row.
final class ShopRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "shops"
@ID(key: .id)
var id: UUID?
@Field(key: "slug")
var slug: String
@Field(key: "name")
var name: String
@Field(key: "city")
var city: String
@Field(key: "country")
var country: String
@Field(key: "since_year")
var sinceYear: Int
@Field(key: "is_verified")
var isVerified: Bool
@Field(key: "rating")
var rating: Double
@Field(key: "rated_sales")
var ratedSales: Int
@Field(key: "sold_count")
var soldCount: Int
@Field(key: "handling_days")
var handlingDays: Int
@Field(key: "ships_note")
var shipsNote: String
@Field(key: "returns_note")
var returnsNote: String
@Field(key: "about")
var about: String
@OptionalParent(key: "owner_id")
var owner: UserRecord?
/// The seller kind token; `nil` reads as a business, which every seeded shop is.
@OptionalField(key: "kind")
var kind: String?
@OptionalField(key: "kvk_number")
var kvkNumber: String?
@OptionalField(key: "vat_id")
var vatID: String?
@OptionalField(key: "legal_name")
var legalName: String?
@Children(for: \.$shop)
var listings: [ListingRecord]
// MARK: Initializers
init() {}
/// Creates a record from a snapshot.
/// - Parameter shop: the snapshot the record takes its values from.
init(
_ shop: Shop
) {
self.slug = shop.slug
self.name = shop.name
self.city = shop.city
self.country = shop.country
self.sinceYear = shop.sinceYear
self.isVerified = shop.isVerified
self.rating = shop.rating
self.ratedSales = shop.ratedSales
self.soldCount = shop.soldCount
self.handlingDays = shop.handlingDays
self.shipsNote = shop.shipsNote
self.returnsNote = shop.returnsNote
self.about = shop.about
self.kind = shop.kind.rawValue
self.kvkNumber = shop.kvkNumber
self.vatID = shop.vatID
self.legalName = shop.legalName
}
}
// MARK: - Extensions
extension ShopRecord {
// MARK: Computed
/// The `Sendable` snapshot of the record.
var snapshot: Shop {
.init(
slug: slug,
name: name,
city: city,
country: country,
sinceYear: sinceYear,
isVerified: isVerified,
rating: rating,
ratedSales: ratedSales,
soldCount: soldCount,
handlingDays: handlingDays,
shipsNote: shipsNote,
returnsNote: returnsNote,
about: about,
kind: kind.flatMap(Shop.Kind.init) ?? .business,
kvkNumber: kvkNumber,
vatID: vatID,
legalName: legalName
)
}
}
@@ -0,0 +1,55 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `users` row.
final class UserRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "email")
var email: String
@Field(key: "display_name")
var displayName: String
// MARK: Initializers
init() {}
/// Creates a record with the given values.
/// - Parameters:
/// - email: the account's normalized email address.
/// - displayName: the name other people see.
init(
email: String,
displayName: String
) {
self.email = email
self.displayName = displayName
}
}
// MARK: - Extensions
extension UserRecord {
// MARK: Computed
/// The `Sendable` snapshot of the saved record.
var snapshot: User {
get throws {
.init(
id: try requireID(),
email: email,
displayName: displayName
)
}
}
}
@@ -0,0 +1,43 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `wants` row: a copy an account is looking for.
final class WantRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "wants"
@ID(key: .id)
var id: UUID?
@Parent(key: "user_id")
var user: UserRecord
@Field(key: "title")
var title: String
@OptionalField(key: "min_grade")
var minGrade: Double?
@OptionalField(key: "max_price_cents")
var maxPriceCents: Int?
@Field(key: "slabbed_only")
var slabbedOnly: Bool
@Field(key: "alerts")
var alerts: Bool
@Field(key: "created_at")
var createdAt: Date
/// When the account last looked at the want; matches listed later are new to them.
@Field(key: "seen_at")
var seenAt: Date
// MARK: Initializers
init() {}
}
@@ -1,63 +0,0 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries.
///
/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference
/// types that must not escape the database's execution context.
public struct Example: Sendable, Equatable {
// MARK: Properties
/// The record's primary key, or `nil` if it has never been saved.
public let id: UUID?
/// The record's name.
public let name: String
}
/// Reads and writes ``ExampleRecord`` rows through the default database.
///
/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the
/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning — so no
/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``;
/// replace it with the first real repository once a domain model is defined.
public struct ExampleRepository: Sendable {
// MARK: Properties
/// The service providing the default database the repository reads and writes through.
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(fluent: Fluent) {
self.fluent = fluent
}
// MARK: Methods
/// Inserts a record with the given name.
/// - Parameter name: the name of the record to insert.
/// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier.
public func create(name: String) async throws -> Example {
let record = ExampleRecord(name: name)
try await record.save(on: fluent.db())
return Example(id: record.id, name: record.name)
}
/// Fetches every record, ordered by name.
/// - Returns: a `Sendable` snapshot of each record, sorted by name.
public func all() async throws -> [Example] {
try await ExampleRecord.query(on: fluent.db())
.sort(\.$name)
.all()
.map { Example(id: $0.id, name: $0.name) }
}
}
@@ -0,0 +1,344 @@
import Foundation
/// The prototype's demo catalogue, taken from the design canvas.
enum Seed {
// MARK: Constants
/// The demo accounts: the buyer the stubbed passkey signs in, then a seller.
static let accounts: [(email: String, displayName: String)] = [
("mara.v@example.com", "Mara Visser"),
("joost@kestrelrow.example", "Joost de Wit"),
]
/// The seller account owning each demo shop, by the shop's slug.
static let owners: [(shop: String, seller: (email: String, displayName: String))] = [
("kestrel-row-comics", accounts[1]),
("third-panel-supply", ("hello@thirdpanel.example", "Noor Janssens")),
("longbox-atlantic", ("sales@longboxatlantic.example", "Owen Pryce")),
("silverfish-comics", ("shop@silverfish.example", "Fenna Bos")),
("inkwell-and-staple", ("post@inkwellstaple.example", "Lukas Brandt")),
]
/// The demo listings, newest first.
static let listings: [Listing] = [
single(
"nightward-1-cgc-9-8", kestrelRow, "Nightward", "1", "Meridian Comics", 2019,
price: 240, shipping: 6, grade: 9.8, condition: "NM/M", grader: "CGC", cert: "4312087-002",
pages: "White", key: "First appearance of the Nightward; first issue of the series",
offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
notes: "Bought from the original owner and sent for grading in one batch. Case is clean with no scuffs or scratches; inner well intact. Photos are of this exact copy — the label photo shows the full cert number, which you can check on the grader's site before you buy."
),
single(
"ferrous-saint-12", thirdPanel, "Ferrous Saint", "12", "Blackline", 1994,
price: 38, shipping: 7, grade: 9.0, condition: "VF/NM",
printing: "newsstand", printingDetail: "First printing, newsstand edition",
tone: "wine", lettering: "FERROUS\nSAINT", imprint: "BLACKLINE",
notes: "Newsstand copy with the barcode box. Light spine stress at the lower staple, otherwise sharp corners and full gloss."
),
single(
"echo-country-3", kestrelRow, "Echo Country", "3", "Harbour & Vale", 2021,
price: 14, shipping: 5, grade: 9.4, condition: "NM",
printing: "2nd print", printingDetail: "Second printing", isFirstPrint: false,
tone: "forest", lettering: "ECHO\nCOUNTRY", imprint: "HARBOUR & VALE",
notes: "Second printing with the recoloured cover. Read once, bagged and boarded since."
),
single(
"vermilion-lake-7-cgc-6-5", longbox, "Vermilion Lake", "7", "Meridian Comics", 1987,
price: 95, shipping: 14, grade: 6.5, condition: "FN+", grader: "CGC", cert: "3907714-011",
pages: "Off-white to white", key: "Origin of the Lake Warden",
printing: "direct", printingDetail: "First printing, direct edition", offers: true,
tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN",
notes: "Older slab with light shelf wear on the case. The grader's notes list a small corner crease and light spine stress."
),
single(
"paper-tigers-1-variant-b", silverfish, "Paper Tigers", "1", "Cutwater Press", 2023,
price: 22, shipping: 5, grade: 9.6, condition: "NM+",
key: "First issue of the series", printing: "variant B", printingDetail: "First printing, variant cover B",
tone: "slate", lettering: "PAPER\nTIGERS", imprint: "CUTWATER",
notes: "Pulled from the box on release day and never read. 1:10 retailer variant."
),
collected(
"hollow-comet-vol-4", thirdPanel, "Hollow Comet, Vol. 4", "Hollow Comet", "Vol. 4", "Sōkai Books", 2022,
format: "manga", price: 11, shipping: 5, condition: "Like new", printingDetail: "English first printing",
tone: "plum", lettering: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 4",
notes: "Read once. No spine creases, no sun fading; obi strip not included."
),
collected(
"nightward-the-first-year-hc", kestrelRow, "Nightward: The First Year", "Nightward", "Hardcover, collects #1–12", "Meridian Comics", 2022,
format: "hardcovers", price: 45, shipping: 8, condition: "Sealed", printingDetail: "First printing",
tone: "navy", lettering: "NIGHTWARD\nTHE FIRST\nYEAR", imprint: "MERIDIAN", number: "HC",
notes: "Still in the publisher's shrink-wrap. Ships in a book box with corner protection."
),
collected(
"solar-divide-vol-1", longbox, "Solar Divide, Vol. 1", "Solar Divide", "Vol. 1", "Sōkai Books", 2020,
format: "manga", price: 28, shipping: 12, condition: "Very good", printingDetail: "English first printing",
tone: "brick", lettering: "SOLAR\nDIVIDE", imprint: "SŌKAI BOOKS", number: "VOL 1",
notes: "Out of print in English. Light shelf wear to the lower edge; pages clean."
),
collected(
"the-gilded-hour-tpb", silverfish, "The Gilded Hour", "The Gilded Hour", "Trade paperback, complete", "Harbour & Vale", 2019,
format: "trade-paperbacks", price: 19, shipping: 6, condition: "Like new", printing: "3rd printing",
printingDetail: "Third printing", isFirstPrint: false,
tone: "olive", lettering: "THE\nGILDED\nHOUR", imprint: "HARBOUR & VALE", number: "TPB",
notes: "Third printing with the corrected lettering in chapter four. Spine uncreased."
),
collected(
"ferrous-saint-omnibus-hc", thirdPanel, "Ferrous Saint Omnibus", "Ferrous Saint", "Hardcover omnibus, collects #1–30", "Blackline", 2020,
format: "hardcovers", price: 88, shipping: 10, condition: "Sealed", printingDetail: "First printing", offers: true,
tone: "ink", lettering: "FERROUS\nSAINT\nOMNIBUS", imprint: "BLACKLINE", number: "HC",
notes: "Sealed. Heavy book: ships double-boxed."
),
single(
"nightward-2-cgc-9-6", kestrelRow, "Nightward", "2", "Meridian Comics", 2019,
price: 85, shipping: 6, grade: 9.6, condition: "NM+", grader: "CGC", cert: "4312087-003",
pages: "White", offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
notes: "Graded in the same batch as our #1. Clean case."
),
single(
"nightward-1-cgc-9-4", longbox, "Nightward", "1", "Meridian Comics", 2019,
price: 150, shipping: 14, grade: 9.4, condition: "NM", grader: "CGC", cert: "4198220-007",
pages: "White", key: "First appearance of the Nightward; first issue of the series",
offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
notes: "A tidy 9.4 for anyone who wants the key without the 9.8 premium."
),
single(
"nightward-1-raw-nm", thirdPanel, "Nightward", "1", "Meridian Comics", 2019,
price: 120, shipping: 7, grade: 9.4, condition: "NM",
key: "First appearance of the Nightward; first issue of the series",
offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
notes: "Raw copy, a grading candidate. One faint non-colour-breaking stress line on the spine, visible in photo 3."
),
single(
"nightward-1-2nd-print", silverfish, "Nightward", "1", "Meridian Comics", 2019,
price: 35, shipping: 5, grade: 8.0, condition: "VF",
key: "First appearance of the Nightward",
printing: "2nd print", printingDetail: "Second printing, red logo", isFirstPrint: false,
tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
notes: "Second printing with the red logo. Small corner blunt, top right."
),
single(
"ferrous-saint-1-cbcs-9-0", inkwell, "Ferrous Saint", "1", "Blackline", 1993,
price: 130, shipping: 9, grade: 9.0, condition: "VF/NM", grader: "CBCS", cert: "21-2E4F771-004",
pages: "White", key: "First appearance of the Ferrous Saint", offers: true,
tone: "wine", lettering: "FERROUS\nSAINT", imprint: "BLACKLINE",
notes: "CBCS slab, verified signature series not applicable. Case has one light scuff on the back."
),
single(
"echo-country-1", inkwell, "Echo Country", "1", "Harbour & Vale", 2021,
price: 24, shipping: 6, grade: 9.4, condition: "NM", key: "First issue of the series",
tone: "forest", lettering: "ECHO\nCOUNTRY", imprint: "HARBOUR & VALE",
notes: "First printing. Flat, glossy, no spine ticks."
),
single(
"vermilion-lake-12", kestrelRow, "Vermilion Lake", "12", "Meridian Comics", 1988,
price: 18, shipping: 5, grade: 8.5, condition: "VF+",
printing: "direct", printingDetail: "First printing, direct edition",
tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN",
notes: "Two small spine ticks; cover gloss is strong for its age."
),
single(
"paper-tigers-2", silverfish, "Paper Tigers", "2", "Cutwater Press", 2023,
price: 9, shipping: 5, grade: 9.4, condition: "NM",
tone: "slate", lettering: "PAPER\nTIGERS", imprint: "CUTWATER",
notes: "Cover A. Bagged and boarded from new."
),
collected(
"hollow-comet-vol-1", inkwell, "Hollow Comet, Vol. 1", "Hollow Comet", "Vol. 1", "Sōkai Books", 2021,
format: "manga", price: 14, shipping: 6, condition: "Very good", printingDetail: "English first printing",
tone: "plum", lettering: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 1",
notes: "First English printing with the misprinted spine number, corrected in later runs."
),
collected(
"vermilion-lake-tpb-vol-1", inkwell, "Vermilion Lake, Vol. 1", "Vermilion Lake", "Trade paperback, collects #1–6", "Meridian Comics", 2015,
format: "trade-paperbacks", price: 16, shipping: 6, condition: "Good", printing: "2nd printing",
printingDetail: "Second printing", isFirstPrint: false,
tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN", number: "TPB",
notes: "A reading copy: creased spine and a previous owner's name inside the front cover."
),
]
/// The demo cons, soonest first, each with the slugs of the shops trading there, dated from the first weekend after `date`.
static func events(
after date: Date
) -> [(event: Event, shops: [String])] {
[
("dutch-comic-con", "Dutch Comic Con", "Jaarbeurs, Utrecht", 0, ["kestrel-row-comics", "silverfish-comics", "third-panel-supply"]),
("stripdagen-haarlem", "Stripdagen Haarlem", "Grote Markt, Haarlem", 1, ["silverfish-comics"]),
("comic-con-amsterdam", "Comic Con Amsterdam", "RAI, Amsterdam", 8, ["kestrel-row-comics", "inkwell-and-staple"]),
("comic-con-brussels", "Comic Con Brussels", "Tour & Taxis, Brussels", 10, ["third-panel-supply"]),
]
.map { slug, name, venue, week, shops in
let (startsOn, endsOn) = weekend(week, after: date)
return (.init(slug: slug, name: name, venue: venue, startsOn: startsOn, endsOn: endsOn), shops)
}
}
/// Noon on the Saturday and Sunday `week` weeks on from the first weekend after `date`, in Amsterdam, where the demo cons are held.
static func weekend(
_ week: Int,
after date: Date
) -> (startsOn: Date, endsOn: Date) {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
let saturday = calendar.nextDate(after: date, matching: .init(hour: 12, weekday: 7), matchingPolicy: .nextTime) ?? date
let startsOn = calendar.date(byAdding: .weekOfYear, value: week, to: saturday) ?? saturday
return (startsOn, calendar.date(byAdding: .day, value: 1, to: startsOn) ?? startsOn)
}
/// The runs the demo listings belong to.
static let series: [Series] = [
.init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 24,
blurb: "Two-year run, one printing for most issues. #1 has a second printing with a red logo; the hardcover collects #1–12."),
.init(slug: "ferrous-saint", name: "Ferrous Saint", publisher: "Blackline", years: "1993–1996", issueCount: 30,
blurb: "A nineties independent with direct and newsstand editions throughout. The omnibus collects the whole run."),
.init(slug: "echo-country", name: "Echo Country", publisher: "Harbour & Vale", years: "2021–2022", issueCount: 6,
blurb: "A six-issue limited series. Later printings carry recoloured covers."),
.init(slug: "vermilion-lake", name: "Vermilion Lake", publisher: "Meridian Comics", years: "1986–1989", issueCount: 18,
blurb: "Meridian's late-eighties flagship. #7 tells the origin of the Lake Warden."),
.init(slug: "paper-tigers", name: "Paper Tigers", publisher: "Cutwater Press", years: "2023–", issueCount: 4,
blurb: "Ongoing. Every issue ships with a 1:10 retailer variant."),
]
/// The demo shops.
static let shops: [Shop] = [kestrelRow, thirdPanel, longbox, silverfish, inkwell]
}
// MARK: - Helpers
private extension Seed {
// MARK: Constants
static let inkwell = Shop(
slug: "inkwell-and-staple", name: "Inkwell & Staple", city: "Berlin", country: "DE", sinceYear: 2024,
isVerified: true, rating: 4.8, ratedSales: 96, soldCount: 140, handlingDays: 3,
shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns",
about: "A two-person shop working through a warehouse find of nineties independents. Everything is graded against the public guide, and anything we are unsure of is graded down."
)
static let kestrelRow = Shop(
slug: "kestrel-row-comics", name: "Kestrel Row Comics", city: "Amsterdam", country: "NL", sinceYear: 2023,
isVerified: true, rating: 4.9, ratedSales: 312, soldCount: 1108, handlingDays: 2,
shipsNote: "ships worldwide, tracked", returnsNote: "14-day returns on raw books",
about: "Modern keys and Bronze Age runs, mostly slabbed. Every raw book is photographed front, back and spine under daylight; grades are conservative and defects are listed, not hinted at. Offers welcome on anything over €50."
)
static let longbox = Shop(
slug: "longbox-atlantic", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024,
isVerified: false, rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4,
shipsNote: "ships worldwide from the UK; import VAT may apply", returnsNote: "returns by agreement",
about: "A collector thinning out twenty years of long boxes. Mid-grade keys and out-of-print manga, priced to move."
)
static let silverfish = Shop(
slug: "silverfish-comics", name: "Silverfish Comics", city: "Utrecht", country: "NL", sinceYear: 2022,
isVerified: true, rating: 4.7, ratedSales: 204, soldCount: 655, handlingDays: 1,
shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns",
about: "New-release variants and recent indies, shipped the next working day. Pickup from the Utrecht shop is always free."
)
static let thirdPanel = Shop(
slug: "third-panel-supply", name: "Third Panel Supply", city: "Antwerp", country: "BE", sinceYear: 2023,
isVerified: true, rating: 4.8, ratedSales: 167, soldCount: 420, handlingDays: 2,
shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns",
about: "Collected editions, manga and the odd newsstand oddity. Sealed means sealed; everything else is described page by page."
)
/// The moment the newest demo listing was listed; each later entry in ``listings`` is a few hours older.
static let epoch = Date(timeIntervalSince1970: 1_789_900_000)
// MARK: Methods
/// Builds a collected edition — manga, trade paperback, or hardcover — which carries a condition but no grade.
static func collected(
_ slug: String,
_ shop: Shop,
_ title: String,
_ series: String,
_ issueLabel: String,
_ publisher: String,
_ year: Int,
format: String,
price: Int,
shipping: Int,
condition: String,
printing: String = "1st printing",
printingDetail: String,
isFirstPrint: Bool = true,
offers: Bool = false,
tone: String,
lettering: String,
imprint: String,
number: String,
notes: String
) -> Listing {
.init(
slug: slug, shop: shop, title: title, seriesTitle: series, issueLabel: issueLabel,
publisher: publisher, year: year, format: format, printing: printing, printingDetail: printingDetail,
isFirstPrint: isFirstPrint, grader: nil, grade: nil, condition: condition, certNumber: nil,
pageQuality: nil, keyNotes: nil, sellerNotes: notes, priceCents: price * 100,
shippingCents: shipping * 100, acceptsOffers: offers,
cover: .init(tone: tone, title: lettering, imprint: imprint, number: number),
listedAt: listedAt(slug)
)
}
/// The listing date of the entry with the given slug: the ``epoch``, less seven hours per earlier entry.
static func listedAt(
_ slug: String
) -> Date {
epoch.addingTimeInterval(-25_200 * Double(order.firstIndex(of: slug) ?? order.count))
}
/// Builds a single issue, slabbed when a grader is given and raw otherwise.
static func single(
_ slug: String,
_ shop: Shop,
_ series: String,
_ number: String,
_ publisher: String,
_ year: Int,
price: Int,
shipping: Int,
grade: Double,
condition: String,
grader: String? = nil,
cert: String? = nil,
pages: String? = nil,
key: String? = nil,
printing: String = "1st print",
printingDetail: String = "First printing",
isFirstPrint: Bool = true,
offers: Bool = false,
tone: String,
lettering: String,
imprint: String,
notes: String
) -> Listing {
.init(
slug: slug, shop: shop, title: "\(series) #\(number)", seriesTitle: "\(series) (\(year) series)",
issueLabel: "#\(number)", publisher: publisher, year: year, format: "single-issues",
printing: printing, printingDetail: printingDetail, isFirstPrint: isFirstPrint, grader: grader,
grade: grade, condition: condition, certNumber: cert, pageQuality: pages, keyNotes: key,
sellerNotes: notes, priceCents: price * 100, shippingCents: shipping * 100, acceptsOffers: offers,
cover: .init(tone: tone, title: lettering, imprint: imprint, number: number),
listedAt: listedAt(slug)
)
}
/// The listing slugs, newest first; ``listedAt(_:)`` spaces the listing dates by this order.
static let order = [
"nightward-1-cgc-9-8", "ferrous-saint-12", "echo-country-3", "vermilion-lake-7-cgc-6-5",
"paper-tigers-1-variant-b", "hollow-comet-vol-4", "nightward-the-first-year-hc", "solar-divide-vol-1",
"the-gilded-hour-tpb", "ferrous-saint-omnibus-hc", "nightward-2-cgc-9-6", "nightward-1-cgc-9-4",
"nightward-1-raw-nm", "nightward-1-2nd-print", "ferrous-saint-1-cbcs-9-0", "echo-country-1",
"vermilion-lake-12", "paper-tigers-2", "hollow-comet-vol-1", "vermilion-lake-tpb-vol-1",
]
}
@@ -0,0 +1,7 @@
/// Why a shop could not be opened.
public enum ShopError: Error, Equatable, Sendable {
/// The account already owns a shop.
case alreadyOwner
/// Another shop has the handle.
case handleTaken
}
@@ -0,0 +1,19 @@
/// Why a step towards a sale was turned down.
public enum TradeError: Error, Equatable, Sendable {
/// The amount is not a positive number of cents at or under the asking price.
case invalidAmount
/// The listing does not exist, or is sold.
case listingUnavailable
/// The account is not a party to the conversation, offer, or sale — or the step is not theirs to take now.
case notAllowed
/// The buyer already has an open offer on the listing.
case offerAlreadyOpen
/// The offer is under the floor the seller hears offers from, in euro cents.
case offerTooLow(floorCents: Int)
/// The listing's seller takes no offers.
case offersNotAccepted
/// The listing, shop, conversation, or sale belongs to the account itself.
case ownListing
/// The shop has no owner to sell on its behalf.
case shopUnattended
}
@@ -22,7 +22,18 @@ public struct PrepareDB: Sendable {
for fluent: Fluent
) async {
await fluent.migrations.add([
CreateExampleRecord()
CreateShop(),
CreateListing(),
AddTradeColumns(),
AddListingDeclarations(),
AddShopRegistration(),
SeedCatalogue(),
CreateUser(),
CreateTrade(),
CreatePasskey(),
CreateCollecting(),
CreateCommunity(),
RedateEvents(),
])
}
@@ -0,0 +1,131 @@
import Foundation
/// A `Sendable` snapshot of a conversation between a buyer and a shop, as one of them sees it.
public struct Conversation: Sendable, Equatable {
// MARK: Properties
/// The conversation's identifier.
public let id: UUID
/// The account buying.
public let buyer: User
/// The shop selling.
public let shop: Shop
/// The listing the conversation is about, or `nil` when it is about the shop at large.
public let listing: Listing?
/// Which side of the conversation the viewer is on.
public let side: Side
/// What was said, offered, and stamped, oldest first.
public let entries: [Entry]
/// The number of entries from the other side the viewer has not seen yet.
public let unreadCount: Int
/// The number of the sale agreed in the conversation, or `nil` when none was.
public let saleNumber: Int?
// MARK: Initializers
public init(
id: UUID,
buyer: User,
shop: Shop,
listing: Listing?,
side: Side,
entries: [Entry],
unreadCount: Int,
saleNumber: Int?
) {
self.id = id
self.buyer = buyer
self.shop = shop
self.listing = listing
self.side = side
self.entries = entries
self.unreadCount = unreadCount
self.saleNumber = saleNumber
}
}
// MARK: - Structs
public extension Conversation {
/// One thing in a conversation: something said, an offer made, or an event stamped.
struct Entry: Sendable, Equatable {
// MARK: Properties
/// What the entry is.
public let kind: Kind
/// Who wrote the message, made the offer, or caused the event.
public let sender: User
/// The message, the offer's note, or the event in a sentence.
public let body: String
/// The offer made, for an offer entry.
public let offer: Offer?
/// The number of the sale an event opened, or `nil`.
public let saleNumber: Int?
/// When the entry was made.
public let createdAt: Date
// MARK: Initializers
public init(
kind: Kind,
sender: User,
body: String,
offer: Offer? = nil,
saleNumber: Int? = nil,
createdAt: Date
) {
self.kind = kind
self.sender = sender
self.body = body
self.offer = offer
self.saleNumber = saleNumber
self.createdAt = createdAt
}
// MARK: Enumerations
/// What a conversation entry is.
public enum Kind: String, Sendable {
/// Something a party wrote.
case text
/// An offer, with its optional note.
case offer
/// Something that happened: an offer answered, a sale agreed, a step of the handshake taken.
case event
}
}
}
// MARK: - Enumerations
public extension Conversation {
/// A side of a conversation.
enum Side: String, Sendable {
case buying
case selling
}
}
// MARK: - Extensions
public extension Conversation {
// MARK: Computed
/// The latest entry, or `nil` in a conversation nothing was said in yet.
var latest: Entry? {
entries.last
}
/// The offer still waiting for an answer, or `nil` when there is none.
var openOffer: Offer? {
entries
.compactMap(\.offer)
.last { $0.status == .open }
}
}
@@ -0,0 +1,39 @@
import Foundation
/// A `Sendable` snapshot of a con where shops trade in person, so a sale agreed on the site can be collected there.
public struct Event: Sendable, Equatable {
// MARK: Properties
/// The event's URL slug.
public let slug: String
/// The event's name.
public let name: String
/// Where it is held, e.g. `Jaarbeurs, Utrecht`.
public let venue: String
/// The first day.
public let startsOn: Date
/// The last day.
public let endsOn: Date
/// The shops trading there.
public let shops: [Shop]
// MARK: Initializers
public init(
slug: String,
name: String,
venue: String,
startsOn: Date,
endsOn: Date,
shops: [Shop] = []
) {
self.slug = slug
self.name = name
self.venue = venue
self.startsOn = startsOn
self.endsOn = endsOn
self.shops = shops
}
}
@@ -0,0 +1,212 @@
import Foundation
/// A `Sendable` snapshot of one copy for sale, with the shop selling it.
public struct Listing: Sendable, Equatable {
// MARK: Properties
/// The listing's URL slug.
public let slug: String
/// The shop selling the copy.
public let shop: Shop
/// The listing's title, e.g. `Nightward #1`.
public let title: String
/// The series the copy belongs to, e.g. `Nightward (2019 series)`.
public let seriesTitle: String
/// The issue or volume, e.g. `#1, cover A`.
public let issueLabel: String
/// The publisher's name.
public let publisher: String
/// The cover year.
public let year: Int
/// The format token, e.g. `single-issues`.
public let format: String
/// The printing in a few words, e.g. `1st print`.
public let printing: String
/// The printing in full, e.g. `First printing, direct edition`.
public let printingDetail: String
/// Whether the copy is a first printing.
public let isFirstPrint: Bool
/// The company that graded and slabbed the copy, or `nil` for a raw copy.
public let grader: String?
/// The numeric grade, or `nil` for formats that are not graded.
public let grade: Double?
/// The condition as the seller states it, e.g. `NM` or `Like new`.
public let condition: String
/// The grader's certificate number, or `nil` for a raw copy.
public let certNumber: String?
/// The page quality, or `nil` when not stated.
public let pageQuality: String?
/// Why the issue is a key, or `nil` when it is not one.
public let keyNotes: String?
/// The seller's notes on the copy.
public let sellerNotes: String
/// The asking price, in euro cents.
public let priceCents: Int
/// The shipping cost to the Netherlands, in euro cents.
public let shippingCents: Int
/// Whether the seller takes offers on the copy.
public let acceptsOffers: Bool
/// The placeholder cover shown until listings carry photos.
public let cover: Cover
/// When the copy was listed.
public let listedAt: Date
/// When a sale of the copy was agreed, or `nil` while it is for sale.
public let soldAt: Date?
/// The defects the seller declared, from the grading guide's checklist.
public let defects: [String]
/// The URL paths of the photos of this copy, the front first; empty while the listing shows its placeholder cover.
public let photos: [String]
// MARK: Initializers
public init(
slug: String,
shop: Shop,
title: String,
seriesTitle: String,
issueLabel: String,
publisher: String,
year: Int,
format: String,
printing: String,
printingDetail: String,
isFirstPrint: Bool,
grader: String?,
grade: Double?,
condition: String,
certNumber: String?,
pageQuality: String?,
keyNotes: String?,
sellerNotes: String,
priceCents: Int,
shippingCents: Int,
acceptsOffers: Bool,
cover: Cover,
listedAt: Date,
soldAt: Date? = nil,
defects: [String] = [],
photos: [String] = []
) {
self.slug = slug
self.shop = shop
self.title = title
self.seriesTitle = seriesTitle
self.issueLabel = issueLabel
self.publisher = publisher
self.year = year
self.format = format
self.printing = printing
self.printingDetail = printingDetail
self.isFirstPrint = isFirstPrint
self.grader = grader
self.grade = grade
self.condition = condition
self.certNumber = certNumber
self.pageQuality = pageQuality
self.keyNotes = keyNotes
self.sellerNotes = sellerNotes
self.priceCents = priceCents
self.shippingCents = shippingCents
self.acceptsOffers = acceptsOffers
self.cover = cover
self.listedAt = listedAt
self.soldAt = soldAt
self.defects = defects
self.photos = photos
}
}
// MARK: - Extensions
public extension Listing {
// MARK: Computed
/// What the seller declared of the copy's condition, in a sentence: the defects, then the notes. Frozen into a sale at agreement.
var declaration: String {
let defects = defects.isEmpty
? "No defects declared."
: "Declared defects: \(defects.joined(separator: "; "))."
return sellerNotes.isEmpty ? defects : "\(defects) \(sellerNotes)"
}
/// The grade or condition in a few words: `CGC 9.8` for a slab, `Raw · NM` for a raw issue, the condition alone for a collected edition.
var gradeLabel: String {
if let grader, let grade {
return "\(grader) \(grade.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))))"
}
return format == "single-issues" ? "Raw · \(condition)" : condition
}
/// Whether the copy is still for sale.
var isAvailable: Bool {
soldAt == nil
}
/// Whether the issue is a key.
var isKey: Bool {
keyNotes != nil
}
/// The issue's number within its series, or `nil` for a copy that is not a numbered single issue.
var issueNumber: Int? {
format == "single-issues"
? Int(issueLabel.drop { !$0.isNumber }.prefix { $0.isNumber })
: nil
}
/// The name of the series the copy belongs to: its series title, less the `(2019 series)` a single issue's carries.
var seriesName: String {
seriesTitle
.replacing(#/\s\(\d{4} series\)$/#, with: "")
}
/// The slug of the series the copy belongs to.
var seriesSlug: String {
ListingDraft.slug(seriesName)
}
/// Whether the copy is graded and slabbed by a grading company.
var isSlabbed: Bool {
grader != nil
}
}
// MARK: - Structs
public extension Listing {
/// A placeholder cover: lettering on a toned ground.
struct Cover: Sendable, Equatable {
// MARK: Properties
/// The tone token naming the cover's colours, e.g. `navy`.
public let tone: String
/// The title lettering, one line per newline.
public let title: String
/// The publisher's imprint line.
public let imprint: String
/// The issue number or volume marker.
public let number: String
// MARK: Initializers
public init(
tone: String,
title: String,
imprint: String,
number: String
) {
self.tone = tone
self.title = title
self.imprint = imprint
self.number = number
}
}
}
@@ -0,0 +1,221 @@
import Foundation
/// What a seller fills in to list a copy. The listing's slug and placeholder cover are derived from it on publishing.
public struct ListingDraft: Sendable, Equatable {
// MARK: Properties
/// The series or book title, e.g. `Nightward`.
public var series: String
/// The issue number or volume, e.g. `1`; empty for a one-off book.
public var issue: String
/// The publisher's name.
public var publisher: String
/// The cover year.
public var year: Int
/// The format token, e.g. `single-issues`.
public var format: String
/// The printing, counted from one; zero for later or unknown.
public var printing: Int
/// The edition, e.g. `Direct`; empty when not applicable.
public var edition: String
/// The company that graded and slabbed the copy, or `nil` for a raw copy.
public var grader: String?
/// The numeric grade, or `nil` for formats that are not graded.
public var grade: Double?
/// The condition as the seller states it, e.g. `NM` or `Like new`.
public var condition: String
/// The grader's certificate number, or `nil` for a raw copy.
public var certNumber: String?
/// The page quality, or `nil` when not stated.
public var pageQuality: String?
/// Why the issue is a key, or `nil` when it is not one.
public var keyNotes: String?
/// The defects declared, from the grading guide's checklist.
public var defects: [String]
/// The seller's notes on the copy.
public var sellerNotes: String
/// The asking price, in euro cents.
public var priceCents: Int
/// The shipping cost to the Netherlands, in euro cents.
public var shippingCents: Int
/// Whether the seller takes offers on the copy.
public var acceptsOffers: Bool
/// The URL paths of the photos of this copy, the front first.
public var photos: [String]
// MARK: Initializers
public init(
series: String,
issue: String,
publisher: String,
year: Int,
format: String,
printing: Int,
edition: String,
grader: String?,
grade: Double?,
condition: String,
certNumber: String?,
pageQuality: String?,
keyNotes: String?,
defects: [String],
sellerNotes: String,
priceCents: Int,
shippingCents: Int,
acceptsOffers: Bool,
photos: [String]
) {
self.series = series
self.issue = issue
self.publisher = publisher
self.year = year
self.format = format
self.printing = printing
self.edition = edition
self.grader = grader
self.grade = grade
self.condition = condition
self.certNumber = certNumber
self.pageQuality = pageQuality
self.keyNotes = keyNotes
self.defects = defects
self.sellerNotes = sellerNotes
self.priceCents = priceCents
self.shippingCents = shippingCents
self.acceptsOffers = acceptsOffers
self.photos = photos
}
}
// MARK: - Extensions
extension ListingDraft {
// MARK: Constants
/// The tones a placeholder cover is drawn in.
static let tones = ["navy", "wine", "forest", "rust", "slate", "plum", "brick", "olive", "ink"]
// MARK: Computed
/// The placeholder cover: the series lettered over a few lines, on a tone picked by the series so every copy of it matches.
var cover: Listing.Cover {
var lines = [String]()
for word in series.uppercased().split(separator: " ").map(String.init) {
if let last = lines.last, last.count + word.count < 10 {
lines[lines.count - 1] = "\(last) \(word)"
} else {
lines.append(word)
}
}
let marker = switch format {
case "manga": issue.isEmpty ? "MANGA" : "VOL \(issue)"
case "trade-paperbacks": "TPB"
case "hardcovers": "HC"
default: issue
}
return .init(
tone: Self.tones[series.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % Self.tones.count }],
title: lines.prefix(3).joined(separator: "\n"),
imprint: publisher.uppercased(),
number: marker
)
}
/// Whether the copy is a single issue.
var isSingleIssue: Bool {
format == "single-issues"
}
/// The printing in a few words, e.g. `1st print`.
var printingLabel: String {
let noun = isSingleIssue ? "print" : "printing"
switch printing {
case 1: return "1st \(noun)"
case 2: return "2nd \(noun)"
case 3: return "3rd \(noun)"
default: return "later \(noun)"
}
}
/// The printing in full, e.g. `First printing, direct edition`.
var printingDetail: String {
let ordinal = switch printing {
case 1: "First"
case 2: "Second"
case 3: "Third"
default: "Later"
}
return edition.isEmpty ? "\(ordinal) printing" : "\(ordinal) printing, \(edition.lowercased()) edition"
}
/// The listing's title, e.g. `Nightward #1`, or `Hollow Comet, Vol. 4`.
var title: String {
guard !issue.isEmpty else {
return series
}
return switch format {
case "single-issues": "\(series) #\(issue)"
case "manga": "\(series), Vol. \(issue)"
default: "\(series) \(issue)"
}
}
// MARK: Methods
/// The listing the draft publishes as.
func listing(
slug: String,
shop: Shop,
at date: Date
) -> Listing {
.init(
slug: slug,
shop: shop,
title: title,
seriesTitle: isSingleIssue ? "\(series) (\(year) series)" : series,
issueLabel: issue.isEmpty ? "—" : (isSingleIssue ? "#\(issue)" : issue),
publisher: publisher,
year: year,
format: format,
printing: printingLabel,
printingDetail: printingDetail,
isFirstPrint: printing == 1,
grader: grader,
grade: grade,
condition: condition,
certNumber: certNumber,
pageQuality: pageQuality,
keyNotes: keyNotes,
sellerNotes: sellerNotes,
priceCents: priceCents,
shippingCents: shippingCents,
acceptsOffers: acceptsOffers,
cover: cover,
listedAt: date,
defects: defects,
photos: photos
)
}
/// A string as a URL slug: lowercased, its letters and digits kept, everything else a single hyphen.
static func slug(
_ string: String
) -> String {
string
.lowercased()
.folding(options: .diacriticInsensitive, locale: nil)
.split { !($0.isASCII && ($0.isLetter || $0.isNumber)) }
.joined(separator: "-")
}
}
@@ -0,0 +1,209 @@
/// The criteria a listing search is narrowed and ordered by; the default matches every listing for sale, newest first.
public struct ListingFilter: Sendable, Equatable {
// MARK: Properties
/// Words that must each appear in the listing's title, publisher, or shop name.
public var query: String?
/// The format tokens to match; empty matches all.
public var formats: [String]
/// `true` for slabbed copies only, `false` for raw only, `nil` for both.
public var isSlabbed: Bool?
/// The lowest numeric grade to match.
public var gradeMin: Double?
/// The highest numeric grade to match.
public var gradeMax: Double?
/// Whether to match first printings only.
public var firstPrintOnly: Bool
/// Whether to match key issues only.
public var keysOnly: Bool
/// The publishers to match; empty matches all.
public var publishers: [String]
/// The lowest price to match, in euro cents.
public var priceMin: Int?
/// The highest price to match, in euro cents.
public var priceMax: Int?
/// Where matching listings ship from.
public var shipsFrom: Region
/// Whether to match only listings whose seller takes offers.
public var acceptsOffers: Bool
/// Whether to match only listings from verified shops.
public var verifiedShops: Bool
/// The slug of the one shop to match, or `nil` for all shops.
public var shop: String?
/// The slugs of the shops to match — those trading at an event, say — or `nil` for all shops.
public var shops: [String]?
/// The order of the results.
public var sort: Sort
// MARK: Initializers
public init(
query: String? = nil,
formats: [String] = [],
isSlabbed: Bool? = nil,
gradeMin: Double? = nil,
gradeMax: Double? = nil,
firstPrintOnly: Bool = false,
keysOnly: Bool = false,
publishers: [String] = [],
priceMin: Int? = nil,
priceMax: Int? = nil,
shipsFrom: Region = .anywhere,
acceptsOffers: Bool = false,
verifiedShops: Bool = false,
shop: String? = nil,
shops: [String]? = nil,
sort: Sort = .newest
) {
self.query = query
self.formats = formats
self.isSlabbed = isSlabbed
self.gradeMin = gradeMin
self.gradeMax = gradeMax
self.firstPrintOnly = firstPrintOnly
self.keysOnly = keysOnly
self.publishers = publishers
self.priceMin = priceMin
self.priceMax = priceMax
self.shipsFrom = shipsFrom
self.acceptsOffers = acceptsOffers
self.verifiedShops = verifiedShops
self.shop = shop
self.shops = shops
self.sort = sort
}
}
// MARK: - Enumerations
public extension ListingFilter {
/// Where a listing ships from.
enum Region: String, CaseIterable, Sendable {
/// The Netherlands.
case nl
/// Any member state of the European Union.
case eu
/// Any country.
case anywhere
}
/// The order of a listing search's results.
enum Sort: String, CaseIterable, Sendable {
/// Most recently listed first.
case newest
/// Cheapest first.
case priceAscending = "price-asc"
/// Most expensive first.
case priceDescending = "price-desc"
/// Highest grade first; ungraded listings last.
case gradeDescending = "grade-desc"
}
}
// MARK: - Extensions
extension ListingFilter {
// MARK: Methods
/// Whether the listing meets every criterion.
/// - Parameter listing: the listing to test.
func matches(
_ listing: Listing
) -> Bool {
guard listing.isAvailable else {
return false
}
if let shop, listing.shop.slug != shop {
return false
}
if let shops, !shops.contains(listing.shop.slug) {
return false
}
if !formats.isEmpty, !formats.contains(listing.format) {
return false
}
if let isSlabbed, listing.isSlabbed != isSlabbed {
return false
}
if gradeMin != nil || gradeMax != nil {
guard
let grade = listing.grade,
grade >= gradeMin ?? 0,
grade <= gradeMax ?? 10
else {
return false
}
}
if firstPrintOnly, !listing.isFirstPrint {
return false
}
if keysOnly, !listing.isKey {
return false
}
if !publishers.isEmpty, !publishers.contains(listing.publisher) {
return false
}
if let priceMin, listing.priceCents < priceMin {
return false
}
if let priceMax, listing.priceCents > priceMax {
return false
}
if acceptsOffers, !listing.acceptsOffers {
return false
}
if verifiedShops, !listing.shop.isVerified {
return false
}
switch shipsFrom {
case .nl where listing.shop.country != "NL": return false
case .eu where !Self.euCountries.contains(listing.shop.country): return false
default: break
}
let haystack = "\(listing.title) \(listing.publisher) \(listing.shop.name)".lowercased()
return (query ?? "")
.lowercased()
.split(whereSeparator: \.isWhitespace)
.allSatisfy { haystack.contains($0) }
}
/// Whether the first listing sorts ahead of the second.
func ordersBefore(
_ lhs: Listing,
_ rhs: Listing
) -> Bool {
switch sort {
case .newest: lhs.listedAt > rhs.listedAt
case .priceAscending: lhs.priceCents < rhs.priceCents
case .priceDescending: lhs.priceCents > rhs.priceCents
case .gradeDescending: (lhs.grade ?? -1) > (rhs.grade ?? -1)
}
}
// MARK: Constants
/// The ISO 3166-1 alpha-2 codes of the European Union's member states.
static let euCountries: Set<String> = [
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU",
"IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK",
]
}
@@ -0,0 +1,87 @@
import Foundation
/// A `Sendable` snapshot of a buyer's offer on a listing.
public struct Offer: Sendable, Equatable {
// MARK: Constants
/// The share of the asking price under which an offer is declined outright.
public static let floorShare = 0.8
/// How long an offer binds its buyer: 48 hours.
public static let lifetime: TimeInterval = 172_800
// MARK: Properties
/// The offer's identifier.
public let id: UUID
/// The identifier of the conversation the offer was made in.
public let conversationID: UUID
/// The listing offered on.
public let listing: Listing
/// The account offering.
public let buyer: User
/// The amount offered, in euro cents.
public let amountCents: Int
/// Where the offer stands; an open offer past its expiry reads as expired.
public let status: Status
/// When the offer was made.
public let createdAt: Date
/// When the offer stops binding its buyer.
public let expiresAt: Date
// MARK: Initializers
public init(
id: UUID,
conversationID: UUID,
listing: Listing,
buyer: User,
amountCents: Int,
status: Status,
createdAt: Date,
expiresAt: Date
) {
self.id = id
self.conversationID = conversationID
self.listing = listing
self.buyer = buyer
self.amountCents = amountCents
self.status = status
self.createdAt = createdAt
self.expiresAt = expiresAt
}
}
// MARK: - Enumerations
public extension Offer {
/// Where an offer stands.
enum Status: String, Sendable {
/// Waiting for the seller's answer.
case open
/// Accepted: a sale record was opened.
case accepted
/// Declined by the seller.
case declined
/// Left unanswered past its expiry, or overtaken by a sale of the listing.
case expired
}
}
// MARK: - Extensions
public extension Offer {
// MARK: Methods
/// The lowest offer a listing's seller hears, in euro cents: ``floorShare`` of the asking price, rounded up to a whole euro.
/// - Parameter askingCents: the listing's asking price, in euro cents.
static func floor(
forAsking askingCents: Int
) -> Int {
Int((Double(askingCents) * floorShare / 100).rounded(.up)) * 100
}
}
@@ -0,0 +1,29 @@
/// A `Sendable` snapshot of a WebAuthn credential registered to an account.
public struct Passkey: Sendable, Equatable {
// MARK: Properties
/// The credential's identifier, base64url-encoded without padding.
public let credentialID: String
/// The credential's public key, COSE-encoded.
public let publicKey: [UInt8]
/// The authenticator's signature counter as last seen; a lower one later on signals a cloned authenticator.
public let signCount: UInt32
/// The account the credential signs in.
public let user: User
// MARK: Initializers
public init(
credentialID: String,
publicKey: [UInt8],
signCount: UInt32,
user: User
) {
self.credentialID = credentialID
self.publicKey = publicKey
self.signCount = signCount
self.user = user
}
}
@@ -0,0 +1,224 @@
import Foundation
/// A `Sendable` snapshot of a sale record: what was agreed, and how far its handshake has come.
///
/// The handshake has five steps — agreed, paid, sent, received, rated — each stamped by the person who took it. Payment is settled between
/// buyer and seller off-platform; the record keeps the amounts and the stamps.
public struct Sale: Sendable, Equatable {
// MARK: Properties
/// The record's number, shown as `CCN-48213`.
public let number: Int
/// The identifier of the conversation the sale was agreed in.
public let conversationID: UUID
/// The listing sold.
public let listing: Listing
/// The account buying.
public let buyer: User
/// The account selling: the owner of the listing's shop.
public let seller: User
/// The agreed price, in euro cents.
public let priceCents: Int
/// The asking price at the moment of agreement, in euro cents.
public let askedCents: Int
/// The agreed shipping cost, in euro cents.
public let shippingCents: Int
/// The condition the seller declared, frozen at the moment of agreement.
public let declared: Declaration
/// When the sale was agreed.
public let agreedAt: Date
/// When the buyer marked the sale paid.
public let paidAt: Date?
/// How the buyer says they paid.
public let paymentNote: String?
/// When the seller marked the copy sent.
public let sentAt: Date?
/// The carrier and tracking the seller gave.
public let shippingNote: String?
/// When the buyer marked the copy received.
public let receivedAt: Date?
/// The stars the buyer gave the seller, out of five.
public let buyerRating: Int?
/// The stars the seller gave the buyer, out of five.
public let sellerRating: Int?
// MARK: Initializers
public init(
number: Int,
conversationID: UUID,
listing: Listing,
buyer: User,
seller: User,
priceCents: Int,
askedCents: Int,
shippingCents: Int,
declared: Declaration,
agreedAt: Date,
paidAt: Date? = nil,
paymentNote: String? = nil,
sentAt: Date? = nil,
shippingNote: String? = nil,
receivedAt: Date? = nil,
buyerRating: Int? = nil,
sellerRating: Int? = nil
) {
self.number = number
self.conversationID = conversationID
self.listing = listing
self.buyer = buyer
self.seller = seller
self.priceCents = priceCents
self.askedCents = askedCents
self.shippingCents = shippingCents
self.declared = declared
self.agreedAt = agreedAt
self.paidAt = paidAt
self.paymentNote = paymentNote
self.sentAt = sentAt
self.shippingNote = shippingNote
self.receivedAt = receivedAt
self.buyerRating = buyerRating
self.sellerRating = sellerRating
}
}
// MARK: - Structs
public extension Sale {
/// The condition a seller declared, frozen when the sale was agreed: a problem report is only ever for what is not in it.
struct Declaration: Sendable, Equatable {
/// The grade or condition, e.g. `CGC 9.8`.
public let grade: String
/// The page quality, or `nil` when not stated.
public let pageQuality: String?
/// The printing in full.
public let printing: String
/// The seller's notes on the copy.
public let notes: String
public init(
grade: String,
pageQuality: String?,
printing: String,
notes: String
) {
self.grade = grade
self.pageQuality = pageQuality
self.printing = printing
self.notes = notes
}
}
}
// MARK: - Enumerations
public extension Sale {
/// Something a party does to move the handshake on.
enum Action: Sendable, Equatable {
/// The buyer says they paid, and how.
case markPaid(note: String)
/// The seller says the copy is on its way, and how.
case markSent(note: String)
/// The buyer says the copy arrived.
case markReceived
/// Either party rates the other, out of five.
case rate(stars: Int)
}
/// A party to a sale.
enum Role: Sendable {
case buyer
case seller
}
/// A step of the handshake, in order.
enum Step: Int, CaseIterable, Comparable, Sendable {
case agreed = 1
case paid
case sent
case received
case rated
public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
}
// MARK: - Extensions
public extension Sale {
// MARK: Computed
/// The last step taken. Rated counts once both parties have rated.
var completed: Step {
if buyerRating != nil, sellerRating != nil {
return .rated
}
if receivedAt != nil {
return .received
}
if sentAt != nil {
return .sent
}
return paidAt != nil ? .paid : .agreed
}
/// The step waited on, or `nil` once the handshake is done.
var next: Step? {
.init(rawValue: completed.rawValue + 1)
}
/// The agreed price and shipping together, in euro cents.
var totalCents: Int {
priceCents + shippingCents
}
// MARK: Methods
/// Whether a party may take an action now.
///
/// Each step waits for the one before it and is taken once, by the party it belongs to: the buyer pays and receives, the seller sends.
/// Ratings unlock only after the copy is received, so every rating maps to a finished sale.
/// - Parameters:
/// - action: the action to take.
/// - role: the party taking it.
func allows(
_ action: Action,
by role: Role
) -> Bool {
switch action {
case .markPaid: role == .buyer && completed == .agreed
case .markSent: role == .seller && completed == .paid
case .markReceived: role == .buyer && completed == .sent
case .rate(let stars): receivedAt != nil && (1...5).contains(stars) && rating(by: role) == nil
}
}
/// The stars a party gave, or `nil` when they have not rated yet.
func rating(
by role: Role
) -> Int? {
role == .buyer ? buyerRating : sellerRating
}
/// The party an account is to the sale, or `nil` when it is neither.
/// - Parameter userID: the account's identifier.
func role(
of userID: UUID
) -> Role? {
if userID == buyer.id {
return .buyer
}
return userID == seller.id ? .seller : nil
}
}
@@ -0,0 +1,37 @@
/// A `Sendable` snapshot of a series in the catalogue: a run of issues, which is what lets a gap in it show.
public struct Series: Sendable, Equatable {
// MARK: Properties
/// The series' URL slug: its name, slugged.
public let slug: String
/// The series' name, e.g. `Nightward`.
public let name: String
/// The publisher's name.
public let publisher: String
/// The years the series ran, e.g. `2019–2021`.
public let years: String
/// The number of issues in the run; zero for a series the catalogue only knows from its listings.
public let issueCount: Int
/// The series in a sentence or two.
public let blurb: String
// MARK: Initializers
public init(
slug: String,
name: String,
publisher: String,
years: String,
issueCount: Int,
blurb: String
) {
self.slug = slug
self.name = name
self.publisher = publisher
self.years = years
self.issueCount = issueCount
self.blurb = blurb
}
}
@@ -0,0 +1,106 @@
/// A `Sendable` snapshot of a seller's shop.
public struct Shop: Sendable, Equatable {
// MARK: Properties
/// The shop's URL slug.
public let slug: String
/// The shop's name.
public let name: String
/// The city the shop ships from.
public let city: String
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
public let country: String
/// The year the shop started selling on the site.
public let sinceYear: Int
/// Whether the shop's identity has been verified.
public let isVerified: Bool
/// The shop's average rating, out of five.
public let rating: Double
/// The number of rated sales behind ``rating``.
public let ratedSales: Int
/// The number of items the shop has sold.
public let soldCount: Int
/// The shop's handling time, in business days.
public let handlingDays: Int
/// Where and how the shop ships.
public let shipsNote: String
/// The shop's returns policy, in a few words.
public let returnsNote: String
/// The shop's description of itself.
public let about: String
/// Whether the seller sells as a private collector or as a business.
public let kind: Kind
/// The business's Chamber of Commerce (KvK) number, shown on its shop page.
public let kvkNumber: String?
/// The business's VAT (BTW) identifier.
public let vatID: String?
/// The business's registered name.
public let legalName: String?
// MARK: Initializers
public init(
slug: String,
name: String,
city: String,
country: String,
sinceYear: Int,
isVerified: Bool,
rating: Double,
ratedSales: Int,
soldCount: Int,
handlingDays: Int,
shipsNote: String,
returnsNote: String,
about: String,
kind: Kind = .business,
kvkNumber: String? = nil,
vatID: String? = nil,
legalName: String? = nil
) {
self.slug = slug
self.name = name
self.city = city
self.country = country
self.sinceYear = sinceYear
self.isVerified = isVerified
self.rating = rating
self.ratedSales = ratedSales
self.soldCount = soldCount
self.handlingDays = handlingDays
self.shipsNote = shipsNote
self.returnsNote = returnsNote
self.about = about
self.kind = kind
self.kvkNumber = kvkNumber
self.vatID = vatID
self.legalName = legalName
}
}
// MARK: - Extensions
public extension Shop {
// MARK: Computed
/// Whether the shop ships from a member state of the European Union, so a delivery within it crosses no customs border.
var shipsFromEU: Bool {
ListingFilter.euCountries.contains(country)
}
}
// MARK: - Enumerations
public extension Shop {
/// How a seller sells, which decides what consumer law asks of them.
enum Kind: String, CaseIterable, Sendable {
/// A business: its KvK number shows on its shop page, and consumers get the 14-day right of withdrawal.
case business
/// A private collector selling their own books: no statutory right of withdrawal.
case privateSeller = "private"
}
}
@@ -0,0 +1,57 @@
/// What an account fills in to open a shop.
public struct ShopDraft: Sendable, Equatable {
// MARK: Properties
/// The shop's name.
public var name: String
/// The shop's handle: its URL slug.
public var handle: String
/// The city the shop ships from.
public var city: String
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
public var country: String
/// The shop's description of itself.
public var about: String
/// Whether the seller sells as a private collector or as a business.
public var kind: Shop.Kind
/// The business's KvK number.
public var kvkNumber: String?
/// The business's VAT identifier.
public var vatID: String?
/// The business's registered name.
public var legalName: String?
/// The shop's returns policy, in a few words.
public var returnsNote: String
/// The shop's handling time, in business days.
public var handlingDays: Int
// MARK: Initializers
public init(
name: String,
handle: String,
city: String,
country: String,
about: String,
kind: Shop.Kind,
kvkNumber: String?,
vatID: String?,
legalName: String?,
returnsNote: String,
handlingDays: Int
) {
self.name = name
self.handle = handle
self.city = city
self.country = country
self.about = about
self.kind = kind
self.kvkNumber = kvkNumber
self.vatID = vatID
self.legalName = legalName
self.returnsNote = returnsNote
self.handlingDays = handlingDays
}
}
@@ -0,0 +1,27 @@
import Foundation
/// A `Sendable` snapshot of an account.
public struct User: Sendable, Equatable {
// MARK: Properties
/// The account's identifier.
public let id: UUID
/// The account's normalized email address.
public let email: String
/// The name other people see.
public let displayName: String
// MARK: Initializers
public init(
id: UUID,
email: String,
displayName: String
) {
self.id = id
self.email = email
self.displayName = displayName
}
}
@@ -0,0 +1,78 @@
import Foundation
/// A `Sendable` snapshot of a copy an account is looking for, with the listings that meet it.
public struct Want: Sendable, Equatable {
// MARK: Properties
/// The want's identifier.
public let id: UUID
/// What is wanted: words that must each appear in a listing's title, e.g. `Nightward #7`.
public let title: String
/// The lowest grade wanted, or `nil` for any.
public let minGrade: Double?
/// The most the account would pay, in euro cents, or `nil` for any price.
public let maxPriceCents: Int?
/// Whether only slabbed copies are wanted.
public let slabbedOnly: Bool
/// Whether new matches alert the account.
public let alerts: Bool
/// The listings for sale that meet the want, newest first. Wants match within the EU alone.
public let matches: [Listing]
/// The number of matches listed since the account last looked at its wants.
public let newMatchCount: Int
// MARK: Initializers
public init(
id: UUID,
title: String,
minGrade: Double?,
maxPriceCents: Int?,
slabbedOnly: Bool,
alerts: Bool,
matches: [Listing],
newMatchCount: Int
) {
self.id = id
self.title = title
self.minGrade = minGrade
self.maxPriceCents = maxPriceCents
self.slabbedOnly = slabbedOnly
self.alerts = alerts
self.matches = matches
self.newMatchCount = newMatchCount
}
}
// MARK: - Extensions
public extension Want {
// MARK: Computed
/// The filter finding the want's matches: the title's words, within the limits set, shipping from the EU.
var filter: ListingFilter {
Self.filter(title: title, minGrade: minGrade, maxPriceCents: maxPriceCents, slabbedOnly: slabbedOnly)
}
// MARK: Methods
/// The filter finding a want's matches.
static func filter(
title: String,
minGrade: Double?,
maxPriceCents: Int?,
slabbedOnly: Bool
) -> ListingFilter {
.init(
query: title.replacingOccurrences(of: "·", with: " "),
isSlabbed: slabbedOnly ? true : nil,
gradeMin: minGrade,
priceMax: maxPriceCents,
shipsFrom: .eu
)
}
}
@@ -0,0 +1,228 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// Reads and creates accounts through the default database.
public struct AccountRepository: Sendable {
// MARK: Constants
/// The email of the demo buyer the prototype's stubbed passkey signs in.
public static let demoEmail = Seed.accounts[0].email
// MARK: Properties
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(
fluent: Fluent
) {
self.fluent = fluent
}
// MARK: Methods
/// Fetches the account with the given email, without creating one.
/// - Returns: the account, or `nil` when no account has the email.
public func existingAccount(
email: String
) async throws -> User? {
try await UserRecord.query(on: fluent.db())
.filter(\.$email == email)
.first()?
.snapshot
}
/// Whether a credential identifier is still free to register.
public func isUnregistered(
credentialID: String
) async throws -> Bool {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.first() == nil
}
/// Creates an account together with its first passkey: one step both joins and enrolls.
///
/// A passkey is only ever added to an account created in the same step. Adding one to an existing account would hand that account to
/// whoever asks, since the prototype has no other proof of who is asking.
/// - Returns: the new account, or `nil` when the email already has one.
public func join(
email: String,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32
) async throws -> User? {
try await fluent.db().transaction { database in
guard try await UserRecord.query(on: database)
.filter(\.$email == email)
.first() == nil
else {
return nil
}
let user = UserRecord(
email: email,
displayName: String(email.prefix { $0 != "@" })
)
try await user.save(on: database)
try await PasskeyRecord(
userID: try user.requireID(),
credentialID: credentialID,
publicKey: publicKey,
signCount: signCount,
at: .init()
).save(on: database)
return try user.snapshot
}
}
/// Fetches the passkey with the given credential identifier, and the account it signs in.
public func passkey(
credentialID: String
) async throws -> Passkey? {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.with(\.$user)
.first()?
.snapshot
}
/// Fetches the passkeys registered to the account with the given email.
public func passkeys(
email: String
) async throws -> [Passkey] {
try await PasskeyRecord.query(on: fluent.db())
.join(UserRecord.self, on: \PasskeyRecord.$user.$id == \UserRecord.$id)
.filter(UserRecord.self, \.$email == email)
.with(\.$user)
.all()
.map { try $0.snapshot }
}
/// Records a passkey's signature counter after a sign-in.
public func recordUse(
ofPasskey credentialID: String,
signCount: UInt32
) async throws {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.set(\.$signCount, to: Int(signCount))
.update()
}
/// Adds a passkey to an account. Only ever for the account's own session: holding one is the proof of who is asking.
public func addPasskey(
to user: User,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32
) async throws {
try await PasskeyRecord(
userID: user.id,
credentialID: credentialID,
publicKey: publicKey,
signCount: signCount,
at: .init()
).save(on: fluent.db())
}
/// Replaces every passkey of an account with a new one: what recovering an account comes down to.
public func replacePasskeys(
of user: User,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32
) async throws {
try await fluent.db().transaction { database in
try await PasskeyRecord.query(on: database)
.filter(\.$user.$id == user.id)
.delete()
try await PasskeyRecord(
userID: user.id,
credentialID: credentialID,
publicKey: publicKey,
signCount: signCount,
at: .init()
).save(on: database)
}
}
/// Fetches the passkeys registered to an account, the oldest first, with when each was created.
public func passkeys(
of user: User
) async throws -> [(passkey: Passkey, createdAt: Date)] {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.sort(\.$createdAt)
.with(\.$user)
.all()
.map { (try $0.snapshot, $0.createdAt) }
}
/// Changes an account's display name.
/// - Returns: the account, renamed.
public func rename(
_ user: User,
to displayName: String
) async throws -> User {
try await UserRecord.query(on: fluent.db())
.filter(\.$id == user.id)
.set(\.$displayName, to: displayName)
.update()
return .init(
id: user.id,
email: user.email,
displayName: displayName
)
}
/// Revokes one of an account's passkeys, taking effect at once.
public func revokePasskey(
_ credentialID: String,
of user: User
) async throws {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.filter(\.$user.$id == user.id)
.delete()
}
/// Fetches the account with the given email, creating it when there is none: one step both signs in and joins.
///
/// A new account is named after the email's local part until its owner picks a display name.
/// - Parameter email: the account's normalized email address.
/// - Returns: the account, existing or new.
public func account(
email: String
) async throws -> User {
try await signIn(email: email).user
}
/// Fetches the account with the given email, creating it when there is none, and says which happened.
/// - Returns: the account, and whether this step created it.
public func signIn(
email: String
) async throws -> (user: User, isNew: Bool) {
if let user = try await existingAccount(email: email) {
return (user, false)
}
let record = UserRecord(
email: email,
displayName: String(email.prefix { $0 != "@" })
)
try await record.save(on: fluent.db())
return (try record.snapshot, true)
}
}
@@ -0,0 +1,166 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// Reads the shops and their listings through the default database.
public struct CatalogueRepository: Sendable {
// MARK: Properties
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(
fluent: Fluent
) {
self.fluent = fluent
}
// MARK: Methods
/// Fetches the listing with the given slug.
/// - Parameter slug: the listing's URL slug.
/// - Returns: the listing, or `nil` when none has the slug.
public func listing(
slug: String
) async throws -> Listing? {
try await ListingRecord.query(on: fluent.db())
.filter(\.$slug == slug)
.with(\.$shop)
.first()?
.snapshot
}
/// Fetches the listings meeting the filter, in its order.
///
/// The prototype filters in memory, which its few dozen seeded listings allow; the criteria move into the query with real data.
/// - Parameter filter: the criteria the listings are narrowed and ordered by.
/// - Returns: every matching listing.
public func listings(
matching filter: ListingFilter = .init()
) async throws -> [Listing] {
try await ListingRecord.query(on: fluent.db())
.with(\.$shop)
.all()
.map(\.snapshot)
.filter(filter.matches)
.sorted(by: filter.ordersBefore)
}
/// Opens a shop for an account: unverified and unrated, public from this moment.
/// - Throws: a ``ShopError`` when the account already owns a shop, or the handle is taken.
public func openShop(
_ draft: ShopDraft,
for owner: User,
at date: Date = .init()
) async throws -> Shop {
try await fluent.db().transaction { database in
guard try await ShopRecord.query(on: database)
.filter(\.$owner.$id == owner.id)
.first() == nil
else {
throw ShopError.alreadyOwner
}
guard try await ShopRecord.query(on: database)
.filter(\.$slug == draft.handle)
.first() == nil
else {
throw ShopError.handleTaken
}
let shop = Shop(
slug: draft.handle,
name: draft.name,
city: draft.city,
country: draft.country,
sinceYear: Calendar(identifier: .gregorian).component(.year, from: date),
isVerified: false,
rating: 0,
ratedSales: 0,
soldCount: 0,
handlingDays: draft.handlingDays,
shipsNote: "ships tracked",
returnsNote: draft.returnsNote,
about: draft.about,
kind: draft.kind,
kvkNumber: draft.kind == .business ? draft.kvkNumber : nil,
vatID: draft.kind == .business ? draft.vatID : nil,
legalName: draft.kind == .business ? draft.legalName : nil
)
let record = ShopRecord(shop)
record.$owner.id = owner.id
try await record.save(on: database)
return shop
}
}
/// Publishes a draft as a listing of the shop the seller owns, under a slug of its own.
/// - Parameters:
/// - draft: what the seller filled in.
/// - seller: the account listing the copy.
/// - date: the moment the copy is listed; the present by default.
/// - Returns: the listing, or `nil` when the seller owns no shop.
public func publish(
_ draft: ListingDraft,
by seller: User,
at date: Date = .init()
) async throws -> Listing? {
try await fluent.db().transaction { database in
guard let shop = try await ShopRecord.query(on: database)
.filter(\.$owner.$id == seller.id)
.first()
else {
return nil
}
let listing = draft.listing(slug: "", shop: shop.snapshot, at: date)
let base = ListingDraft.slug("\(listing.title) \(listing.gradeLabel)")
let taken = Set(
try await ListingRecord.query(on: database)
.filter(\.$slug =~ base)
.all(\.$slug)
)
var slug = base
var suffix = 2
while taken.contains(slug) {
slug = "\(base)-\(suffix)"
suffix += 1
}
let published = draft.listing(slug: slug, shop: shop.snapshot, at: date)
try await ListingRecord(published, shopID: try shop.requireID()).save(on: database)
return published
}
}
/// Fetches the name of every publisher with a listing, in alphabetical order.
public func publishers() async throws -> [String] {
try await ListingRecord.query(on: fluent.db())
.unique()
.sort(\.$publisher)
.all(\.$publisher)
}
/// Fetches the shop with the given slug.
/// - Parameter slug: the shop's URL slug.
/// - Returns: the shop, or `nil` when none has the slug.
public func shop(
slug: String
) async throws -> Shop? {
try await ShopRecord.query(on: fluent.db())
.filter(\.$slug == slug)
.first()?
.snapshot
}
}
@@ -0,0 +1,296 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// Reads and writes what a collector keeps: their want list, and the series catalogue they fill gaps in.
public struct CollectingRepository: Sendable {
// MARK: Constants
/// The most wants an account keeps.
public static let maxWants = 50
// MARK: Properties
private let fluent: Fluent
/// The clock wants are stamped and new matches judged by.
private let now: @Sendable () -> Date
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameters:
/// - fluent: the service whose default database the repository operates on.
/// - now: the clock wants are stamped and new matches judged by; the system's by default.
public init(
fluent: Fluent,
now: @escaping @Sendable () -> Date = { .init() }
) {
self.fluent = fluent
self.now = now
}
}
// MARK: - Wants
public extension CollectingRepository {
/// Adds a want to an account's list; a blank one, or one past the limit, is dropped.
func addWant(
title: String,
minGrade: Double?,
maxPriceCents: Int?,
slabbedOnly: Bool,
for user: User
) async throws {
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard
!title.isEmpty,
try await WantRecord.query(on: fluent.db()).filter(\.$user.$id == user.id).count() < Self.maxWants
else {
return
}
let want = WantRecord()
want.$user.id = user.id
want.title = String(title.prefix(120))
want.minGrade = minGrade
want.maxPriceCents = maxPriceCents
want.slabbedOnly = slabbedOnly
want.alerts = true
want.createdAt = now()
// A match already listed is news to a want just made.
want.seenAt = .distantPast
try await want.save(on: fluent.db())
}
/// Marks an account's wants as looked at: their matches so far are no longer new.
func markWantsSeen(
by user: User
) async throws {
try await WantRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.set(\.$seenAt, to: now())
.update()
}
/// The number of new matches across the wants an account has alerts on for.
func newMatchCount(
for user: User
) async throws -> Int {
try await wants(of: user)
.filter(\.alerts)
.reduce(0) { $0 + $1.newMatchCount }
}
/// Removes one of an account's wants.
func removeWant(
_ id: UUID,
of user: User
) async throws {
try await WantRecord.query(on: fluent.db())
.filter(\.$id == id)
.filter(\.$user.$id == user.id)
.delete()
}
/// Switches the alerts of one of an account's wants.
func toggleAlerts(
ofWant id: UUID,
of user: User
) async throws {
guard let want = try await WantRecord.query(on: fluent.db())
.filter(\.$id == id)
.filter(\.$user.$id == user.id)
.first()
else {
return
}
want.alerts.toggle()
try await want.save(on: fluent.db())
}
/// Fetches an account's wants, the newest first, each with the listings that meet it.
func wants(
of user: User
) async throws -> [Want] {
let listings = try await CatalogueRepository(fluent: fluent).listings()
return try await WantRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.sort(\.$createdAt, .descending)
.all()
.map { record in
let filter = Want.filter(
title: record.title,
minGrade: record.minGrade,
maxPriceCents: record.maxPriceCents,
slabbedOnly: record.slabbedOnly
)
let matches = listings.filter(filter.matches)
return .init(
id: try record.requireID(),
title: record.title,
minGrade: record.minGrade,
maxPriceCents: record.maxPriceCents,
slabbedOnly: record.slabbedOnly,
alerts: record.alerts,
matches: matches,
newMatchCount: matches.count { $0.listedAt > record.seenAt }
)
}
}
}
// MARK: - Series
public extension CollectingRepository {
/// Fetches every series in the catalogue, by name.
func allSeries() async throws -> [Series] {
try await SeriesRecord.query(on: fluent.db())
.sort(\.$name)
.all()
.map(\.snapshot)
}
/// Fetches the series with the given slug.
///
/// A series sellers list copies of, but the catalogue has no run for, is made up from those listings — without an issue count, so without gaps.
func series(
slug: String
) async throws -> Series? {
if let record = try await SeriesRecord.query(on: fluent.db())
.filter(\.$slug == slug)
.first()
{
return record.snapshot
}
return try await CatalogueRepository(fluent: fluent)
.listings(matching: .init(sort: .newest))
.first { $0.seriesSlug == slug }
.map {
.init(
slug: slug,
name: $0.seriesName,
publisher: $0.publisher,
years: String($0.year),
issueCount: 0,
blurb: ""
)
}
}
}
// MARK: - Collection
public extension CollectingRepository {
/// Adds issues of a series to an account's collection; the ones already in it, or outside the run, are skipped.
func own(
issues: [Int],
ofSeries slug: String,
by user: User
) async throws {
guard let series = try await series(slug: slug) else {
return
}
let owned = try await ownedIssues(of: user)[slug] ?? []
let run = series.issueCount > 0 ? 1...series.issueCount : 1...999
for issue in Set(issues).subtracting(owned) where run.contains(issue) {
try await OwnedIssueRecord(userID: user.id, seriesSlug: slug, issue: issue).save(on: fluent.db())
}
}
/// Fetches the issues an account owns, by series slug.
func ownedIssues(
of user: User
) async throws -> [String: Set<Int>] {
try await OwnedIssueRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.all()
.reduce(into: [:]) { $0[$1.seriesSlug, default: []].insert($1.issue) }
}
/// Takes an issue out of an account's collection.
func disown(
issue: Int,
ofSeries slug: String,
by user: User
) async throws {
try await OwnedIssueRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.filter(\.$seriesSlug == slug)
.filter(\.$issue == issue)
.delete()
}
}
// MARK: - Events
public extension CollectingRepository {
/// Fetches the events still to come or under way, soonest first, each with the shops trading there.
func upcomingEvents() async throws -> [Event] {
try await EventRecord.query(on: fluent.db())
.filter(\.$endsOn >= now().addingTimeInterval(-86_400))
.sort(\.$startsOn)
.with(\.$shops)
.all()
.map { record in
.init(
slug: record.slug,
name: record.name,
venue: record.venue,
startsOn: record.startsOn,
endsOn: record.endsOn,
shops: record.shops.map(\.snapshot)
)
}
}
/// Sets the upcoming events the shop an account owns trades at.
func setEvents(
_ slugs: [String],
forShopOwnedBy user: User
) async throws {
try await fluent.db().transaction { database in
guard let shop = try await ShopRecord.query(on: database)
.filter(\.$owner.$id == user.id)
.first()
else {
return
}
let upcoming = try await EventRecord.query(on: database)
.filter(\.$endsOn >= now().addingTimeInterval(-86_400))
.with(\.$shops)
.all()
for event in upcoming {
let trades = try event.shops.contains { try $0.requireID() == shop.requireID() }
if slugs.contains(event.slug), !trades {
try await event.$shops.attach(shop, on: database)
} else if !slugs.contains(event.slug), trades {
try await event.$shops.detach(shop, on: database)
}
}
}
}
}
@@ -0,0 +1,637 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// Reads and writes what leads to a sale: conversations, offers, and the sale records with their handshake.
///
/// Every write checks that the account may make it, and throws a ``TradeError`` when not. A write touching several rows runs in a transaction.
public struct TradeRepository: Sendable {
// MARK: Constants
/// The number before the first sale record's: the prototype's records count up from `CCN-48213`.
private static let firstNumber = 48_212
// MARK: Properties
private let fluent: Fluent
/// The clock entries are stamped and expiries judged by.
private let now: @Sendable () -> Date
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameters:
/// - fluent: the service whose default database the repository operates on.
/// - now: the clock entries are stamped and expiries judged by; the system's by default.
public init(
fluent: Fluent,
now: @escaping @Sendable () -> Date = { .init() }
) {
self.fluent = fluent
self.now = now
}
}
// MARK: - Conversations
public extension TradeRepository {
/// Fetches a conversation as one of its parties sees it, marking it read by them.
/// - Returns: the conversation, or `nil` when there is none the account is a party to.
func conversation(
id: UUID,
for user: User
) async throws -> Conversation? {
guard
let record = try await conversations(on: fluent.db())
.filter(\.$id == id)
.first(),
let side = side(of: user.id, in: record)
else {
return nil
}
let conversation = try snapshot(of: record, as: side)
if side == .buying {
record.buyerReadAt = now()
} else {
record.sellerReadAt = now()
}
try await record.save(on: fluent.db())
return conversation
}
/// Fetches every conversation an account is a party to, the latest spoken in first.
func conversations(
for user: User
) async throws -> [Conversation] {
try await conversations(on: fluent.db())
.all()
.compactMap { record in
try side(of: user.id, in: record).map { try snapshot(of: record, as: $0) }
}
.sorted { ($0.latest?.createdAt ?? .distantPast) > ($1.latest?.createdAt ?? .distantPast) }
}
/// Finds the buyer's conversation about a listing, starting it when there is none.
/// - Returns: the conversation's identifier.
func conversation(
aboutListing slug: String,
buyer: User
) async throws -> UUID {
try await fluent.db().transaction { database in
let listing = try await listing(slug: slug, on: database)
return try await conversation(
between: buyer,
and: listing.shop,
about: listing,
on: database
).requireID()
}
}
/// Finds the buyer's conversation with a shop at large, starting it when there is none.
/// - Returns: the conversation's identifier.
func conversation(
withShop slug: String,
buyer: User
) async throws -> UUID {
try await fluent.db().transaction { database in
guard let shop = try await ShopRecord.query(on: database)
.filter(\.$slug == slug)
.with(\.$owner)
.first()
else {
throw TradeError.listingUnavailable
}
return try await conversation(
between: buyer,
and: shop,
about: nil,
on: database
).requireID()
}
}
/// Adds a message to a conversation; one that is blank is dropped.
func post(
_ body: String,
to id: UUID,
from user: User
) async throws {
let body = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard
let record = try await conversations(on: fluent.db())
.filter(\.$id == id)
.first(),
side(of: user.id, in: record) != nil
else {
throw TradeError.notAllowed
}
guard !body.isEmpty else {
return
}
try await MessageRecord(
conversationID: id,
senderID: user.id,
kind: .text,
body: String(body.prefix(2_000)),
at: now()
).save(on: fluent.db())
}
/// The number of conversations holding something an account has not seen yet.
func unreadCount(
for user: User
) async throws -> Int {
try await conversations(for: user)
.count { $0.unreadCount > 0 }
}
}
// MARK: - Offers
public extension TradeRepository {
/// Makes a buyer's offer on a listing, in their conversation about it.
///
/// The offer binds the buyer for ``Offer/lifetime``. One under the listing's floor is turned down outright, and a buyer holds one open
/// offer per listing.
/// - Returns: the identifier of the conversation the offer was made in.
func makeOffer(
onListing slug: String,
buyer: User,
amountCents: Int,
note: String
) async throws -> UUID {
try await fluent.db().transaction { database in
let listing = try await listing(slug: slug, on: database)
guard listing.acceptsOffers else {
throw TradeError.offersNotAccepted
}
guard amountCents > 0, amountCents <= listing.priceCents else {
throw TradeError.invalidAmount
}
let floor = Offer.floor(forAsking: listing.priceCents)
guard amountCents >= floor else {
throw TradeError.offerTooLow(floorCents: floor)
}
let listingID = try listing.requireID()
let isOpen = try await OfferRecord.query(on: database)
.filter(\.$listing.$id == listingID)
.filter(\.$buyer.$id == buyer.id)
.filter(\.$status == Offer.Status.open.rawValue)
.filter(\.$expiresAt > now())
.first() != nil
guard !isOpen else {
throw TradeError.offerAlreadyOpen
}
let conversationID = try await conversation(
between: buyer,
and: listing.shop,
about: listing,
on: database
).requireID()
let offer = OfferRecord(
conversationID: conversationID,
listingID: listingID,
buyerID: buyer.id,
amountCents: amountCents,
at: now()
)
try await offer.save(on: database)
try await MessageRecord(
conversationID: conversationID,
senderID: buyer.id,
kind: .offer,
body: String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(500)),
offerID: try offer.requireID(),
at: now()
).save(on: database)
return conversationID
}
}
/// Fetches the offers waiting for a seller's answer, the soonest to expire first.
func openOffers(
for seller: User
) async throws -> [Offer] {
try await OfferRecord.query(on: fluent.db())
.filter(\.$status == Offer.Status.open.rawValue)
.filter(\.$expiresAt > now())
.sort(\.$expiresAt)
.with(\.$buyer)
.with(\.$listing) { $0.with(\.$shop) { $0.with(\.$owner) } }
.all()
.filter { $0.listing.shop.$owner.id == seller.id }
.map { try $0.snapshot(at: now()) }
}
/// Answers an offer on the seller's behalf. Accepting it opens a sale record.
/// - Returns: the number of the sale opened, or `nil` when the offer was declined.
@discardableResult
func respond(
toOffer id: UUID,
accepting: Bool,
by seller: User
) async throws -> Int? {
try await fluent.db().transaction { database in
guard
let offer = try await OfferRecord.query(on: database)
.filter(\.$id == id)
.with(\.$listing, { $0.with(\.$shop) })
.first(),
offer.listing.shop.$owner.id == seller.id,
offer.status == Offer.Status.open.rawValue,
offer.expiresAt > now()
else {
throw TradeError.notAllowed
}
guard accepting else {
offer.status = Offer.Status.declined.rawValue
try await offer.save(on: database)
try await MessageRecord(
conversationID: offer.$conversation.id,
senderID: seller.id,
kind: .event,
body: "declined the offer",
at: now()
).save(on: database)
return nil
}
guard offer.listing.soldAt == nil else {
throw TradeError.listingUnavailable
}
offer.status = Offer.Status.accepted.rawValue
try await offer.save(on: database)
return try await agree(
on: offer.listing,
conversationID: offer.$conversation.id,
buyerID: offer.$buyer.id,
sellerID: seller.id,
priceCents: offer.amountCents,
event: (seller.id, "accepted the offer"),
on: database
)
}
}
}
// MARK: - Sales
public extension TradeRepository {
/// Buys a listing outright, at its asking price, opening a sale record.
/// - Returns: the number of the sale opened.
func buy(
listing slug: String,
buyer: User
) async throws -> Int {
try await fluent.db().transaction { database in
let listing = try await listing(slug: slug, on: database)
guard let sellerID = listing.shop.$owner.id else {
throw TradeError.shopUnattended
}
let conversation = try await conversation(
between: buyer,
and: listing.shop,
about: listing,
on: database
)
return try await agree(
on: listing,
conversationID: try conversation.requireID(),
buyerID: buyer.id,
sellerID: sellerID,
priceCents: listing.priceCents,
event: (buyer.id, "bought the copy at the asking price"),
on: database
)
}
}
/// Takes a step of a sale's handshake, stamping it into the sale's conversation.
/// - Returns: the sale after the step.
@discardableResult
func perform(
_ action: Sale.Action,
onSale number: Int,
by user: User
) async throws -> Sale {
try await fluent.db().transaction { database in
guard
let record = try await sales(on: database)
.filter(\.$number == number)
.first(),
let role = try record.snapshot.role(of: user.id),
try record.snapshot.allows(action, by: role)
else {
throw TradeError.notAllowed
}
let event: String
switch action {
case .markPaid(let note):
record.paidAt = now()
record.paymentNote = Self.note(note)
event = "marked the sale paid"
case .markSent(let note):
record.sentAt = now()
record.shippingNote = Self.note(note)
event = "marked the copy sent"
case .markReceived:
record.receivedAt = now()
event = "marked the copy received"
case .rate(let stars):
if role == .buyer {
record.buyerRating = stars
} else {
record.sellerRating = stars
}
event = "left a rating"
}
try await record.save(on: database)
try await MessageRecord(
conversationID: record.$conversation.id,
senderID: user.id,
kind: .event,
body: event,
saleNumber: number,
at: now()
).save(on: database)
return try record.snapshot
}
}
/// Fetches a sale record as one of its parties.
/// - Returns: the sale, or `nil` when there is none the account is a party to.
func sale(
number: Int,
for user: User
) async throws -> Sale? {
try await sales(on: fluent.db())
.filter(\.$number == number)
.first()
.map { try $0.snapshot }
.flatMap { $0.role(of: user.id) == nil ? nil : $0 }
}
/// Fetches every sale an account is a party to, the latest agreed first.
func sales(
for user: User
) async throws -> [Sale] {
try await sales(on: fluent.db())
.group(.or) {
$0.filter(\.$buyer.$id == user.id)
$0.filter(\.$seller.$id == user.id)
}
.sort(\.$agreedAt, .descending)
.all()
.map { try $0.snapshot }
}
/// Fetches the shop an account owns.
/// - Returns: the shop, or `nil` when the account owns none.
func shop(
ownedBy user: User
) async throws -> Shop? {
try await ShopRecord.query(on: fluent.db())
.filter(\.$owner.$id == user.id)
.first()?
.snapshot
}
}
// MARK: - Helpers
private extension TradeRepository {
// MARK: Methods
/// Opens the sale record of an agreement, takes the listing off sale, lapses the other offers on it, and stamps the event.
/// - Returns: the number of the sale opened.
func agree(
on listing: ListingRecord,
conversationID: UUID,
buyerID: UUID,
sellerID: UUID,
priceCents: Int,
event: (actor: UUID, body: String),
on database: any Database
) async throws -> Int {
let listingID = try listing.requireID()
let number = (try await SaleRecord.query(on: database).max(\.$number) ?? Self.firstNumber) + 1
let declared = listing.snapshot
let sale = SaleRecord()
sale.number = number
sale.$conversation.id = conversationID
sale.$listing.id = listingID
sale.$buyer.id = buyerID
sale.$seller.id = sellerID
sale.priceCents = priceCents
sale.askedCents = listing.priceCents
sale.shippingCents = listing.shippingCents
sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "")
sale.declaredPageQuality = declared.pageQuality
sale.declaredPrinting = declared.printingDetail
sale.declaredNotes = declared.declaration
sale.agreedAt = now()
listing.soldAt = now()
try await sale.save(on: database)
try await listing.save(on: database)
try await OfferRecord.query(on: database)
.filter(\.$listing.$id == listingID)
.filter(\.$status == Offer.Status.open.rawValue)
.set(\.$status, to: Offer.Status.expired.rawValue)
.update()
try await MessageRecord(
conversationID: conversationID,
senderID: event.actor,
kind: .event,
body: event.body,
saleNumber: number,
at: now()
).save(on: database)
return number
}
/// Finds the conversation between a buyer and a shop about a listing — or about the shop at large — starting it when there is none.
func conversation(
between buyer: User,
and shop: ShopRecord,
about listing: ListingRecord?,
on database: any Database
) async throws -> ConversationRecord {
guard let ownerID = shop.$owner.id else {
throw TradeError.shopUnattended
}
guard ownerID != buyer.id else {
throw TradeError.ownListing
}
let shopID = try shop.requireID()
let listingID = try listing?.requireID()
let existing = try await ConversationRecord.query(on: database)
.filter(\.$buyer.$id == buyer.id)
.filter(\.$shop.$id == shopID)
.all()
.first { $0.$listing.id == listingID }
if let existing {
return existing
}
let conversation = ConversationRecord(
buyerID: buyer.id,
shopID: shopID,
listingID: listingID,
at: now()
)
try await conversation.save(on: database)
return conversation
}
/// The conversations query, with everything a snapshot reads eager-loaded.
func conversations(
on database: any Database
) -> QueryBuilder<ConversationRecord> {
ConversationRecord.query(on: database)
.with(\.$buyer)
.with(\.$shop)
.with(\.$listing) { $0.with(\.$shop) }
.with(\.$sales)
.with(\.$messages) { message in
message
.with(\.$sender)
.with(\.$offer) { offer in
offer
.with(\.$buyer)
.with(\.$listing) { $0.with(\.$shop) }
}
}
}
/// Fetches a listing still for sale, with its shop.
func listing(
slug: String,
on database: any Database
) async throws -> ListingRecord {
guard
let listing = try await ListingRecord.query(on: database)
.filter(\.$slug == slug)
.with(\.$shop)
.first(),
listing.soldAt == nil
else {
throw TradeError.listingUnavailable
}
return listing
}
/// A party's note, trimmed and capped, or `nil` when blank.
static func note(
_ note: String
) -> String? {
let note = note.trimmingCharacters(in: .whitespacesAndNewlines)
return note.isEmpty ? nil : String(note.prefix(200))
}
/// The sales query, with everything a snapshot reads eager-loaded.
func sales(
on database: any Database
) -> QueryBuilder<SaleRecord> {
SaleRecord.query(on: database)
.with(\.$buyer)
.with(\.$seller)
.with(\.$listing) { $0.with(\.$shop) }
}
/// The side of a conversation an account is on, or `nil` when it is a party to neither.
func side(
of userID: UUID,
in record: ConversationRecord
) -> Conversation.Side? {
if record.$buyer.id == userID {
return .buying
}
return record.shop.$owner.id == userID ? .selling : nil
}
/// The snapshot of a conversation, as the given side sees it.
func snapshot(
of record: ConversationRecord,
as side: Conversation.Side
) throws -> Conversation {
let viewerID = side == .buying ? record.$buyer.id : record.shop.$owner.id
let readAt = side == .buying ? record.buyerReadAt : record.sellerReadAt
let messages = record.messages.sorted { $0.createdAt < $1.createdAt }
return .init(
id: try record.requireID(),
buyer: try record.buyer.snapshot,
shop: record.shop.snapshot,
listing: record.listing?.snapshot,
side: side,
entries: try messages.map { message in
.init(
kind: .init(rawValue: message.kind) ?? .text,
sender: try message.sender.snapshot,
body: message.body,
offer: try message.offer?.snapshot(at: now()),
saleNumber: message.saleNumber,
createdAt: message.createdAt
)
},
unreadCount: messages.count { $0.createdAt > readAt && $0.$sender.id != viewerID },
saleNumber: record.sales.first?.number
)
}
}
@@ -19,14 +19,18 @@ struct TLSTests {
// 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()
defer {
try? await server.stop()
}
let connection = try await connect(to: server, tls: .off)
let isConnected = !connection.isClosed
defer {
try? await connection.close()
}
try await connection.close()
try await server.stop()
#expect(isConnected)
#expect(!connection.isClosed)
}
@Test
@@ -34,14 +38,18 @@ struct TLSTests {
// 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()
defer {
try? await server.stop()
}
let connection = try await connect(to: server, tls: .prefer)
let isConnected = !connection.isClosed
defer {
try? await connection.close()
}
try await connection.close()
try await server.stop()
#expect(isConnected)
#expect(!connection.isClosed)
}
@Test
@@ -64,6 +64,11 @@ struct ProbeTests {
// 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()
defer {
try? await server.stop()
}
let service = try Service(
driver: .postgres(
.init(
@@ -91,7 +96,6 @@ struct ProbeTests {
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
@@ -69,26 +69,22 @@ struct ServiceTests {
let fluent = service()
do {
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
} catch {
defer {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
}
@Test("in-memory: migrate, insert, read back")
@Test("in-memory: migrate, seed, read back")
func inMemoryRoundTrip() async throws {
try await roundTrip(driver: .inMemory)
}
@Test(
"postgres: migrate, insert, read back",
"postgres: migrate, seed, read back",
.enabled(if: postgresDriver != nil)
)
func postgresRoundTrip() async throws {
@@ -104,7 +100,7 @@ struct ServiceTests {
private extension ServiceTests {
/// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after.
/// Migrates, seeds, and reads the catalogue back against the given driver, shutting the pool down after.
///
/// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that,
/// the test must shut it down explicitly — even on failure — or the pool asserts on `deinit`.
@@ -124,26 +120,21 @@ private extension ServiceTests {
let fluent = service()
do {
await prepareDB(for: fluent)
try await fluent.migrate()
let repository = ExampleRepository(fluent: fluent)
let created = try await repository.create(name: "site")
#expect(try await repository.all().contains(created))
if revertAfter {
try await fluent.revert()
}
} catch {
defer {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
await prepareDB(for: fluent)
try await fluent.migrate()
let repository = CatalogueRepository(fluent: fluent)
#expect(try await !repository.listings().isEmpty)
if revertAfter {
try await fluent.revert()
}
}
}
@@ -0,0 +1,137 @@
import Foundation
import Testing
@testable import Persistence
@Suite(
"ListingDraft model",
.tags(.model)
)
struct ListingDraftTests {
// MARK: Computed tests
@Test
func `titles a copy by its format`() {
#expect(Self.draft().title == "Nightward #3")
#expect(Self.draft(format: "manga").title == "Nightward, Vol. 3")
#expect(Self.draft(issue: "", format: "hardcovers").title == "Nightward")
}
@Test
func `words the printing short and in full`() {
#expect(Self.draft().printingLabel == "1st print")
#expect(Self.draft().printingDetail == "First printing, direct edition")
#expect(Self.draft(format: "manga", printing: 2, edition: "").printingLabel == "2nd printing")
#expect(Self.draft(printing: 0, edition: "").printingDetail == "Later printing")
}
@Test
func `letters the placeholder cover over a few lines, toned by the series`() {
let cover = Self.draft(series: "The Gilded Hour of Saints", format: "trade-paperbacks").cover
#expect(cover.title == "THE GILDED\nHOUR OF\nSAINTS")
#expect(cover.number == "TPB")
#expect(cover.imprint == "MERIDIAN COMICS")
#expect(ListingDraft.tones.contains(cover.tone))
#expect(Self.draft(issue: "1").cover.tone == Self.draft(issue: "2").cover.tone)
#expect(Self.draft(format: "manga").cover.number == "VOL 3")
}
// MARK: Methods tests
@Test
func `publishes as a listing carrying what was declared`() {
let listing = Self.draft().listing(slug: "nightward-3-raw-nm", shop: Seed.shops[0], at: .init(timeIntervalSince1970: 0))
#expect(listing.seriesTitle == "Nightward (2019 series)")
#expect(listing.issueLabel == "#3")
#expect(listing.isFirstPrint)
#expect(listing.gradeLabel == "Raw · NM")
#expect(listing.defects == ["Spine stress / ticks"])
#expect(listing.photos == ["/uploads/a.jpg"])
#expect(listing.declaration == "Declared defects: Spine stress / ticks. Two ticks near the top staple.")
#expect(listing.isAvailable)
}
@Test(arguments: zip(
["Nightward #1 CGC 9.8", "Hollow Comet, Vol. 4 — Sōkai", " Raw · NM+ ", "???"],
["nightward-1-cgc-9-8", "hollow-comet-vol-4-sokai", "raw-nm", ""]
))
func `slugs a title down to letters, digits, and hyphens`(
_ title: String,
expects slug: String
) {
#expect(ListingDraft.slug(title) == slug)
}
// MARK: Repository tests
@Test
func `publishes into the seller's shop, under a slug of its own, and into the search`() async throws {
try await withDatabase { fluent in
let catalogue = CatalogueRepository(fluent: fluent)
let joost = try await AccountRepository(fluent: fluent).account(email: "joost@kestrelrow.example")
let first = try #require(try await catalogue.publish(Self.draft(), by: joost))
let second = try #require(try await catalogue.publish(Self.draft(), by: joost))
let found = try await catalogue.listing(slug: first.slug)
let newest = try await catalogue.listings().first
#expect(first.slug == "nightward-3-raw-nm")
#expect(second.slug == "nightward-3-raw-nm-2")
#expect(first.shop.slug == "kestrel-row-comics")
#expect(found?.defects == ["Spine stress / ticks"])
#expect(found?.photos == ["/uploads/a.jpg"])
#expect(newest?.slug == second.slug)
}
}
@Test
func `publishes nothing for an account without a shop`() async throws {
try await withDatabase { fluent in
let mara = try await AccountRepository(fluent: fluent).account(email: "mara.v@example.com")
let listing = try await CatalogueRepository(fluent: fluent).publish(Self.draft(), by: mara)
#expect(listing == nil)
}
}
@Test
func `freezes the declared defects into a sale`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let joost = try await accounts.account(email: "joost@kestrelrow.example")
let mara = try await accounts.account(email: "mara.v@example.com")
let listing = try #require(try await CatalogueRepository(fluent: fluent).publish(Self.draft(), by: joost))
let trade = TradeRepository(fluent: fluent)
let number = try await trade.buy(listing: listing.slug, buyer: mara)
let sale = try await trade.sale(number: number, for: mara)
#expect(sale?.declared.notes == "Declared defects: Spine stress / ticks. Two ticks near the top staple.")
}
}
}
// MARK: - Helpers
extension ListingDraftTests {
/// A draft of a raw Nightward #3 with one declared defect and one photo, unless told otherwise.
static func draft(
series: String = "Nightward",
issue: String = "3",
format: String = "single-issues",
printing: Int = 1,
edition: String = "Direct"
) -> ListingDraft {
.init(
series: series, issue: issue, publisher: "Meridian Comics", year: 2019, format: format,
printing: printing, edition: edition, grader: nil, grade: 9.4, condition: "NM", certNumber: nil,
pageQuality: nil, keyNotes: nil, defects: ["Spine stress / ticks"],
sellerNotes: "Two ticks near the top staple.", priceCents: 3_000, shippingCents: 500,
acceptsOffers: true, photos: ["/uploads/a.jpg"]
)
}
}
@@ -0,0 +1,91 @@
import Testing
@testable import Persistence
@Suite(
"ListingFilter model",
.tags(.model)
)
struct ListingFilterTests {
// MARK: Methods tests
@Test
func `matches every listing by default`() {
#expect(slugs(.init()).count == Seed.listings.count)
}
@Test
func `matches every word of the query across title, publisher, and shop`() {
#expect(slugs(.init(query: "NIGHTWARD kestrel")) == [
"nightward-1-cgc-9-8", "nightward-the-first-year-hc", "nightward-2-cgc-9-6",
])
#expect(slugs(.init(query: "no such book")).isEmpty)
}
@Test
func `matches the given formats`() {
#expect(slugs(.init(formats: ["manga"])) == ["hollow-comet-vol-4", "solar-divide-vol-1", "hollow-comet-vol-1"])
}
@Test
func `matches slabbed or raw copies`() {
#expect(slugs(.init(isSlabbed: true)).count == 5)
#expect(slugs(.init(formats: ["single-issues"], isSlabbed: false)).count == 8)
}
@Test
func `leaves ungraded listings out of a grade range`() {
#expect(slugs(.init(gradeMin: 9.6)) == ["nightward-1-cgc-9-8", "paper-tigers-1-variant-b", "nightward-2-cgc-9-6"])
#expect(slugs(.init(gradeMax: 7)) == ["vermilion-lake-7-cgc-6-5"])
}
@Test
func `matches a price range in cents`() {
#expect(slugs(.init(priceMin: 10_000, priceMax: 20_000)) == [
"nightward-1-cgc-9-4", "nightward-1-raw-nm", "ferrous-saint-1-cbcs-9-0",
])
}
@Test
func `matches where a listing ships from`() {
#expect(slugs(.init(shipsFrom: .nl)).count == 9)
#expect(slugs(.init(shipsFrom: .eu)).count == Seed.listings.count - 3)
}
@Test
func `matches the remaining flags`() {
#expect(slugs(.init(verifiedShops: true)).count == Seed.listings.count - 3)
#expect(slugs(.init(keysOnly: true)).count == 8)
#expect(slugs(.init(firstPrintOnly: true)).count == Seed.listings.count - 4)
#expect(slugs(.init(acceptsOffers: true)).count == 7)
#expect(slugs(.init(publishers: ["Cutwater Press"])) == ["paper-tigers-1-variant-b", "paper-tigers-2"])
#expect(slugs(.init(shop: "silverfish-comics")).count == 4)
}
@Test
func `orders ungraded listings last by grade`() {
let grades = Seed.listings
.sorted(by: ListingFilter(sort: .gradeDescending).ordersBefore)
.map(\.grade)
#expect(grades.first == 9.8)
#expect(grades.suffix(6).allSatisfy { $0 == nil })
}
}
// MARK: - Helpers
private extension ListingFilterTests {
/// The slugs of the demo listings the filter matches, in seed order.
func slugs(
_ filter: ListingFilter
) -> [String] {
Seed.listings
.filter(filter.matches)
.map(\.slug)
}
}
@@ -0,0 +1,118 @@
import Foundation
import Testing
@testable import Persistence
@Suite(
"Sale model",
.tags(.model)
)
struct SaleTests {
// MARK: Computed tests
@Test
func `walks the handshake one step at a time`() {
#expect(sale().completed == .agreed)
#expect(sale().next == .paid)
#expect(sale(paid: true).completed == .paid)
#expect(sale(paid: true, sent: true).completed == .sent)
#expect(sale(paid: true, sent: true, received: true).completed == .received)
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).completed == .received)
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).completed == .rated)
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).next == nil)
}
@Test
func `adds the shipping to the price`() {
#expect(sale().totalCents == 22_100)
}
// MARK: Methods tests
@Test
func `gives each step to its party, once, in order`() {
#expect(sale().allows(.markPaid(note: ""), by: .buyer))
#expect(!sale().allows(.markPaid(note: ""), by: .seller))
#expect(!sale(paid: true).allows(.markPaid(note: ""), by: .buyer))
#expect(!sale().allows(.markSent(note: ""), by: .seller))
#expect(sale(paid: true).allows(.markSent(note: ""), by: .seller))
#expect(!sale(paid: true).allows(.markSent(note: ""), by: .buyer))
#expect(!sale(paid: true).allows(.markReceived, by: .buyer))
#expect(sale(paid: true, sent: true).allows(.markReceived, by: .buyer))
#expect(!sale(paid: true, sent: true).allows(.markReceived, by: .seller))
}
@Test
func `unlocks ratings only once the copy is received, one per party, out of five`() {
let received = sale(paid: true, sent: true, received: true)
#expect(!sale(paid: true, sent: true).allows(.rate(stars: 5), by: .buyer))
#expect(received.allows(.rate(stars: 5), by: .buyer))
#expect(received.allows(.rate(stars: 1), by: .seller))
#expect(!received.allows(.rate(stars: 0), by: .buyer))
#expect(!received.allows(.rate(stars: 6), by: .buyer))
#expect(!sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .buyer))
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .seller))
}
@Test
func `knows its parties`() {
let sale = sale()
#expect(sale.role(of: sale.buyer.id) == .buyer)
#expect(sale.role(of: sale.seller.id) == .seller)
#expect(sale.role(of: .init()) == nil)
}
// MARK: Offer tests
@Test(arguments: zip(
[24_000, 1_400, 4_500, 100],
[19_200, 1_200, 3_600, 100]
))
func `floors offers at four fifths of the asking price, in whole euros`(
asking: Int,
expects floor: Int
) {
#expect(Offer.floor(forAsking: asking) == floor)
}
}
// MARK: - Helpers
private extension SaleTests {
/// A sale of the first demo listing at €215 + €6, as far along as told.
func sale(
paid: Bool = false,
sent: Bool = false,
received: Bool = false,
buyerRating: Int? = nil,
sellerRating: Int? = nil
) -> Sale {
let stamp = Date(timeIntervalSince1970: 0)
return .init(
number: 48_213,
conversationID: .init(),
listing: Seed.listings[0],
buyer: .init(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"),
seller: .init(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit"),
priceCents: 21_500,
askedCents: 24_000,
shippingCents: 600,
declared: .init(grade: "CGC 9.8", pageQuality: "White", printing: "First printing", notes: ""),
agreedAt: stamp,
paidAt: paid ? stamp : nil,
sentAt: sent ? stamp : nil,
receivedAt: received ? stamp : nil,
buyerRating: buyerRating,
sellerRating: sellerRating
)
}
}
@@ -0,0 +1,173 @@
import Testing
@testable import Persistence
@Suite(
"AccountRepository repository",
.tags(.repository)
)
struct AccountRepositoryTests {
// MARK: Methods tests
@Test
func `finds the seeded demo buyer`() async throws {
try await withDatabase { fluent in
let user = try await AccountRepository(fluent: fluent).account(email: AccountRepository.demoEmail)
#expect(user.email == "mara.v@example.com")
#expect(user.displayName == "Mara Visser")
}
}
@Test
func `joins an unknown email once, named after its local part`() async throws {
try await withDatabase { fluent in
let repository = AccountRepository(fluent: fluent)
let joined = try await repository.account(email: "sam@example.com")
let returning = try await repository.account(email: "sam@example.com")
#expect(joined.displayName == "sam")
#expect(returning == joined)
}
}
}
// MARK: - Passkeys
extension AccountRepositoryTests {
@Test
func `joins an account together with its first passkey`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let free = try await accounts.isUnregistered(credentialID: "cred-1")
let sam = try #require(try await accounts.join(email: "sam@example.com", credentialID: "cred-1", publicKey: [1, 2, 3], signCount: 0))
let passkey = try await accounts.passkey(credentialID: "cred-1")
let byEmail = try await accounts.passkeys(email: "sam@example.com")
let taken = try await accounts.isUnregistered(credentialID: "cred-1")
#expect(free)
#expect(!taken)
#expect(sam.displayName == "sam")
#expect(passkey == Passkey(credentialID: "cred-1", publicKey: [1, 2, 3], signCount: 0, user: sam))
#expect(byEmail == [passkey])
}
}
@Test
func `never adds a passkey to an account that already exists`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let hijack = try await accounts.join(email: AccountRepository.demoEmail, credentialID: "cred-x", publicKey: [9], signCount: 0)
let passkeys = try await accounts.passkeys(email: AccountRepository.demoEmail)
let stillFree = try await accounts.isUnregistered(credentialID: "cred-x")
#expect(hijack == nil)
#expect(passkeys.isEmpty)
#expect(stillFree)
}
}
@Test
func `records a passkey's signature counter, and finds accounts without creating them`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
_ = try await accounts.join(email: "sam@example.com", credentialID: "cred-1", publicKey: [1], signCount: 0)
try await accounts.recordUse(ofPasskey: "cred-1", signCount: 7)
let passkey = try await accounts.passkey(credentialID: "cred-1")
let known = try await accounts.existingAccount(email: "sam@example.com")
let unknown = try await accounts.existingAccount(email: "nobody@example.com")
let stillUnknown = try await accounts.existingAccount(email: "nobody@example.com")
#expect(passkey?.signCount == 7)
#expect(known?.email == "sam@example.com")
#expect(unknown == nil)
#expect(stillUnknown == nil)
}
}
}
// MARK: - Accounts and shops
extension AccountRepositoryTests {
@Test
func `says whether a sign-in created the account`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let first = try await accounts.signIn(email: "sam@example.com")
let again = try await accounts.signIn(email: "sam@example.com")
#expect(first.isNew)
#expect(!again.isNew)
#expect(first.user == again.user)
}
}
@Test
func `renames an account, and manages the passkeys of its own`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: AccountRepository.demoEmail)
let sam = try await accounts.account(email: "sam@example.com")
let renamed = try await accounts.rename(mara, to: "Mara V.")
let stored = try await accounts.existingAccount(email: AccountRepository.demoEmail)
try await accounts.addPasskey(to: mara, credentialID: "cred-m", publicKey: [1], signCount: 0)
try await accounts.revokePasskey("cred-m", of: sam)
let kept = try await accounts.passkeys(of: mara)
try await accounts.revokePasskey("cred-m", of: mara)
let revoked = try await accounts.passkeys(of: mara)
#expect(renamed.displayName == "Mara V.")
#expect(stored?.displayName == "Mara V.")
#expect(kept.map(\.passkey.credentialID) == ["cred-m"])
#expect(revoked.isEmpty)
}
}
@Test
func `opens one shop per account, under a handle of its own`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let catalogue = CatalogueRepository(fluent: fluent)
let mara = try await accounts.account(email: AccountRepository.demoEmail)
let sam = try await accounts.account(email: "sam@example.com")
let draft = ShopDraft(
name: "Mara's Longbox", handle: "maras-longbox", city: "Haarlem", country: "NL", about: "Spare copies.",
kind: .privateSeller, kvkNumber: "12345678", vatID: nil, legalName: nil, returnsNote: "14-day returns", handlingDays: 2
)
let shop = try await catalogue.openShop(draft, for: mara)
let owned = try await TradeRepository(fluent: fluent).shop(ownedBy: mara)
#expect(shop.slug == "maras-longbox")
#expect(shop.kind == .privateSeller)
// A private seller shows no registration, whatever was typed.
#expect(shop.kvkNumber == nil)
#expect(!shop.isVerified)
#expect(shop.ratedSales == 0)
#expect(owned == shop)
await #expect(throws: ShopError.alreadyOwner) {
try await catalogue.openShop(draft, for: mara)
}
await #expect(throws: ShopError.handleTaken) {
try await catalogue.openShop(draft, for: sam)
}
// And the new shop sells: its owner lists a copy, which a buyer finds.
let listing = try await catalogue.publish(ListingDraftTests.draft(), by: mara)
#expect(listing?.shop.slug == "maras-longbox")
}
}
}
@@ -0,0 +1,90 @@
import HummingbirdFluent
import Logging
import Testing
@testable import Persistence
@Suite(
"CatalogueRepository repository",
.tags(.repository)
)
struct CatalogueRepositoryTests {
// MARK: Methods tests
@Test
func `seeds every demo shop and listing`() async throws {
try await withCatalogue { repository in
let listings = try await repository.listings()
#expect(listings.count == Seed.listings.count)
for shop in Seed.shops {
let found = try await repository.shop(slug: shop.slug)
#expect(found == shop)
}
}
}
@Test
func `lists the newest listing first by default`() async throws {
try await withCatalogue { repository in
let listings = try await repository.listings()
#expect(listings.map(\.slug) == Seed.listings.map(\.slug))
}
}
@Test
func `finds a listing with its shop by slug`() async throws {
try await withCatalogue { repository in
let found = try await repository.listing(slug: "nightward-1-cgc-9-8")
let missing = try await repository.listing(slug: "missing")
let listing = try #require(found)
#expect(listing.title == "Nightward #1")
#expect(listing.shop.name == "Kestrel Row Comics")
#expect(missing == nil)
}
}
@Test
func `lists each publisher once, alphabetically`() async throws {
try await withCatalogue { repository in
let publishers = try await repository.publishers()
#expect(publishers == [
"Blackline", "Cutwater Press", "Harbour & Vale", "Meridian Comics", "Sōkai Books",
])
}
}
@Test
func `narrows and orders the listings by the filter`() async throws {
try await withCatalogue { repository in
let listings = try await repository.listings(matching: .init(
query: "nightward 1",
sort: .priceAscending
))
#expect(listings.map(\.priceCents) == [3_500, 12_000, 15_000, 24_000])
}
}
}
// MARK: - Helpers
private extension CatalogueRepositoryTests {
/// Runs the body against a migrated, seeded in-memory catalogue.
func withCatalogue(
_ body: (CatalogueRepository) async throws -> Void
) async throws {
try await withDatabase {
try await body(.init(fluent: $0))
}
}
}
@@ -0,0 +1,131 @@
import Foundation
import Testing
@testable import Persistence
@Suite(
"CollectingRepository repository",
.tags(.repository)
)
struct CollectingRepositoryTests {
// MARK: Wants tests
@Test
func `matches a want within its limits, shipping from the EU alone`() async throws {
try await withDatabase { fluent in
let collecting = CollectingRepository(fluent: fluent)
let mara = try await AccountRepository(fluent: fluent).account(email: "mara.v@example.com")
try await collecting.addWant(title: " Nightward #1 ", minGrade: 9.0, maxPriceCents: 20_000, slabbedOnly: false, for: mara)
try await collecting.addWant(title: " ", minGrade: nil, maxPriceCents: nil, slabbedOnly: false, for: mara)
let wants = try await collecting.wants(of: mara)
let want = try #require(wants.first)
#expect(wants.count == 1)
#expect(want.title == "Nightward #1")
// The raw NM at €120 from Antwerp; the €150 CGC 9.4 ships from the UK, and the rest cost more or grade lower.
#expect(want.matches.map(\.slug) == ["nightward-1-raw-nm"])
#expect(want.newMatchCount == 1)
#expect(want.alerts)
}
}
@Test
func `counts a match as new until the wants are looked at, and again when a copy is listed`() async throws {
try await withDatabase { fluent in
let clock = Clock()
let collecting = CollectingRepository(fluent: fluent, now: clock.now)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let joost = try await accounts.account(email: "joost@kestrelrow.example")
try await collecting.addWant(title: "Nightward #3", minGrade: nil, maxPriceCents: nil, slabbedOnly: false, for: mara)
let nothing = try await collecting.newMatchCount(for: mara)
_ = try await CatalogueRepository(fluent: fluent).publish(ListingDraftTests.draft(), by: joost, at: clock.now())
clock.advance(by: 60)
let listed = try await collecting.newMatchCount(for: mara)
try await collecting.markWantsSeen(by: mara)
let seen = try await collecting.newMatchCount(for: mara)
let stillMatching = try await collecting.wants(of: mara).first?.matches.count
#expect(nothing == 0)
#expect(listed == 1)
#expect(seen == 0)
#expect(stillMatching == 1)
}
}
@Test
func `keeps a want's alerts and removal to its owner`() async throws {
try await withDatabase { fluent in
let collecting = CollectingRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
try await collecting.addWant(title: "Nightward #1", minGrade: nil, maxPriceCents: nil, slabbedOnly: true, for: mara)
let id = try #require(try await collecting.wants(of: mara).first?.id)
try await collecting.toggleAlerts(ofWant: id, of: sam)
try await collecting.removeWant(id, of: sam)
let untouched = try await collecting.wants(of: mara)
try await collecting.toggleAlerts(ofWant: id, of: mara)
let silenced = try await collecting.wants(of: mara)
let quiet = try await collecting.newMatchCount(for: mara)
try await collecting.removeWant(id, of: mara)
let removed = try await collecting.wants(of: mara)
#expect(untouched.first?.alerts == true)
#expect(untouched.first?.matches.allSatisfy(\.isSlabbed) == true)
#expect(silenced.first?.alerts == false)
#expect(quiet == 0)
#expect(removed.isEmpty)
}
}
// MARK: Series tests
@Test
func `knows the seeded runs, and makes up a series it only knows from its listings`() async throws {
try await withDatabase { fluent in
let collecting = CollectingRepository(fluent: fluent)
let all = try await collecting.allSeries()
let nightward = try await collecting.series(slug: "nightward")
let listedOnly = try await collecting.series(slug: "hollow-comet")
let unknown = try await collecting.series(slug: "no-such-series")
#expect(all.map(\.slug) == ["echo-country", "ferrous-saint", "nightward", "paper-tigers", "vermilion-lake"])
#expect(nightward?.issueCount == 24)
#expect(listedOnly?.name == "Hollow Comet")
#expect(listedOnly?.issueCount == 0)
#expect(unknown == nil)
}
}
@Test
func `derives a listing's series and issue number from what it states`() {
let single = Seed.listings[0]
let manga = Seed.listings[5]
#expect(single.seriesName == "Nightward")
#expect(single.seriesSlug == "nightward")
#expect(single.issueNumber == 1)
#expect(manga.seriesSlug == "hollow-comet")
#expect(manga.issueNumber == nil)
}
}
@@ -0,0 +1,139 @@
import Foundation
import Testing
@testable import Persistence
@Suite(
"CollectingRepository repository, collection and events",
.tags(.repository)
)
struct CommunityRepositoryTests {
// MARK: Collection tests
@Test
func `keeps a collection per account, within a series' run`() async throws {
try await withDatabase { fluent in
let collecting = CollectingRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
// Echo Country runs six issues: #9 is outside it, and #2 is given twice.
try await collecting.own(issues: [1, 2, 2, 9, 0], ofSeries: "echo-country", by: mara)
try await collecting.own(issues: [2, 3], ofSeries: "echo-country", by: mara)
try await collecting.own(issues: [1], ofSeries: "no-such-series", by: mara)
try await collecting.disown(issue: 1, ofSeries: "echo-country", by: sam)
let owned = try await collecting.ownedIssues(of: mara)
let others = try await collecting.ownedIssues(of: sam)
try await collecting.disown(issue: 1, ofSeries: "echo-country", by: mara)
let after = try await collecting.ownedIssues(of: mara)
#expect(owned == ["echo-country": [1, 2, 3]])
#expect(others.isEmpty)
#expect(after == ["echo-country": [2, 3]])
}
}
// MARK: Events tests
@Test
func `lists the events still to come, soonest first, with the shops trading there`() async throws {
try await withDatabase { fluent in
let clock = Clock()
let collecting = CollectingRepository(fluent: fluent, now: clock.now)
// The present, which every demo con is seeded after.
clock.advance(by: Date().timeIntervalSince(clock.now()))
let all = try await collecting.upcomingEvents()
// The first con's last day: still on. Two weeks later it is over, and so is Stripdagen.
clock.advance(by: try #require(all.first).endsOn.timeIntervalSince(clock.now()))
let lastDay = try await collecting.upcomingEvents()
clock.advance(by: 14 * 86_400)
let later = try await collecting.upcomingEvents()
#expect(all.map(\.slug) == ["dutch-comic-con", "stripdagen-haarlem", "comic-con-amsterdam", "comic-con-brussels"])
#expect(Set(all[0].shops.map(\.slug)) == ["kestrel-row-comics", "silverfish-comics", "third-panel-supply"])
#expect(lastDay.first?.slug == "dutch-comic-con")
#expect(later.first?.slug == "comic-con-amsterdam")
}
}
@Test
func `dates the demo cons on weekends, from the first one after seeding`() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
// Monday 21 September 2026: the dates the prototype first shipped with.
let days = Seed.events(after: Date(timeIntervalSince1970: 1_790_000_000)).map { entry in
[entry.event.startsOn, entry.event.endsOn]
.map { calendar.dateComponents([.month, .day, .hour], from: $0) }
.map { "\($0.month ?? 0)/\($0.day ?? 0) \($0.hour ?? 0):00" }
}
#expect(days == [
["9/26 12:00", "9/27 12:00"],
["10/3 12:00", "10/4 12:00"],
["11/21 12:00", "11/22 12:00"],
["12/5 12:00", "12/6 12:00"],
])
}
@Test
func `lets a seller pick the events their shop trades at`() async throws {
try await withDatabase { fluent in
let collecting = CollectingRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let joost = try await accounts.account(email: "joost@kestrelrow.example")
let mara = try await accounts.account(email: "mara.v@example.com")
try await collecting.setEvents(["stripdagen-haarlem", "comic-con-amsterdam", "no-such-event"], forShopOwnedBy: joost)
// An account without a shop changes nothing.
try await collecting.setEvents(["dutch-comic-con"], forShopOwnedBy: mara)
let trading = try await collecting.upcomingEvents()
.filter { $0.shops.contains { $0.slug == "kestrel-row-comics" } }
.map(\.slug)
#expect(trading == ["stripdagen-haarlem", "comic-con-amsterdam"])
}
}
@Test
func `narrows a search to the shops trading at an event`() {
let filter = ListingFilter(shops: ["silverfish-comics"])
#expect(Seed.listings.filter(filter.matches).map(\.shop.slug).allSatisfy { $0 == "silverfish-comics" })
#expect(Seed.listings.filter(filter.matches).count == 4)
#expect(Seed.listings.filter(ListingFilter(shops: []).matches).isEmpty)
}
// MARK: Recovery tests
@Test
func `replaces every passkey of an account with the one it recovers with`() async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let sam = try #require(try await accounts.join(email: "sam@example.com", credentialID: "old-1", publicKey: [1], signCount: 3))
try await accounts.addPasskey(to: sam, credentialID: "old-2", publicKey: [2], signCount: 0)
try await accounts.replacePasskeys(of: sam, credentialID: "new", publicKey: [9], signCount: 0)
let passkeys = try await accounts.passkeys(of: sam)
let lost = try await accounts.passkey(credentialID: "old-1")
#expect(passkeys.map(\.passkey.credentialID) == ["new"])
#expect(lost == nil)
}
}
}
@@ -0,0 +1,341 @@
import Foundation
import HummingbirdFluent
import Testing
@testable import Persistence
@Suite(
"TradeRepository repository",
.tags(.repository)
)
struct TradeRepositoryTests {
// MARK: Constants
/// The first demo listing: Kestrel Row's Nightward #1, asking €240, open to offers.
private let slug = "nightward-1-cgc-9-8"
// MARK: Offers tests
@Test
func `makes an offer in the buyer's conversation about the listing`() async throws {
try await withTrade { trade, mara, joost, _ in
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: " Would you take €215? ")
let buying = try #require(try await trade.conversation(id: id, for: mara))
let inbox = try await trade.openOffers(for: joost)
#expect(buying.side == .buying)
#expect(buying.listing?.slug == slug)
#expect(buying.entries.map(\.kind) == [.offer])
#expect(buying.entries[0].body == "Would you take €215?")
#expect(buying.openOffer?.amountCents == 21_500)
#expect(inbox.map(\.amountCents) == [21_500])
#expect(inbox[0].buyer == mara)
}
}
@Test
func `turns down offers that break the rules`() async throws {
try await withTrade { trade, mara, joost, _ in
await #expect(throws: TradeError.offerTooLow(floorCents: 19_200)) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 19_100, note: "")
}
await #expect(throws: TradeError.invalidAmount) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 24_100, note: "")
}
await #expect(throws: TradeError.invalidAmount) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 0, note: "")
}
await #expect(throws: TradeError.offersNotAccepted) {
try await trade.makeOffer(onListing: "echo-country-3", buyer: mara, amountCents: 1_300, note: "")
}
await #expect(throws: TradeError.ownListing) {
try await trade.makeOffer(onListing: slug, buyer: joost, amountCents: 21_500, note: "")
}
await #expect(throws: TradeError.listingUnavailable) {
try await trade.makeOffer(onListing: "missing", buyer: mara, amountCents: 21_500, note: "")
}
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
await #expect(throws: TradeError.offerAlreadyOpen) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
}
}
}
@Test
func `lapses an offer left unanswered for 48 hours`() async throws {
try await withTrade { trade, mara, joost, clock in
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first)
clock.advance(by: Offer.lifetime)
let conversation = try #require(try await trade.conversation(id: id, for: mara))
let inbox = try await trade.openOffers(for: joost)
#expect(conversation.entries[0].offer?.status == .expired)
#expect(conversation.openOffer == nil)
#expect(inbox.isEmpty)
await #expect(throws: TradeError.notAllowed) {
try await trade.respond(toOffer: offer.id, accepting: true, by: joost)
}
// The listing is free for a new offer.
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
}
}
@Test
func `declines an offer, leaving the listing for sale`() async throws {
try await withTrade { trade, mara, joost, _ in
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first)
let number = try await trade.respond(toOffer: offer.id, accepting: false, by: joost)
let conversation = try #require(try await trade.conversation(id: id, for: mara))
#expect(number == nil)
#expect(conversation.entries.map(\.kind) == [.offer, .event])
#expect(conversation.entries[0].offer?.status == .declined)
#expect(conversation.entries[1].body == "declined the offer")
#expect(conversation.saleNumber == nil)
}
}
@Test
func `lets only the shop's owner answer an offer`() async throws {
try await withTrade { trade, mara, joost, _ in
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first)
await #expect(throws: TradeError.notAllowed) {
try await trade.respond(toOffer: offer.id, accepting: true, by: mara)
}
}
}
// MARK: Sales tests
@Test
func `accepting an offer opens a sale, takes the listing off sale, and lapses the other offers`() async throws {
try await withDatabase { fluent in
let clock = Clock()
let trade = TradeRepository(fluent: fluent, now: clock.now)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
let joost = try await accounts.account(email: "joost@kestrelrow.example")
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let other = try await trade.makeOffer(onListing: slug, buyer: sam, amountCents: 20_000, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first { $0.buyer == mara })
let number = try #require(try await trade.respond(toOffer: offer.id, accepting: true, by: joost))
let sale = try #require(try await trade.sale(number: number, for: mara))
let listing = try await CatalogueRepository(fluent: fluent).listing(slug: slug)
let forSale = try await CatalogueRepository(fluent: fluent).listings()
let lapsed = try #require(try await trade.conversation(id: other, for: sam))
let inbox = try await trade.openOffers(for: joost)
#expect(number == 48_213)
#expect(sale.priceCents == 21_500)
#expect(sale.askedCents == 24_000)
#expect(sale.shippingCents == 600)
#expect(sale.declared.grade == "CGC 9.8 · cert 4312087-002")
#expect(sale.buyer == mara)
#expect(sale.seller == joost)
#expect(sale.completed == .agreed)
#expect(listing?.isAvailable == false)
#expect(!forSale.contains { $0.slug == slug })
#expect(lapsed.entries[0].offer?.status == .expired)
#expect(inbox.isEmpty)
await #expect(throws: TradeError.listingUnavailable) {
try await trade.buy(listing: slug, buyer: sam)
}
}
}
@Test
func `buys at the asking price, numbering the sales in order`() async throws {
try await withTrade { trade, mara, _, _ in
let first = try await trade.buy(listing: slug, buyer: mara)
let second = try await trade.buy(listing: "echo-country-3", buyer: mara)
let sale = try #require(try await trade.sale(number: second, for: mara))
let sales = try await trade.sales(for: mara)
#expect(first == 48_213)
#expect(second == 48_214)
#expect(sale.priceCents == sale.askedCents)
#expect(sales.count == 2)
}
}
@Test
func `walks the handshake, stamping each step into the conversation`() async throws {
try await withTrade { trade, mara, joost, _ in
let number = try await trade.buy(listing: slug, buyer: mara)
await #expect(throws: TradeError.notAllowed) {
try await trade.perform(.markSent(note: ""), onSale: number, by: joost)
}
await #expect(throws: TradeError.notAllowed) {
try await trade.perform(.markPaid(note: ""), onSale: number, by: joost)
}
try await trade.perform(.markPaid(note: " iDEAL "), onSale: number, by: mara)
try await trade.perform(.markSent(note: "DHL 3S123"), onSale: number, by: joost)
try await trade.perform(.markReceived, onSale: number, by: mara)
try await trade.perform(.rate(stars: 5), onSale: number, by: mara)
let sale = try await trade.perform(.rate(stars: 4), onSale: number, by: joost)
let conversation = try #require(try await trade.conversation(id: sale.conversationID, for: joost))
#expect(sale.completed == .rated)
#expect(sale.paymentNote == "iDEAL")
#expect(sale.shippingNote == "DHL 3S123")
#expect(sale.buyerRating == 5)
#expect(sale.sellerRating == 4)
#expect(conversation.saleNumber == number)
#expect(conversation.entries.map(\.body) == [
"bought the copy at the asking price", "marked the sale paid", "marked the copy sent",
"marked the copy received", "left a rating", "left a rating",
])
}
}
@Test
func `shows a sale to its parties alone`() async throws {
try await withDatabase { fluent in
let trade = TradeRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
let number = try await trade.buy(listing: slug, buyer: mara)
let strangers = try await trade.sale(number: number, for: sam)
let none = try await trade.sales(for: sam)
#expect(strangers == nil)
#expect(none.isEmpty)
}
}
// MARK: Conversations tests
@Test
func `keeps one conversation per buyer and listing, and one with the shop at large`() async throws {
try await withTrade { trade, mara, _, _ in
let first = try await trade.conversation(aboutListing: slug, buyer: mara)
let again = try await trade.conversation(aboutListing: slug, buyer: mara)
let shop = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
let shopAgain = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
#expect(first == again)
#expect(shop == shopAgain)
#expect(first != shop)
}
}
@Test
func `counts what the other side said as unread until the conversation is opened`() async throws {
try await withTrade { trade, mara, joost, clock in
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
clock.advance(by: 60)
try await trade.post("Is the case clean?", to: id, from: mara)
try await trade.post(" ", to: id, from: mara)
let before = try await trade.unreadCount(for: joost)
let own = try await trade.unreadCount(for: mara)
clock.advance(by: 60)
let opened = try #require(try await trade.conversation(id: id, for: joost))
let after = try await trade.unreadCount(for: joost)
#expect(before == 1)
#expect(own == 0)
#expect(opened.side == .selling)
#expect(opened.unreadCount == 1)
#expect(opened.entries.map(\.body) == ["Is the case clean?"])
#expect(after == 0)
}
}
@Test
func `keeps strangers out of a conversation`() async throws {
try await withDatabase { fluent in
let trade = TradeRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
let peek = try await trade.conversation(id: id, for: sam)
#expect(peek == nil)
await #expect(throws: TradeError.notAllowed) {
try await trade.post("Hello", to: id, from: sam)
}
}
}
@Test
func `finds the shop an account owns`() async throws {
try await withTrade { trade, mara, joost, _ in
let owned = try await trade.shop(ownedBy: joost)
let none = try await trade.shop(ownedBy: mara)
#expect(owned?.slug == "kestrel-row-comics")
#expect(none == nil)
}
}
}
// MARK: - Helpers
/// A clock the tests move by hand.
final class Clock: @unchecked Sendable {
private let lock = NSLock()
private var date = Date(timeIntervalSince1970: 1_790_000_000)
/// The clock's reading.
@Sendable
func now() -> Date {
lock.withLock { date }
}
/// Moves the clock forward.
func advance(
by interval: TimeInterval
) {
lock.withLock { date.addTimeInterval(interval) }
}
}
private extension TradeRepositoryTests {
/// Runs the body against a seeded database, with the demo buyer, the first demo listing's seller, and the clock the repository reads.
func withTrade(
_ body: (TradeRepository, User, User, Clock) async throws -> Void
) async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let clock = Clock()
try await body(
.init(fluent: fluent, now: clock.now),
try await accounts.account(email: "mara.v@example.com"),
try await accounts.account(email: "joost@kestrelrow.example"),
clock
)
}
}
}
@@ -5,4 +5,8 @@ extension Tag {
@Tag static var enumeration: Tag
/// Tests exercising a method of the Persistence package.
@Tag static var method: Tag
/// Tests exercising a model of the Persistence package.
@Tag static var model: Tag
/// Tests exercising a repository of the Persistence package.
@Tag static var repository: Tag
}
@@ -0,0 +1,23 @@
import HummingbirdFluent
import Logging
@testable import Persistence
/// Runs the body against a migrated, seeded in-memory database, shutting the pool down after — even on failure, or the pool asserts on `deinit`.
func withDatabase(
_ body: (Fluent) async throws -> Void
) async throws {
let fluent = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)()
defer {
try? await fluent.shutdown()
}
await PrepareDB()(for: fluent)
try await fluent.migrate()
try await body(fluent)
}
+13 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -29,3 +29,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+1 -1
View File
@@ -25,6 +25,6 @@ Tests/
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`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation only.
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more