Added the catalogue models and seed to support phase 1 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
717df5208c
commit
6d7ab4bcba
19 files changed
+1381
-151
No files matched your search
@@ -8,13 +8,14 @@ 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 |
|
||||
| Catalogue | `CatalogueRepository`, returning `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.
|
||||
- **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`.
|
||||
@@ -28,19 +29,19 @@ Sources/
|
||||
├── Public/
|
||||
│ ├── Enumerations/ Driver, TLS
|
||||
│ ├── Methods/ Service, PrepareDB, Probe
|
||||
│ ├── Models/ Shop, Listing, ListingFilter
|
||||
│ ├── Repositories/ CatalogueRepository
|
||||
│ └── Types/ Configuration
|
||||
└── Internal/
|
||||
├── Migrations/ CreateExampleRecord
|
||||
├── Models/ ExampleRecord
|
||||
└── Repositories/ ExampleRepository (returning the Example snapshot)
|
||||
├── Migrations/ CreateShop, CreateListing, SeedCatalogue
|
||||
├── Models/ ShopRecord, ListingRecord
|
||||
└── 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
|
||||
|
||||
@@ -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,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,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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,176 @@
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,283 @@
|
||||
import Foundation
|
||||
|
||||
/// The prototype's demo catalogue, taken from the design canvas.
|
||||
enum Seed {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// 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 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",
|
||||
]
|
||||
|
||||
}
|
||||
@@ -22,7 +22,9 @@ public struct PrepareDB: Sendable {
|
||||
for fluent: Fluent
|
||||
) async {
|
||||
await fluent.migrations.add([
|
||||
CreateExampleRecord()
|
||||
CreateShop(),
|
||||
CreateListing(),
|
||||
SeedCatalogue(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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
|
||||
|
||||
// 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
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Listing {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Whether the issue is a key.
|
||||
var isKey: Bool {
|
||||
keyNotes != nil
|
||||
}
|
||||
|
||||
/// 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,197 @@
|
||||
/// The criteria a listing search is narrowed and ordered by; the default matches every listing, 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 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,
|
||||
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.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 {
|
||||
if let shop, listing.shop.slug != shop {
|
||||
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,65 @@
|
||||
/// 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
|
||||
|
||||
// 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
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import FluentKit
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -82,13 +82,13 @@ struct ServiceTests {
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
@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 +104,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`.
|
||||
@@ -129,10 +129,9 @@ private extension ServiceTests {
|
||||
|
||||
try await fluent.migrate()
|
||||
|
||||
let repository = ExampleRepository(fluent: fluent)
|
||||
let created = try await repository.create(name: "site")
|
||||
let repository = CatalogueRepository(fluent: fluent)
|
||||
|
||||
#expect(try await repository.all().contains(created))
|
||||
#expect(try await !repository.listings().isEmpty)
|
||||
|
||||
if revertAfter {
|
||||
try await fluent.revert()
|
||||
|
||||
@@ -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,104 @@
|
||||
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, shutting the pool down after.
|
||||
func withCatalogue(
|
||||
_ body: (CatalogueRepository) async throws -> Void
|
||||
) async throws {
|
||||
let fluent = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)()
|
||||
|
||||
do {
|
||||
await PrepareDB()(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
try await body(.init(fluent: fluent))
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user