Surface sync failures in Settings via lastSyncError
Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
This commit is contained in:
@@ -20,6 +20,10 @@ final class SyncEngine {
|
||||
private(set) var iCloudStatus: ICloudStatus = .checking
|
||||
private(set) var isSyncing = false
|
||||
|
||||
/// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
|
||||
/// next successful write or full reconcile.
|
||||
private(set) var lastSyncError: String?
|
||||
|
||||
/// Called after the cache changes (local or remote). The watch bridge uses
|
||||
/// this to push fresh state to the watch.
|
||||
var onCacheChanged: (() -> Void)?
|
||||
@@ -168,7 +172,7 @@ final class SyncEngine {
|
||||
deleteCachedEntity(jsonRelativePath: path)
|
||||
}
|
||||
}
|
||||
try? context.save()
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
onCacheChanged?()
|
||||
}
|
||||
|
||||
@@ -184,7 +188,7 @@ final class SyncEngine {
|
||||
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
||||
await save(workout: doc)
|
||||
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
||||
try? context.save()
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
onCacheChanged?()
|
||||
}
|
||||
|
||||
@@ -192,15 +196,23 @@ final class SyncEngine {
|
||||
|
||||
func save(split doc: SplitDocument) async {
|
||||
guard let fm = fileManager else { return }
|
||||
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
|
||||
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
|
||||
do {
|
||||
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
|
||||
lastSyncError = nil
|
||||
} catch {
|
||||
report("Failed to save split", error)
|
||||
}
|
||||
// Cache updates reactively via the monitor.
|
||||
}
|
||||
|
||||
func save(workout doc: WorkoutDocument) async {
|
||||
guard let fm = fileManager else { return }
|
||||
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
|
||||
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
|
||||
do {
|
||||
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
|
||||
lastSyncError = nil
|
||||
} catch {
|
||||
report("Failed to save workout", error)
|
||||
}
|
||||
// Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
|
||||
// a completed doc (re)considers an estimate (the writer dedupes on metrics).
|
||||
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
|
||||
@@ -220,8 +232,9 @@ final class SyncEngine {
|
||||
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
|
||||
do {
|
||||
try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
|
||||
lastSyncError = nil
|
||||
} catch {
|
||||
print("[Sync] delete failed for \(id): \(error)")
|
||||
report("Failed to delete \(id)", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +250,7 @@ final class SyncEngine {
|
||||
// treat an unreadable file as absent. The next monitor event or
|
||||
// reconcile retries it.
|
||||
log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
|
||||
report("Failed to read \(relativePath)", error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -279,6 +293,7 @@ final class SyncEngine {
|
||||
// how evicted records used to vanish on storage-constrained
|
||||
// devices.
|
||||
log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
|
||||
report("Failed to read \(path)", error)
|
||||
unreadablePaths.insert(path)
|
||||
continue
|
||||
}
|
||||
@@ -308,7 +323,14 @@ final class SyncEngine {
|
||||
context.delete(w)
|
||||
}
|
||||
}
|
||||
try? context.save()
|
||||
do {
|
||||
try context.save()
|
||||
// A fully clean pass supersedes any earlier failure; a pass with
|
||||
// unreadable files keeps its own report visible.
|
||||
if unreadablePaths.isEmpty { lastSyncError = nil }
|
||||
} catch {
|
||||
report("Cache save failed", error)
|
||||
}
|
||||
onCacheChanged?()
|
||||
}
|
||||
|
||||
@@ -343,4 +365,14 @@ final class SyncEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Reporting
|
||||
|
||||
/// Record a non-fatal sync failure: log it and publish it as `lastSyncError`
|
||||
/// for the UI to render. Replaces silent `try?` / print-only handling.
|
||||
private func report(_ message: String, _ error: Error? = nil) {
|
||||
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
|
||||
print("[Sync] \(full)")
|
||||
lastSyncError = full
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user