feat(events): wordeck user-owned-Routen auf event-sourced Coordinator umleiten

E-4.4b Rewiring: WordeckAPIs decks/cards/reviews-Methoden delegieren jetzt
über die @MainActor-Fassade WordeckData an WordeckEventCoordinator (lokaler
EventLog + FSRS + sync2) statt an die toten REST-Routen (410). Views/Stores/
GradeQueue/StudySession bleiben unverändert — minimal-invasiv. duplicateDeck
+ distractors lokal ergänzt (Server-Endpoints kennen event-sourced Karten
nicht). configure() beim App-Launch. Marketplace/Generate/Author bleiben REST.
Build grün, 48/48 Tests grün.

Kompilier-verifiziert — Study-Loop braucht noch Geräte-Verifikation.
Offen: Datenmigration vor-Cutover gecachter Decks (CachedDeck→Events).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-25 15:33:04 +02:00
parent 579622e9dc
commit 3daca22477
3 changed files with 125 additions and 84 deletions

View file

@ -41,6 +41,10 @@ struct WordeckNativeApp: App {
auth.bootstrap()
_auth = State(initialValue: auth)
_authGate = State(initialValue: ManaAuthGate(auth: auth))
// Event-Sync-Coordinator initialisieren (E-4.4b): user-owned-Daten
// (decks/cards/reviews) laufen jetzt event-sourced über ManaEventSync
// statt über die toten REST-Routen. Crypto + Sync starten async.
WordeckEventCoordinator.configure(auth: auth)
Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)")
}

View file

@ -30,30 +30,20 @@ actor WordeckAPI {
/// Optional: `forkedFromMarketplaceOnly` filtert auf Inbox-Decks
/// (für den Inbox-Banner).
func listDecks(forkedFromMarketplaceOnly: Bool = false) async throws -> [Deck] {
var path = "/api/v1/decks"
if forkedFromMarketplaceOnly {
path += "?forked_from_marketplace=true"
}
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
let body = try decoder.decode(DeckListResponse.self, from: data)
return body.decks
// Event-sourced (E-4.4b) REST-Route ist 410. Siehe WordeckEventCoordinator.
try await WordeckData.listDecks(forkedFromMarketplaceOnly: forkedFromMarketplaceOnly)
}
/// `GET /api/v1/cards?deck_id=...` Anzahl Karten in einem Deck.
/// Web macht das pro Deck einzeln; identisches Pattern hier.
func cardCount(deckId: String) async throws -> Int {
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
try ensureOK(http, data: data)
return try decoder.decode(CardListResponse.self, from: data).total
try await WordeckData.cardCount(deckId: deckId)
}
/// `GET /api/v1/cards?deck_id=...` komplette Liste der Karten
/// für den Browse-Modus im DeckDetailView.
func listCards(deckId: String) async throws -> [Card] {
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
try ensureOK(http, data: data)
return try decoder.decode(CardListResponse.self, from: data).cards
try await WordeckData.listCards(deckId: deckId)
}
/// `GET /api/v1/decks/:deckId/distractors` N zufällige Feldwerte
@ -65,20 +55,14 @@ actor WordeckAPI {
field: String = "answer",
count: Int = 3
) async throws -> [String] {
let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)"
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
return try decoder.decode(DistractorsResponse.self, from: data).distractors
// Lokal gesampelt (E-4.4b) Server kennt event-sourced Karten nicht.
try await WordeckData.distractors(deckId: deckId, cardId: cardId, field: field, count: count)
}
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int {
let (data, http) = try await transport.request(
path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=500"
)
try ensureOK(http, data: data)
return try decoder.decode(DueReviewsResponse.self, from: data).total
try await WordeckData.dueCount(deckId: deckId)
}
// MARK: - Marketplace
@ -152,48 +136,25 @@ actor WordeckAPI {
/// `POST /api/v1/decks` Deck anlegen.
@discardableResult
func createDeck(_ body: DeckCreateBody) async throws -> Deck {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks",
method: "POST",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Deck.self, from: responseData)
try await WordeckData.createDeck(body)
}
/// `PATCH /api/v1/decks/:id` Deck-Felder ändern.
@discardableResult
func updateDeck(id: String, body: DeckUpdateBody) async throws -> Deck {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks/\(id)",
method: "PATCH",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Deck.self, from: responseData)
try await WordeckData.updateDeck(id: id, body: body)
}
/// `DELETE /api/v1/decks/:id` Deck löschen (kaskadiert Cards + Reviews).
func deleteDeck(id: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/decks/\(id)",
method: "DELETE"
)
try ensureOK(http, data: data)
try await WordeckData.deleteDeck(id: id)
}
/// `POST /api/v1/decks/:id/duplicate` Server-seitige Kopie mit
/// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer.
@discardableResult
func duplicateDeck(id: String) async throws -> Deck {
let (data, http) = try await transport.request(
path: "/api/v1/decks/\(id)/duplicate",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(Deck.self, from: data)
try await WordeckData.duplicateDeck(id: id)
}
/// `POST /api/v1/marketplace/private/:deckId/pull-update` Smart-Merge-
@ -272,38 +233,20 @@ actor WordeckAPI {
/// (1 für basic, 2 für basic-reverse, N für cloze).
@discardableResult
func createCard(_ body: CardCreateBody) async throws -> Card {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/cards",
method: "POST",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Card.self, from: responseData)
try await WordeckData.createCard(body)
}
/// `PATCH /api/v1/cards/:id` nur `fields` und `media_refs`
/// sind änderbar.
@discardableResult
func updateCard(id: String, body: CardUpdateBody) async throws -> Card {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/cards/\(id)",
method: "PATCH",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Card.self, from: responseData)
try await WordeckData.updateCard(id: id, body: body)
}
/// `DELETE /api/v1/cards/:id` Karte + zugehörige Reviews löschen
/// (Cascade auf DB-Ebene).
func deleteCard(id: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/cards/\(id)",
method: "DELETE"
)
try ensureOK(http, data: data)
try await WordeckData.deleteCard(id: id)
}
// MARK: - Study
@ -311,11 +254,7 @@ actor WordeckAPI {
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` fällige Reviews
/// inklusive zugehöriger Card-Daten. Hot-Path für die Study-View.
func dueReviews(deckId: String, limit: Int = 500) async throws -> [DueReview] {
let (data, http) = try await transport.request(
path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=\(limit)"
)
try ensureOK(http, data: data)
return try decoder.decode(DueReviewsListResponse.self, from: data).reviews
try await WordeckData.dueReviews(deckId: deckId, limit: limit)
}
/// `POST /api/v1/reviews/:cardId/:subIndex/grade` gibt eine
@ -328,14 +267,7 @@ actor WordeckAPI {
rating: Rating,
reviewedAt: Date = .now
) async throws -> Review {
let body = try makeJSON(GradeReviewBody(rating: rating, reviewedAt: reviewedAt))
let (data, http) = try await transport.request(
path: "/api/v1/reviews/\(cardId)/\(subIndex)/grade",
method: "POST",
body: body
)
try ensureOK(http, data: data)
return try decoder.decode(Review.self, from: data)
try await WordeckData.gradeReview(cardId: cardId, subIndex: subIndex, rating: rating, reviewedAt: reviewedAt)
}
// MARK: - JSON-Encoding

View file

@ -217,6 +217,41 @@ final class WordeckEventCoordinator {
)
}
/// Lokale Deck-Kopie (Server-Duplicate-Endpoint ist 410). Neues Deck
/// mit (Kopie)"-Suffix + alle nicht-gelöschten Karten, ohne FSRS-Verlauf.
@discardableResult
func duplicateDeck(id: String) async throws -> Deck {
guard let src = try deckProjection(id) else { throw WordeckSyncError.notFound("deck:\(id)") }
let newDeck = try await createDeck(DeckCreateBody(
name: src.name + " (Kopie)",
description: src.description,
color: src.color,
category: src.category.flatMap(DeckCategory.init(rawValue:)),
visibility: nil
))
for cardProj in try allCardProjections(deckId: id) {
_ = try await createCard(CardCreateBody(
deckId: newDeck.id,
type: CardType(rawValue: cardProj.type) ?? .basic,
fields: WordeckEventAdapters.parseFields(cardProj.fieldsJson)
))
}
return newDeck
}
/// Distraktoren für Multiple-Choice lokal aus anderen Karten des Decks
/// gesampelt (Server-Endpoint kennt event-sourced Karten nicht mehr).
func distractors(deckId: String, cardId: String, field: String = "answer", count: Int = 3) throws -> [String] {
var values: [String] = []
for cardProj in try allCardProjections(deckId: deckId) where cardProj.id != cardId {
let fields = WordeckEventAdapters.parseFields(cardProj.fieldsJson)
if let value = fields[field] ?? fields["answer"] ?? fields["back"], !value.isEmpty {
values.append(value)
}
}
return Array(Set(values).shuffled().prefix(count))
}
// MARK: - Reviews
func dueReviews(deckId: String, limit: Int = 500) throws -> [DueReview] {
@ -318,11 +353,81 @@ final class WordeckEventCoordinator {
enum WordeckSyncError: LocalizedError {
case notFound(String)
case reducerGap(String)
case unavailable
var errorDescription: String? {
switch self {
case let .notFound(id): "Nicht gefunden: \(id)"
case let .reducerGap(id): "Reducer-Lücke: \(id)"
case .unavailable: "Event-Sync nicht initialisiert. Erst App neu starten."
}
}
}
/// `@MainActor`-Delegations-Fassade auf den geteilten Coordinator. Liefert
/// nur Sendable-Werte zurück, damit `WordeckAPI` (ein Actor) gefahrlos
/// `await WordeckData.x(...)` aufrufen kann ohne den non-Sendable
/// Coordinator über die Actor-Grenze zu reichen.
@MainActor
enum WordeckData {
private static func coordinator() throws -> WordeckEventCoordinator {
guard let shared = WordeckEventCoordinator.shared else { throw WordeckSyncError.unavailable }
return shared
}
static func listDecks(forkedFromMarketplaceOnly: Bool) throws -> [Deck] {
try coordinator().listDecks(forkedFromMarketplaceOnly: forkedFromMarketplaceOnly)
}
static func cardCount(deckId: String) throws -> Int {
try coordinator().cardCount(deckId: deckId)
}
static func listCards(deckId: String) throws -> [Card] {
try coordinator().listCards(deckId: deckId)
}
static func dueCount(deckId: String) throws -> Int {
try coordinator().dueCount(deckId: deckId)
}
static func distractors(deckId: String, cardId: String, field: String, count: Int) throws -> [String] {
try coordinator().distractors(deckId: deckId, cardId: cardId, field: field, count: count)
}
static func createDeck(_ body: DeckCreateBody) async throws -> Deck {
try await coordinator().createDeck(body)
}
static func updateDeck(id: String, body: DeckUpdateBody) async throws -> Deck {
try await coordinator().updateDeck(id: id, body: body)
}
static func deleteDeck(id: String) async throws {
try await coordinator().deleteDeck(id: id)
}
static func duplicateDeck(id: String) async throws -> Deck {
try await coordinator().duplicateDeck(id: id)
}
static func createCard(_ body: CardCreateBody) async throws -> Card {
try await coordinator().createCard(body)
}
static func updateCard(id: String, body: CardUpdateBody) async throws -> Card {
try await coordinator().updateCard(id: id, body: body)
}
static func deleteCard(id: String) async throws {
try await coordinator().deleteCard(id: id)
}
static func dueReviews(deckId: String, limit: Int) throws -> [DueReview] {
try coordinator().dueReviews(deckId: deckId, limit: limit)
}
static func gradeReview(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date) async throws -> Review {
try await coordinator().gradeReview(cardId: cardId, subIndex: subIndex, rating: rating, reviewedAt: reviewedAt)
}
}