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:
Trey t
2026-02-14 23:09:11 -06:00
parent 3023475f66
commit f1cd81c395
8 changed files with 306 additions and 55 deletions

View File

@@ -10,10 +10,32 @@ import Foundation
import os.log
extension DataController {
// MARK: - Photo Cleanup
func cleanupPhotoIfNeeded(for entry: MoodEntryModel) {
if let photoID = entry.photoID {
if !PhotoManager.shared.deletePhoto(id: photoID) {
AppLogger.general.warning("Failed to delete orphaned photo \(photoID.uuidString) for entry on \(entry.forDate)")
}
}
}
// MARK: - Delete Operations
func clearDB() {
do {
// Clean up photo files before batch delete
let descriptor = FetchDescriptor<MoodEntryModel>()
if let allEntries = try? modelContext.fetch(descriptor) {
for entry in allEntries {
cleanupPhotoIfNeeded(for: entry)
}
}
try modelContext.delete(model: MoodEntryModel.self)
saveAndRunDataListeners()
AnalyticsManager.shared.track(.allDataCleared)
} catch {
AppLogger.general.error("Failed to clear database: \(error.localizedDescription)")
}
@@ -24,9 +46,10 @@ extension DataController {
let entries = getData(startDate: startDate, endDate: Date(), includedDays: [])
for entry in entries {
cleanupPhotoIfNeeded(for: entry)
modelContext.delete(entry)
}
save()
saveAndRunDataListeners()
}
func deleteRandomFromLast(numberOfEntries: Int) {
@@ -34,9 +57,10 @@ extension DataController {
let entries = getData(startDate: startDate, endDate: Date(), includedDays: [])
for entry in entries where Bool.random() {
cleanupPhotoIfNeeded(for: entry)
modelContext.delete(entry)
}
save()
saveAndRunDataListeners()
}
/// Get ALL entries for a specific date (not just the first one)
@@ -46,7 +70,7 @@ extension DataController {
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
entry.forDate >= startDate && entry.forDate < endDate
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
@@ -58,9 +82,10 @@ extension DataController {
func deleteAllEntries(forDate date: Date) {
let entries = getAllEntries(byDate: date)
for entry in entries {
cleanupPhotoIfNeeded(for: entry)
modelContext.delete(entry)
}
save()
saveAndRunDataListeners()
}
/// Find and remove duplicate entries, keeping only the most recent for each date
@@ -92,6 +117,7 @@ extension DataController {
// Keep the first (best) entry, delete the rest
for entry in sorted.dropFirst() {
cleanupPhotoIfNeeded(for: entry)
modelContext.delete(entry)
duplicatesRemoved += 1
}
@@ -100,6 +126,7 @@ extension DataController {
if duplicatesRemoved > 0 {
saveAndRunDataListeners()
AppLogger.general.info("Removed \(duplicatesRemoved) duplicate entries")
AnalyticsManager.shared.track(.duplicatesRemoved(count: duplicatesRemoved))
}
return duplicatesRemoved