Fix 7 data mutation layer risks identified in audit
- save()/saveAndRunDataListeners() now return @discardableResult Bool; listeners only fire on successful save - MoodLogger.updateMood() now recalculates streak, updates Live Activity, and notifies watch (was missing these side effects) - CSV import uses new addBatch()/importMoods() for O(1) side effects instead of O(n) per-row widget reloads and streak calcs - Foreground task ordering: fillInMissingDates() now runs before removeDuplicates() so backfill-created duplicates are caught same cycle - WidgetMoodSaver deletes ALL entries for date (was fetchLimit=1, leaving CloudKit sync duplicates behind) - cleanupPhotoIfNeeded logs warning on failed photo deletion instead of silently orphaning files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,15 +73,37 @@ extension VoteMoodIntent: ForegroundContinuableIntent {}
|
||||
#if WIDGET_EXTENSION
|
||||
enum WidgetMoodSaver {
|
||||
private static let logger = Logger(subsystem: "com.tt.feels.widget", category: "WidgetMoodSaver")
|
||||
private static var cachedContainer: ModelContainer?
|
||||
|
||||
@MainActor
|
||||
static func save(mood: Mood, date: Date) {
|
||||
do {
|
||||
let container = try getOrCreateContainer()
|
||||
try performSave(mood: mood, date: date, container: container)
|
||||
} catch {
|
||||
// Container may be stale or corrupted — discard cache and retry once
|
||||
logger.warning("First save attempt failed, retrying with fresh container: \(error.localizedDescription)")
|
||||
cachedContainer = nil
|
||||
do {
|
||||
let container = try getOrCreateContainer()
|
||||
try performSave(mood: mood, date: date, container: container)
|
||||
} catch {
|
||||
logger.error("Failed to save mood after retry: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func getOrCreateContainer() throws -> ModelContainer {
|
||||
if let existing = cachedContainer {
|
||||
return existing
|
||||
}
|
||||
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let appGroupID = Constants.currentGroupShareId
|
||||
|
||||
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
|
||||
logger.error("App Group not available")
|
||||
return
|
||||
throw WidgetMoodSaverError.appGroupUnavailable
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@@ -90,34 +112,55 @@ enum WidgetMoodSaver {
|
||||
let storeURL = containerURL.appendingPathComponent("Feels.store")
|
||||
#endif
|
||||
|
||||
do {
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
let context = container.mainContext
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
cachedContainer = container
|
||||
return container
|
||||
}
|
||||
|
||||
// Delete existing entry for this date
|
||||
let startDate = Calendar.current.startOfDay(for: date)
|
||||
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
|
||||
@MainActor
|
||||
private static func performSave(mood: Mood, date: Date, container: ModelContainer) throws {
|
||||
let context = container.mainContext
|
||||
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate && entry.forDate <= endDate
|
||||
}
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
// Delete existing entry for this date
|
||||
let startDate = Calendar.current.startOfDay(for: date)
|
||||
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
|
||||
|
||||
if let existing = try? context.fetch(descriptor).first {
|
||||
context.delete(existing)
|
||||
try? context.save()
|
||||
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate && entry.forDate < endDate
|
||||
}
|
||||
)
|
||||
|
||||
// Create new entry
|
||||
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: .widget)
|
||||
context.insert(entry)
|
||||
let existing = try context.fetch(descriptor)
|
||||
if !existing.isEmpty {
|
||||
for entry in existing {
|
||||
context.delete(entry)
|
||||
}
|
||||
try context.save()
|
||||
logger.info("Saved mood \(mood.rawValue) from widget")
|
||||
} catch {
|
||||
logger.error("Failed to save mood: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: .widget)
|
||||
context.insert(entry)
|
||||
try context.save()
|
||||
logger.info("Saved mood \(mood.rawValue) from widget")
|
||||
|
||||
// Note: The widget cannot run full side effects (HealthKit, streaks, analytics, etc.)
|
||||
// because it runs in a separate extension process without access to MoodLogger.
|
||||
// When the main app returns to the foreground, it calls
|
||||
// MoodLogger.shared.processPendingSideEffects() to catch up on any side effects
|
||||
// that were missed from widget or watch entries.
|
||||
}
|
||||
|
||||
enum WidgetMoodSaverError: LocalizedError {
|
||||
case appGroupUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .appGroupUnavailable:
|
||||
return "App Group container is not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user