Diagnóstico iCloud: dump completo del árbol CKError (códigos hoja + record types) + sonda de integridad local
deepErrorReport() sigue NSUnderlyingError y CKPartialErrorsByItemIDKey y extrae el recordType del CKRecord embebido → dice qué entidad rechaza CloudKit. integrityReport() lista counts por entidad + registros con id/createdAt nil u orphan snapshots. Ambos en el diagnóstico copiable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
This commit is contained in:
@@ -123,6 +123,10 @@ class CoreDataStack: ObservableObject {
|
||||
@Published private(set) var lastExportDate: Date?
|
||||
@Published private(set) var isSyncing = false
|
||||
@Published private(set) var lastSyncError: String?
|
||||
/// Full recursive dump of the last sync error tree (leaf CKError codes, record
|
||||
/// types, retry-after, raw userInfo keys). Surfaced in the copyable diagnostics
|
||||
/// so we can see WHICH record CloudKit rejects, not just the opaque code 2.
|
||||
@Published private(set) var lastSyncErrorDetail: String?
|
||||
/// Human-readable, actionable explanation of the last sync error (nil if none).
|
||||
@Published private(set) var lastSyncErrorHint: String?
|
||||
/// True only when sync is genuinely broken (never synced + total failure).
|
||||
@@ -348,6 +352,7 @@ class CoreDataStack: ObservableObject {
|
||||
} else if let error = event.error {
|
||||
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
|
||||
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
|
||||
self.lastSyncErrorDetail = "\(typeLabel)\n\(Self.deepErrorReport(error))"
|
||||
self.lastSyncErrorHint = Self.hint(for: error)
|
||||
// Severity: a partial failure AFTER sync has worked at least
|
||||
// once is background noise (a subset of records CloudKit
|
||||
@@ -427,6 +432,106 @@ class CoreDataStack: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Full recursive walk of a CloudKit/Core Data error tree. Unlike `describeError`
|
||||
/// (a compact one-liner), this follows NSUnderlyingError chains and
|
||||
/// CKPartialErrorsByItemIDKey, and pulls the failing record TYPE out of any
|
||||
/// embedded CKRecord — which is what tells us WHICH entity CloudKit rejects.
|
||||
static func deepErrorReport(_ error: Error) -> String {
|
||||
var lines: [String] = []
|
||||
var leafCodes = Set<Int>()
|
||||
var recordTypes = Set<String>()
|
||||
var visited = 0
|
||||
|
||||
func walk(_ err: Error, indent: Int) {
|
||||
guard visited < 300 else { return }
|
||||
visited += 1
|
||||
let ns = err as NSError
|
||||
let pad = String(repeating: " ", count: indent)
|
||||
lines.append("\(pad)\(ns.domain)(\(ns.code))")
|
||||
if ns.domain == "CKErrorDomain", ns.code != 2 { leafCodes.insert(ns.code) }
|
||||
|
||||
let info = ns.userInfo
|
||||
if let reason = info[NSLocalizedFailureReasonErrorKey] as? String {
|
||||
lines.append("\(pad) reason: \(reason)")
|
||||
}
|
||||
if let desc = info[NSLocalizedDescriptionKey] as? String {
|
||||
lines.append("\(pad) desc: \(desc)")
|
||||
}
|
||||
if let retry = info["CKErrorRetryAfterKey"] {
|
||||
lines.append("\(pad) retryAfter: \(retry)")
|
||||
}
|
||||
for key in ["CKRecordChangedErrorServerRecordKey",
|
||||
"CKRecordChangedErrorClientRecordKey",
|
||||
"CKRecordChangedErrorAncestorRecordKey"] {
|
||||
if let rec = info[key] as? CKRecord {
|
||||
recordTypes.insert(rec.recordType)
|
||||
lines.append("\(pad) record: \(rec.recordType) [\(rec.recordID.recordName)]")
|
||||
}
|
||||
}
|
||||
if let partial = info["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
|
||||
lines.append("\(pad) partialErrors: \(partial.count)")
|
||||
for (rid, value) in partial.prefix(25) {
|
||||
if let name = (rid as? CKRecord.ID)?.recordName {
|
||||
lines.append("\(pad) item: \(name)")
|
||||
}
|
||||
if let sub = value as? Error { walk(sub, indent: indent + 3) }
|
||||
}
|
||||
}
|
||||
if let underlying = info[NSUnderlyingErrorKey] as? Error {
|
||||
lines.append("\(pad) underlying:")
|
||||
walk(underlying, indent: indent + 2)
|
||||
}
|
||||
for (k, v) in info {
|
||||
let ks = "\(k)"
|
||||
if ks == NSUnderlyingErrorKey || ks == "CKPartialErrorsByItemIDKey" { continue }
|
||||
if let sub = v as? Error, (sub as NSError) !== ns {
|
||||
lines.append("\(pad) [\(ks)]:")
|
||||
walk(sub, indent: indent + 2)
|
||||
}
|
||||
}
|
||||
let keys = info.keys.map { "\($0)" }.sorted()
|
||||
if !keys.isEmpty { lines.append("\(pad) keys: \(keys.joined(separator: ", "))") }
|
||||
}
|
||||
|
||||
walk(error, indent: 0)
|
||||
|
||||
var summary = ""
|
||||
if !leafCodes.isEmpty {
|
||||
summary += "leafCKCodes: [\(leafCodes.sorted().map(String.init).joined(separator: ", "))]\n"
|
||||
}
|
||||
if !recordTypes.isEmpty {
|
||||
summary += "recordTypes: [\(recordTypes.sorted().joined(separator: ", "))]\n"
|
||||
}
|
||||
return summary + lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Local Core Data integrity probe: per-entity counts + records with nil id /
|
||||
/// nil createdAt / orphan relationships that can silently block a CloudKit export.
|
||||
func integrityReport() -> String {
|
||||
let ctx = viewContext
|
||||
var lines: [String] = []
|
||||
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal", "JournalEntry"]
|
||||
for name in entities {
|
||||
let count = (try? ctx.count(for: NSFetchRequest<NSManagedObject>(entityName: name))) ?? -1
|
||||
lines.append("\(name): \(count)")
|
||||
}
|
||||
func flag(_ label: String, _ n: Int) { if n > 0 { lines.append("⚠︎ \(label): \(n)") } }
|
||||
func fetch(_ name: String) -> [NSManagedObject] {
|
||||
(try? ctx.fetch(NSFetchRequest<NSManagedObject>(entityName: name))) ?? []
|
||||
}
|
||||
let sources = fetch("InvestmentSource")
|
||||
flag("sources nil id", sources.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("sources nil createdAt", sources.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
let snaps = fetch("Snapshot")
|
||||
flag("snapshots nil id", snaps.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("snapshots nil createdAt", snaps.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
flag("orphan snapshots (nil source)", snaps.filter { $0.value(forKey: "source") == nil }.count)
|
||||
let goals = fetch("Goal")
|
||||
flag("goals nil id", goals.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("goals nil createdAt", goals.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func forceReload() {
|
||||
viewContext.perform { [weak self] in
|
||||
self?.viewContext.refreshAllObjects()
|
||||
|
||||
Reference in New Issue
Block a user