Stop silent save failures from locking out textbook re-seeds

Two bugs were causing chapters to disappear on every relaunch:

1. seedTextbookData used `try? context.save()` (swallowing errors) and
   returned `inserted > 0`, so a failed save still reported success.
   Callers then bumped UserDefaults textbookDataVersion and subsequent
   launches skipped the re-seed entirely — with no rows on disk.

2. refreshTextbookDataIfNeeded wiped chapters via the batch-delete API
   `context.delete(model: TextbookChapter.self)`, which hits the store
   directly without clearing the context's .unique-id index. Re-inserting
   chapters with the same ids could then throw a unique-constraint error
   on save — also silently eaten by `try?`.

Fixes:
- seedTextbookData now uses do/catch around save(), returns false on
  error, and verifies persistence via fetchCount before returning true.
- refreshTextbookDataIfNeeded fetches and deletes chapters individually
  so the context tracks the deletion cleanly; wipe save is also now
  checked and bails early on failure.
- Bumped textbookDataVersion to 11 so devices poisoned by the previous
  silent-failure path retry on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-21 22:33:23 -05:00
parent 3c5600f562
commit 455df18dad

View File

@@ -6,7 +6,7 @@ actor DataLoader {
static let courseDataVersion = 7
static let courseDataKey = "courseDataVersion"
static let textbookDataVersion = 10
static let textbookDataVersion = 11
static let textbookDataKey = "textbookDataVersion"
/// Quick check: does the DB need seeding or course data refresh?
@@ -156,17 +156,26 @@ actor DataLoader {
print("Textbook data version outdated — re-seeding...")
let context = ModelContext(container)
// Only wipe textbook chapters and our textbook-scoped CourseDecks
// (not the LanGo decks, which live in the same tables).
try? context.delete(model: TextbookChapter.self)
// Fetch + delete individually instead of batch delete. SwiftData's
// context.delete(model:) hits the store directly and doesn't always
// clear the unique-constraint index before the reseed's save runs,
// so re-inserting rows with the same .unique id can throw.
let textbookCourseName = "Complete Spanish Step-by-Step"
if let existing = try? context.fetch(FetchDescriptor<TextbookChapter>()) {
for chapter in existing { context.delete(chapter) }
}
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
)
if let decks = try? context.fetch(deckDescriptor) {
for deck in decks { context.delete(deck) }
}
try? context.save()
do {
try context.save()
} catch {
print("[DataLoader] ERROR: textbook wipe save failed: \(error)")
return
}
if seedTextbookData(context: context) {
shared.set(textbookDataVersion, forKey: textbookDataKey)
@@ -480,14 +489,27 @@ actor DataLoader {
inserted += 1
}
try? context.save()
do {
try context.save()
} catch {
print("[DataLoader] ERROR: textbook chapter save failed: \(error)")
return false
}
// Verify rows actually hit the store guards against the case where
// save returned cleanly but no rows were persisted.
let persisted = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
guard persisted > 0 else {
print("[DataLoader] ERROR: textbook seeded \(inserted) chapters but persisted count is 0")
return false
}
// Seed textbook-derived vocabulary flashcards as CourseDecks so the
// existing Course UI can surface them alongside LanGo decks.
seedTextbookVocabDecks(context: context, courseName: courseName)
print("Textbook seeding complete: \(inserted) chapters")
return inserted > 0
print("Textbook seeding complete: \(inserted) chapters inserted, \(persisted) persisted")
return true
}
private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {