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:
parent
3daca22477
commit
96ef285dfe
4 changed files with 187 additions and 1 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
85
Sources/Core/Sync/WordeckDataMigration.swift
Normal file
85
Sources/Core/Sync/WordeckDataMigration.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue