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:
@@ -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>
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user