diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index 2eca5c3..99540dd 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -9,7 +9,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da | 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 | | 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, and reads the `Series` catalogue | +| 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 | @@ -33,12 +33,12 @@ Sources/ ├── Public/ │ ├── Enumerations/ Driver, TLS, ShopError, TradeError │ ├── Methods/ Service, PrepareDB, Probe -│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Conversation, Offer, Sale +│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Event, Conversation, Offer, Sale │ ├── Repositories/ CatalogueRepository, AccountRepository, CollectingRepository, TradeRepository │ └── Types/ Configuration └── Internal/ - ├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, AddShopRegistration, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey, CreateCollecting - ├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, WantRecord, SeriesRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord + ├── 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 diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateCommunity.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateCommunity.swift new file mode 100644 index 0000000..66ddd40 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateCommunity.swift @@ -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 { + 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() + } + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/CommunityRecords.swift b/Packages/Persistence/Sources/Internal/Models/CommunityRecords.swift new file mode 100644 index 0000000..48f8c65 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/CommunityRecords.swift @@ -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 + } + +} diff --git a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift index 2e48b9a..05de58a 100644 --- a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift +++ b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift @@ -158,6 +158,31 @@ enum Seed { ), ] + /// The demo cons, soonest first, each with the slugs of the shops trading there. + static let events: [(event: Event, shops: [String])] = [ + (.init(slug: "dutch-comic-con", name: "Dutch Comic Con", venue: "Jaarbeurs, Utrecht", startsOn: day(2026, 9, 26), endsOn: day(2026, 9, 27)), + ["kestrel-row-comics", "silverfish-comics", "third-panel-supply"]), + (.init(slug: "stripdagen-haarlem", name: "Stripdagen Haarlem", venue: "Grote Markt, Haarlem", startsOn: day(2026, 10, 3), endsOn: day(2026, 10, 4)), + ["silverfish-comics"]), + (.init(slug: "comic-con-amsterdam", name: "Comic Con Amsterdam", venue: "RAI, Amsterdam", startsOn: day(2026, 11, 21), endsOn: day(2026, 11, 22)), + ["kestrel-row-comics", "inkwell-and-staple"]), + (.init(slug: "comic-con-brussels", name: "Comic Con Brussels", venue: "Tour & Taxis, Brussels", startsOn: day(2026, 12, 5), endsOn: day(2026, 12, 6)), + ["third-panel-supply"]), + ] + + /// Noon on a day in Amsterdam, where the demo cons are dated. + static func day( + _ year: Int, + _ month: Int, + _ day: Int + ) -> Date { + var calendar = Calendar(identifier: .gregorian) + + calendar.timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt + + return calendar.date(from: .init(year: year, month: month, day: day, hour: 12)) ?? .distantPast + } + /// The runs the demo listings belong to. static let series: [Series] = [ .init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 24, diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index bd8d39d..050c0ec 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -32,6 +32,7 @@ public struct PrepareDB: Sendable { CreateTrade(), CreatePasskey(), CreateCollecting(), + CreateCommunity(), ]) } diff --git a/Packages/Persistence/Sources/Public/Models/Event.swift b/Packages/Persistence/Sources/Public/Models/Event.swift new file mode 100644 index 0000000..8af64ea --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Event.swift @@ -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 + } + +} diff --git a/Packages/Persistence/Sources/Public/Models/ListingFilter.swift b/Packages/Persistence/Sources/Public/Models/ListingFilter.swift index 44b5fd8..a170d82 100644 --- a/Packages/Persistence/Sources/Public/Models/ListingFilter.swift +++ b/Packages/Persistence/Sources/Public/Models/ListingFilter.swift @@ -31,6 +31,8 @@ public struct ListingFilter: Sendable, Equatable { 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 @@ -51,6 +53,7 @@ public struct ListingFilter: Sendable, Equatable { acceptsOffers: Bool = false, verifiedShops: Bool = false, shop: String? = nil, + shops: [String]? = nil, sort: Sort = .newest ) { self.query = query @@ -67,6 +70,7 @@ public struct ListingFilter: Sendable, Equatable { self.acceptsOffers = acceptsOffers self.verifiedShops = verifiedShops self.shop = shop + self.shops = shops self.sort = sort } @@ -117,6 +121,10 @@ extension ListingFilter { return false } + if let shops, !shops.contains(listing.shop.slug) { + return false + } + if !formats.isEmpty, !formats.contains(listing.format) { return false } diff --git a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift index da5580e..034084b 100644 --- a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift @@ -133,6 +133,27 @@ public struct AccountRepository: Sendable { ).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 diff --git a/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift b/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift index d08911a..1d6133a 100644 --- a/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift @@ -192,3 +192,105 @@ public extension CollectingRepository { } } + +// 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] { + 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) + } + } + } + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/CommunityRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/CommunityRepositoryTests.swift new file mode 100644 index 0000000..d5363da --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/CommunityRepositoryTests.swift @@ -0,0 +1,121 @@ +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) + + // 22 September 2026: every demo con is still to come. + clock.advance(by: Seed.day(2026, 9, 22).timeIntervalSince(clock.now())) + + let all = try await collecting.upcomingEvents() + + // Sunday 27 September, the con's last day: still on. Two weeks later it is over, and so is Stripdagen. + clock.advance(by: 5 * 86_400) + + 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 `lets a seller pick the events their shop trades at`() async throws { + try await withDatabase { fluent in + let clock = Clock() + let collecting = CollectingRepository(fluent: fluent, now: clock.now) + 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") + + clock.advance(by: Seed.day(2026, 9, 22).timeIntervalSince(clock.now())) + + 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) + } + } + +}