Added the catalogue models and seed to support phase 1 of the POC prototype in the Persistence package.

This commit is contained in:
javier committed 2026-09-21 11:16:30 +02:00
1 parent 717df5208c
commit 6d7ab4bcba
19 files changed
+1381 -151

No files matched your search

@@ -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",
]
}