Make textbook data self-heal after widget schema wipes

Root cause of the repeatedly-disappearing textbook: both widget timeline
providers were opening the shared local SwiftData store with a schema
that omitted TextbookChapter. On each widget refresh SwiftData
destructively migrated the store to match the widget's narrower schema,
dropping the ZTEXTBOOKCHAPTER rows (and sometimes the table itself).
The app then re-created an empty table on next open, but
refreshTextbookDataIfNeeded skipped re-seeding because the UserDefaults
version flag was already current — leaving the store empty indefinitely.

Three changes:

1. Widgets (CombinedWidget, WordOfDayWidget): added TextbookChapter to
   both schema arrays so they match the main app. Widget refreshes will
   no longer drop the entity.

2. DataLoader.refreshTextbookDataIfNeeded: trigger now considers BOTH
   the version flag and the actual on-disk row count. If rows are
   missing for any reason (past wipes, future subset-schema openers,
   corruption), the next launch re-seeds. Eliminates the class of bug
   where a version flag lies about what's really in the store.

3. StoreInspector: reports ZTEXTBOOKCHAPTER row count alongside the
   other entities so we can confirm state from logs.

Bumped textbookDataVersion to 12 so devices that were stuck in the
silent-failure state re-seed on next launch regardless of prior flag
value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-21 23:31:14 -05:00
parent d9ddaa4902
commit 8e1c9b6bf1
4 changed files with 30 additions and 11 deletions

View File

@@ -6,7 +6,7 @@ actor DataLoader {
static let courseDataVersion = 7 static let courseDataVersion = 7
static let courseDataKey = "courseDataVersion" static let courseDataKey = "courseDataVersion"
static let textbookDataVersion = 11 static let textbookDataVersion = 12
static let textbookDataKey = "textbookDataVersion" static let textbookDataKey = "textbookDataVersion"
/// Quick check: does the DB need seeding or course data refresh? /// Quick check: does the DB need seeding or course data refresh?
@@ -148,13 +148,24 @@ actor DataLoader {
} }
} }
/// Re-seed textbook data if the version has changed. /// Re-seed textbook data if the version has changed OR if the rows are
/// missing on disk. The row-count check exists because anything opening
/// this store with a subset schema (e.g. an out-of-date widget extension)
/// can destructively drop the rows without touching UserDefaults so a
/// pure version-flag trigger would leave us permanently empty.
static func refreshTextbookDataIfNeeded(container: ModelContainer) async { static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard let shared = UserDefaults.standard
if shared.integer(forKey: textbookDataKey) >= textbookDataVersion { return }
print("Textbook data version outdated — re-seeding...")
let context = ModelContext(container) let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion
if versionCurrent && existingCount > 0 { return }
if versionCurrent {
print("Textbook data version current but store has \(existingCount) chapters — re-seeding...")
} else {
print("Textbook data version outdated — re-seeding...")
}
// Fetch + delete individually instead of batch delete. SwiftData's // Fetch + delete individually instead of batch delete. SwiftData's
// context.delete(model:) hits the store directly and doesn't always // context.delete(model:) hits the store directly and doesn't always

View File

@@ -26,12 +26,14 @@ enum StoreInspector {
let hasZVERBFORM = tables.contains("ZVERBFORM") let hasZVERBFORM = tables.contains("ZVERBFORM")
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE") let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
let hasZVOCABCARD = tables.contains("ZVOCABCARD") let hasZVOCABCARD = tables.contains("ZVOCABCARD")
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
var summary = "[StoreInspector:\(label)] \(tables.count) tables" var summary = "[StoreInspector:\(label)] \(tables.count) tables"
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)" summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)" summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)" summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)" summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)"
print(summary) print(summary)
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables) // Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)

View File

@@ -41,14 +41,16 @@ struct CombinedProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil } guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 6 local entities to match the main app's schema. // MUST declare all 7 local entities to match the main app's schema.
// Declaring a subset would cause SwiftData to destructively migrate the store // Declaring a subset would cause SwiftData to destructively migrate the
// on open, dropping the entities not listed here. // store on open, dropping the entities not listed here (this is how we
// previously lost all TextbookChapter rows on every widget refresh).
let config = ModelConfiguration( let config = ModelConfiguration(
"local", "local",
schema: Schema([ schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self, Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
]), ]),
url: localURL, url: localURL,
cloudKitDatabase: .none cloudKitDatabase: .none
@@ -56,6 +58,7 @@ struct CombinedProvider: TimelineProvider {
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
configurations: config configurations: config
) else { return nil } ) else { return nil }

View File

@@ -32,14 +32,16 @@ struct WordOfDayProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil } guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 6 local entities to match the main app's schema. // MUST declare all 7 local entities to match the main app's schema.
// Declaring a subset would cause SwiftData to destructively migrate the store // Declaring a subset would cause SwiftData to destructively migrate the
// on open, dropping the entities not listed here. // store on open, dropping the entities not listed here (this is how we
// previously lost all TextbookChapter rows on every widget refresh).
let config = ModelConfiguration( let config = ModelConfiguration(
"local", "local",
schema: Schema([ schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self, Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
]), ]),
url: localURL, url: localURL,
cloudKitDatabase: .none cloudKitDatabase: .none
@@ -47,6 +49,7 @@ struct WordOfDayProvider: TimelineProvider {
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
configurations: config configurations: config
) else { return nil } ) else { return nil }