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

This commit is contained in:
javier committed 2026-09-21 11:44:41 +02:00
1 parent ea89ea892a
commit 09c7b8a1a7
10 files changed
+238 -21

No files matched your search

+5 -4
View File
@@ -8,6 +8,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver | | Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order | | 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 | | 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; `CreateUser` seeds the prototype's demo accounts |
| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | | Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
## Design rules ## Design rules
@@ -29,12 +30,12 @@ Sources/
├── Public/ ├── Public/
│ ├── Enumerations/ Driver, TLS │ ├── Enumerations/ Driver, TLS
│ ├── Methods/ Service, PrepareDB, Probe │ ├── Methods/ Service, PrepareDB, Probe
│ ├── Models/ Shop, Listing, ListingFilter │ ├── Models/ Shop, Listing, ListingFilter, User
│ ├── Repositories/ CatalogueRepository │ ├── Repositories/ CatalogueRepository, AccountRepository
│ └── Types/ Configuration │ └── Types/ Configuration
└── Internal/ └── Internal/
├── Migrations/ CreateShop, CreateListing, SeedCatalogue ├── Migrations/ CreateShop, CreateListing, SeedCatalogue, CreateUser
├── Models/ ShopRecord, ListingRecord ├── Models/ ShopRecord, ListingRecord, UserRecord
└── Seeds/ Seed └── Seeds/ Seed
Tests/ Tests/
├── Cases/ the test suites, mirroring the Sources/ layout ├── Cases/ the test suites, mirroring the Sources/ layout
@@ -0,0 +1,26 @@
import FluentKit
/// Creates and drops the `users` table backing ``UserRecord``, seeded with the prototype's demo accounts.
struct CreateUser: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(UserRecord.schema)
.id()
.field("email", .string, .required)
.field("display_name", .string, .required)
.unique(on: "email")
.create()
for account in Seed.accounts {
try await UserRecord(email: account.email, displayName: account.displayName).save(on: database)
}
}
func revert(on database: Database) async throws {
try await database.schema(UserRecord.schema)
.delete()
}
}
@@ -0,0 +1,55 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `users` row.
final class UserRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "email")
var email: String
@Field(key: "display_name")
var displayName: String
// MARK: Initializers
init() {}
/// Creates a record with the given values.
/// - Parameters:
/// - email: the account's normalized email address.
/// - displayName: the name other people see.
init(
email: String,
displayName: String
) {
self.email = email
self.displayName = displayName
}
}
// MARK: - Extensions
extension UserRecord {
// MARK: Computed
/// The `Sendable` snapshot of the saved record.
var snapshot: User {
get throws {
.init(
id: try requireID(),
email: email,
displayName: displayName
)
}
}
}
@@ -5,6 +5,12 @@ enum Seed {
// MARK: Constants // MARK: Constants
/// The demo accounts: the buyer the stubbed passkey signs in, then a seller.
static let accounts: [(email: String, displayName: String)] = [
("mara.v@example.com", "Mara Visser"),
("joost@kestrelrow.example", "Joost de Wit"),
]
/// The demo listings, newest first. /// The demo listings, newest first.
static let listings: [Listing] = [ static let listings: [Listing] = [
single( single(
@@ -25,6 +25,7 @@ public struct PrepareDB: Sendable {
CreateShop(), CreateShop(),
CreateListing(), CreateListing(),
SeedCatalogue(), SeedCatalogue(),
CreateUser(),
]) ])
} }
@@ -0,0 +1,27 @@
import Foundation
/// A `Sendable` snapshot of an account.
public struct User: Sendable, Equatable {
// MARK: Properties
/// The account's identifier.
public let id: UUID
/// The account's normalized email address.
public let email: String
/// The name other people see.
public let displayName: String
// MARK: Initializers
public init(
id: UUID,
email: String,
displayName: String
) {
self.id = id
self.email = email
self.displayName = displayName
}
}
@@ -0,0 +1,53 @@
import FluentKit
import HummingbirdFluent
/// Reads and creates accounts through the default database.
public struct AccountRepository: Sendable {
// MARK: Constants
/// The email of the demo buyer the prototype's stubbed passkey signs in.
public static let demoEmail = Seed.accounts[0].email
// MARK: Properties
private let fluent: Fluent
// MARK: Initializers
/// Creates a repository backed by the given `Fluent` service.
/// - Parameter fluent: the service whose default database the repository operates on.
public init(
fluent: Fluent
) {
self.fluent = fluent
}
// MARK: Methods
/// Fetches the account with the given email, creating it when there is none: one step both signs in and joins.
///
/// A new account is named after the email's local part until its owner picks a display name.
/// - Parameter email: the account's normalized email address.
/// - Returns: the account, existing or new.
public func account(
email: String
) async throws -> User {
if let record = try await UserRecord.query(on: fluent.db())
.filter(\.$email == email)
.first()
{
return try record.snapshot
}
let record = UserRecord(
email: email,
displayName: String(email.prefix { $0 != "@" })
)
try await record.save(on: fluent.db())
return try record.snapshot
}
}
@@ -0,0 +1,35 @@
import Testing
@testable import Persistence
@Suite(
"AccountRepository repository",
.tags(.repository)
)
struct AccountRepositoryTests {
// MARK: Methods tests
@Test
func `finds the seeded demo buyer`() async throws {
try await withDatabase { fluent in
let user = try await AccountRepository(fluent: fluent).account(email: AccountRepository.demoEmail)
#expect(user.email == "mara.v@example.com")
#expect(user.displayName == "Mara Visser")
}
}
@Test
func `joins an unknown email once, named after its local part`() async throws {
try await withDatabase { fluent in
let repository = AccountRepository(fluent: fluent)
let joined = try await repository.account(email: "sam@example.com")
let returning = try await repository.account(email: "sam@example.com")
#expect(joined.displayName == "sam")
#expect(returning == joined)
}
}
}
@@ -78,27 +78,13 @@ struct CatalogueRepositoryTests {
private extension CatalogueRepositoryTests { private extension CatalogueRepositoryTests {
/// Runs the body against a migrated, seeded in-memory catalogue, shutting the pool down after. /// Runs the body against a migrated, seeded in-memory catalogue.
func withCatalogue( func withCatalogue(
_ body: (CatalogueRepository) async throws -> Void _ body: (CatalogueRepository) async throws -> Void
) async throws { ) async throws {
let fluent = try Service( try await withDatabase {
driver: .inMemory, try await body(.init(fluent: $0))
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()
} }
} }
@@ -0,0 +1,27 @@
import HummingbirdFluent
import Logging
@testable import Persistence
/// Runs the body against a migrated, seeded in-memory database, shutting the pool down after — even on failure, or the pool asserts on `deinit`.
func withDatabase(
_ body: (Fluent) async throws -> Void
) async throws {
let fluent = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)()
do {
await PrepareDB()(for: fluent)
try await fluent.migrate()
try await body(fluent)
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}