From f34460d97ae1853e612f22008a874ab4e473e3cd Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Tue, 21 Jul 2026 18:00:22 +0200 Subject: [PATCH] =?UTF-8?q?Diagn=C3=B3stico=20iCloud:=20dump=20completo=20?= =?UTF-8?q?del=20=C3=A1rbol=20CKError=20(c=C3=B3digos=20hoja=20+=20record?= =?UTF-8?q?=20types)=20+=20sonda=20de=20integridad=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554 --- PortfolioJournal.xcodeproj/project.pbxproj | 20 ++-- .../xcschemes/PortfolioJournal.xcscheme | 14 ++- .../xcschemes/xcschememanagement.plist | 5 + PortfolioJournal/Models/CoreDataStack.swift | 105 ++++++++++++++++++ .../Views/Settings/SettingsView.swift | 6 + 5 files changed, 138 insertions(+), 12 deletions(-) diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 4a9dce3..4dbfbb6 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -582,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -739,7 +739,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -774,7 +774,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget"; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -805,7 +805,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -829,7 +829,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -852,7 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -875,7 +875,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -899,7 +899,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; @@ -927,7 +927,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore"; GENERATE_INFOPLIST_FILE = NO; diff --git a/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme index 04e7a3a..524d729 100644 --- a/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme +++ b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> @@ -73,6 +73,16 @@ ReferencedContainer = "container:PortfolioJournal.xcodeproj"> + + + + + + diff --git a/PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist b/PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist index e0c0940..5f8631c 100644 --- a/PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 1 + PortfolioJournalQuickUpdate.xcscheme_^#shared#^_ + + orderHint + 2 + PortfolioJournalWidgetExtension.xcscheme_^#shared#^_ orderHint diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index 1fe469f..cc77ffe 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -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() + var recordTypes = Set() + 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(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(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() diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index 3e84537..d57cbec 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -898,6 +898,12 @@ struct SettingsView: View { lines.append("critical: \(cloudStack.syncErrorIsCritical)") lines.append("hint: \(cloudStack.lastSyncErrorHint ?? "—")") lines.append("error: \(cloudStack.lastSyncError ?? "none this session")") + lines.append("") + lines.append("— integrity —") + lines.append(cloudStack.integrityReport()) + lines.append("") + lines.append("— error detail —") + lines.append(cloudStack.lastSyncErrorDetail ?? "none this session") return lines.joined(separator: "\n") } }