feat(events): Legacy-Cache→Event-Datenmigration (E-4.4b)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-25 15:45:29 +02:00
parent 3daca22477
commit 96ef285dfe
4 changed files with 187 additions and 1 deletions

View file

@ -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-CacheEvent-Migration (E-4.4b).
if let coordinator = WordeckEventCoordinator.shared {
await WordeckDataMigration.runIfNeeded(context: modelContext, coordinator: coordinator)
}
}
}

View file

@ -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<CachedDeck>()) {
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<CachedDueReview>()) {
reviewByKey["\(review.cardId)__\(review.subIndex)"] = review
}
let nowISO = iso.string(from: Date())
for card in try context.fetch(FetchDescriptor<CachedCard>()) {
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<PendingGrade>()) {
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()
}
}

View file

@ -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 {

View file

@ -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)
}
}