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:
alexandrev-tibco
2026-07-21 18:00:22 +02:00
parent e916218271
commit f34460d97a
5 changed files with 138 additions and 12 deletions
+10 -10
View File
@@ -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;
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
@@ -34,7 +34,7 @@
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0ETEST001000000000000000"
BlueprintIdentifier = "0ETEST0012F31000000000001"
BuildableName = "PortfolioJournalTests.xctest"
BlueprintName = "PortfolioJournalTests"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
@@ -73,6 +73,16 @@
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.Logging.stderr 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.CloudKitDebug 3 "
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<StoreKitConfigurationFileReference
identifier = "../../PortfolioJournal/Configuration.storekit">
</StoreKitConfigurationFileReference>
@@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>PortfolioJournalQuickUpdate.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
+105
View File
@@ -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()
@@ -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")
}
}