From 96ef285dfe725d3e4a57206bb1ac2f836d2b7483 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 25 May 2026 15:45:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(events):=20Legacy-Cache=E2=86=92Event-Date?= =?UTF-8?q?nmigration=20(E-4.4b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Einmalige Migration der SwiftData-Caches (CachedDeck/CachedCard/ CachedDueReview/PendingGrade) in den EventLog: IDs + FSRS-State der gecachten Reviews 1:1 erhalten (importDeck/importCard/importReview), Karten ohne gecachtes Review als „neu" initialisiert, offline-PendingGrades via lokalem FSRS nachgespielt. Läuft einmal (UserDefaults-Flag, Retry bei Fehler) in RootView.task nach configure. Schließt die „Rückkehrer sehen leer"-Lücke. 49/49 Tests grün (Import-Pfad verifiziert FSRS-Erhalt). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/RootView.swift | 5 ++ Sources/Core/Sync/WordeckDataMigration.swift | 85 +++++++++++++++++++ .../Core/Sync/WordeckEventCoordinator.swift | 71 +++++++++++++++- .../WordeckEventCoordinatorTests.swift | 27 ++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 Sources/Core/Sync/WordeckDataMigration.swift diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 4e7fb35..a175a68 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -9,6 +9,7 @@ import SwiftUI struct RootView: View { @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate + @Environment(\.modelContext) private var modelContext @State private var selectedTab: AppTab = .decks @State private var pendingDeepLinkSlug: String? @State private var showCreateDeck = false @@ -55,6 +56,10 @@ struct RootView: View { ) } } + // Einmalige Legacy-Cache→Event-Migration (E-4.4b). + if let coordinator = WordeckEventCoordinator.shared { + await WordeckDataMigration.runIfNeeded(context: modelContext, coordinator: coordinator) + } } } diff --git a/Sources/Core/Sync/WordeckDataMigration.swift b/Sources/Core/Sync/WordeckDataMigration.swift new file mode 100644 index 0000000..678ef88 --- /dev/null +++ b/Sources/Core/Sync/WordeckDataMigration.swift @@ -0,0 +1,85 @@ +import Foundation +import SwiftData + +/// Einmalige Migration der Legacy-SwiftData-Caches (`CachedDeck`/ +/// `CachedCard`/`CachedDueReview`/`PendingGrade`) in den event-sourced +/// Store (E-4.4b). Bewahrt Deck-/Card-IDs + den FSRS-State der gecachten +/// Reviews 1:1; Karten ohne gecachtes Review werden als „neu" initialisiert +/// (sofort fällig — bewusster Trade-off, da der Cache nur fällige Reviews +/// hielt). Offline-`PendingGrade`s werden via lokalem FSRS nachgespielt. +/// +/// Läuft genau einmal (UserDefaults-Flag); bei Fehler kein Flag → Retry +/// beim nächsten Start. +@MainActor +enum WordeckDataMigration { + private static let flagKey = "wordeck.legacyMigrated.v1" + + static func runIfNeeded(context: ModelContext, coordinator: WordeckEventCoordinator) async { + guard !UserDefaults.standard.bool(forKey: flagKey) else { return } + do { + try await migrate(context: context, coordinator: coordinator) + UserDefaults.standard.set(true, forKey: flagKey) + Log.app.info("Legacy→Event-Migration abgeschlossen") + } catch { + Log.app.error( + "Legacy-Migration fehlgeschlagen (Retry nächster Start): \(String(describing: error), privacy: .public)" + ) + } + } + + private static func formatter() -> ISO8601DateFormatter { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + } + + private static func migrate(context: ModelContext, coordinator: WordeckEventCoordinator) async throws { + let iso = formatter() + + for deck in try context.fetch(FetchDescriptor()) { + try await coordinator.importDeck( + id: deck.id, name: deck.name, description: deck.deckDescription, + color: deck.color, category: deck.categoryRaw, archived: deck.archivedAt != nil + ) + } + + var reviewByKey: [String: CachedDueReview] = [:] + for review in try context.fetch(FetchDescriptor()) { + reviewByKey["\(review.cardId)__\(review.subIndex)"] = review + } + + let nowISO = iso.string(from: Date()) + for card in try context.fetch(FetchDescriptor()) { + let type = CardType(rawValue: card.typeRaw) ?? .basic + try await coordinator.importCard(id: card.id, deckId: card.deckId, type: card.typeRaw, fields: card.fields) + for sub in 0 ..< coordinator.subIndexCount(type: type, fields: card.fields) { + if let review = reviewByKey["\(card.id)__\(sub)"] { + try await coordinator.importReview( + cardId: card.id, subIndex: sub, due: iso.string(from: review.due), state: review.stateRaw, + stability: review.stability, difficulty: review.difficulty, + elapsedDays: review.elapsedDays, scheduledDays: review.scheduledDays, + learningSteps: review.learningSteps, reps: review.reps, lapses: review.lapses, + lastReview: review.lastReview.map { iso.string(from: $0) } + ) + } else { + try await coordinator.importReview( + cardId: card.id, subIndex: sub, due: nowISO, state: "new", + stability: 0, difficulty: 0, elapsedDays: 0, scheduledDays: 0, + learningSteps: 0, reps: 0, lapses: 0, lastReview: nil + ) + } + } + } + + // Offline-Grades nachspielen (lokales FSRS), dann aus der Queue löschen. + for grade in try context.fetch(FetchDescriptor()) { + if let rating = Rating(rawValue: grade.ratingRaw) { + _ = try? await coordinator.gradeReview( + cardId: grade.cardId, subIndex: grade.subIndex, rating: rating, reviewedAt: grade.reviewedAt + ) + } + context.delete(grade) + } + try context.save() + } +} diff --git a/Sources/Core/Sync/WordeckEventCoordinator.swift b/Sources/Core/Sync/WordeckEventCoordinator.swift index 0b48c16..089f325 100644 --- a/Sources/Core/Sync/WordeckEventCoordinator.swift +++ b/Sources/Core/Sync/WordeckEventCoordinator.swift @@ -68,7 +68,7 @@ final class WordeckEventCoordinator { .filter { $0.deletedAt == nil && $0.deckId == deckId } } - private func subIndexCount(type: CardType, fields: [String: String]) -> Int { + func subIndexCount(type: CardType, fields: [String: String]) -> Int { switch type { case .basic, .typing, .multipleChoice: 1 case .basicReverse: 2 @@ -348,6 +348,75 @@ final class WordeckEventCoordinator { case .easy: .easy } } + + // MARK: - Legacy-Migration (Cache → Events) + + /// Importiert ein Legacy-Deck unter seiner Original-ID (Identität + + /// Cross-Device-Idempotenz bleiben erhalten). + func importDeck( + id: String, name: String, description: String?, color: String?, + category: String?, archived: Bool + ) async throws { + try await emit(aggregate: WordeckEvent.deckAggregate(id), type: WordeckEvent.deckCreated, payload: .object([ + "deckId": .string(id), + "name": .string(name), + "description": str(description), + "color": str(color), + "category": str(category) + ])) + if archived { + try await emit( + aggregate: WordeckEvent.deckAggregate(id), + type: WordeckEvent.deckArchived, + payload: .object(["deckId": .string(id)]) + ) + } + } + + func importCard(id: String, deckId: String, type: String, fields: [String: String]) async throws { + let fieldsJson = try String(data: JSONEncoder().encode(fields), encoding: .utf8) ?? "{}" + try await emit(aggregate: WordeckEvent.cardAggregate(id), type: WordeckEvent.cardCreated, payload: .object([ + "cardId": .string(id), + "deckId": .string(deckId), + "type": .string(type), + "fieldsJson": .string(fieldsJson), + "tags": .array([]) + ])) + } + + /// Importiert einen Review-State 1:1 aus dem Cache: `ReviewInitialized` + /// plus (falls schon gelernt, `reps > 0`) ein `ReviewGraded`, das die + /// gespeicherten FSRS-Felder exakt übernimmt — **kein** Re-Compute. + // swiftlint:disable:next function_parameter_count + func importReview( + cardId: String, subIndex: Int, due: String, state: String, + stability: Double, difficulty: Double, elapsedDays: Double, scheduledDays: Double, + learningSteps: Int, reps: Int, lapses: Int, lastReview _: String? + ) async throws { + let aggId = WordeckEvent.reviewAggregate(cardId: cardId, subIndex: subIndex) + let reviewId = ULID.generate() + try await emit(aggregate: aggId, type: WordeckEvent.reviewInitialized, payload: .object([ + "reviewId": .string(reviewId), + "cardId": .string(cardId), + "subIndex": .int(Int64(subIndex)), + "due": .string(due) + ])) + guard reps > 0 else { return } + try await emit(aggregate: aggId, type: WordeckEvent.reviewGraded, payload: .object([ + "reviewId": .string(reviewId), + "rating": .string("good"), // Platzhalter — Reducer projiziert aus den new*-Feldern + "newState": .string(state), + "newDue": .string(due), + "newStability": .double(stability), + "newDifficulty": .double(difficulty), + "newElapsedDays": .double(elapsedDays), + "newScheduledDays": .double(scheduledDays), + "newLearningSteps": .int(Int64(learningSteps)), + "newReps": .int(Int64(reps)), + "newLapses": .int(Int64(lapses)), + "prevSnapshotJson": .null + ])) + } } enum WordeckSyncError: LocalizedError { diff --git a/Tests/UnitTests/WordeckEventCoordinatorTests.swift b/Tests/UnitTests/WordeckEventCoordinatorTests.swift index 55f34ee..3a969fb 100644 --- a/Tests/UnitTests/WordeckEventCoordinatorTests.swift +++ b/Tests/UnitTests/WordeckEventCoordinatorTests.swift @@ -65,4 +65,31 @@ struct WordeckEventCoordinatorTests { try await c.deleteDeck(id: deck.id) #expect(try c.listDecks().isEmpty) } + + @Test("import bewahrt IDs + FSRS-State") + func legacyImport() async throws { + let c = try makeCoordinator() + try await c.importDeck( + id: "d1", + name: "Imp", + description: nil, + color: nil, + category: "language", + archived: false + ) + try await c.importCard(id: "card1", deckId: "d1", type: "basic", fields: ["front": "a", "back": "b"]) + // Fälliges Review (Vergangenheit) mit bestehendem FSRS-State. + try await c.importReview( + cardId: "card1", subIndex: 0, due: "2026-01-01T10:00:00.000Z", state: "review", + stability: 12.3, difficulty: 6.1, elapsedDays: 5, scheduledDays: 12, + learningSteps: 0, reps: 3, lapses: 1, lastReview: "2026-05-20T10:00:00.000Z" + ) + #expect(try c.listDecks().contains { $0.id == "d1" }) + #expect(try c.listCards(deckId: "d1").first?.id == "card1") + let due = try c.dueReviews(deckId: "d1") + #expect(due.count == 1) + #expect(due.first?.review.stability == 12.3) + #expect(due.first?.review.reps == 3) + #expect(due.first?.review.state == .review) + } }