Files
Spanish/Conjuga/Conjuga/Services/StoreInspector.swift
Trey t 8e1c9b6bf1 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>
2026-04-21 23:31:14 -05:00

67 lines
3.0 KiB
Swift

import Foundation
import SQLite3
/// Read-only SQLite inspector for diagnosing SwiftData store state.
/// Does NOT modify the file just reads `sqlite_master` and a couple of counts.
enum StoreInspector {
static func dump(at url: URL, label: String) {
let path = url.path
guard FileManager.default.fileExists(atPath: path) else {
print("[StoreInspector:\(label)] file does not exist at \(path)")
return
}
var db: OpaquePointer?
defer { if let db = db { sqlite3_close(db) } }
let openResult = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil)
guard openResult == SQLITE_OK else {
print("[StoreInspector:\(label)] open failed code=\(openResult)")
return
}
// List tables
let tables = queryStrings(db: db, sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
let hasZVERB = tables.contains("ZVERB")
let hasZVERBFORM = tables.contains("ZVERBFORM")
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
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 += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -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)
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
let zTables = tables.filter { $0.hasPrefix("Z") && !$0.hasPrefix("Z_") }
if !zTables.isEmpty {
print("[StoreInspector:\(label)] entity tables: \(zTables.joined(separator: ", "))")
}
}
private static func queryStrings(db: OpaquePointer?, sql: String) -> [String] {
var stmt: OpaquePointer?
defer { if let stmt = stmt { sqlite3_finalize(stmt) } }
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
var results: [String] = []
while sqlite3_step(stmt) == SQLITE_ROW {
if let cstr = sqlite3_column_text(stmt, 0) {
results.append(String(cString: cstr))
}
}
return results
}
private static func queryInt(db: OpaquePointer?, sql: String) -> Int {
var stmt: OpaquePointer?
defer { if let stmt = stmt { sqlite3_finalize(stmt) } }
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return -1 }
guard sqlite3_step(stmt) == SQLITE_ROW else { return -1 }
return Int(sqlite3_column_int(stmt, 0))
}
}