Rama 1.6.0: dedup CloudKit-safe (WIP: StableID + UUIDs estables en defaults)

Funde 1.5.1 en 1.6.0. Parte 1 dedup (prevención): StableID (UUIDv5 namespace+nombre),
Account.defaultAccountStableID, Category.stableID(for:) + asignación en
createDefaultCategories. Pendiente: asignar en repos + limpieza de duplicados existentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
alexandrev-tibco
2026-07-25 12:04:04 +02:00
parent e4a93a5d4a
commit eee0910680
4 changed files with 55 additions and 10 deletions
+10 -10
View File
@@ -559,7 +559,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -600,7 +600,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -752,7 +752,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -787,7 +787,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -814,7 +814,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -838,7 +838,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -861,7 +861,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -884,7 +884,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -911,7 +911,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -940,7 +940,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -46,6 +46,11 @@ public class Account: NSManagedObject, Identifiable {
extension Account {
static let defaultAccountName = "Default"
/// Deterministic UUID for the auto-created "Default" account, so every device
/// creates it as the SAME CloudKit record instead of a per-device duplicate.
/// See StableID.
static let defaultAccountStableID = StableID.v5("account:default")
}
// MARK: - Computed Properties
@@ -92,9 +92,17 @@ extension Category {
("ETFs", "#EC4899", "chart.bar.fill")
]
/// Deterministic UUID for a default category, so every device creates each
/// default category as the SAME CloudKit record instead of a per-device
/// duplicate. Only meaningful for the built-in default names. See StableID.
static func stableID(for name: String) -> UUID {
StableID.v5("category:\(name.lowercased())")
}
static func createDefaultCategories(in context: NSManagedObjectContext) {
for (index, categoryData) in defaultCategories.enumerated() {
let category = Category(context: context)
category.id = stableID(for: categoryData.name)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
+32
View File
@@ -0,0 +1,32 @@
import Foundation
import CryptoKit
/// Deterministic UUIDs (RFC 4122 v5) for records that every device/install must
/// create identically the default Account and the default Categories.
///
/// Why: `awakeFromInsert()` assigns a random UUID, so each device seeded its own
/// "Default" account + default categories with *different* UUIDs before the first
/// CloudKit import. CloudKit keys on the UUID (record name), so those identical-in-
/// meaning records piled up as distinct records (8 accounts, 23 categories in the
/// wild). Giving the defaults a STABLE UUID derived from a fixed namespace + name
/// means every device produces the SAME record name CloudKit natively merges
/// them into one. No dedup, no deletes, no cascade the root-cause fix.
enum StableID {
/// Fixed app namespace (generated once, constant forever never change it).
static let namespace = UUID(uuidString: "F3A1C2D4-5E6F-4A7B-8C9D-0E1F2A3B4C5D")!
/// RFC 4122 v5 UUID: SHA-1(namespace bytes name) with version/variant bits set.
static func v5(_ name: String) -> UUID {
var hasher = Insecure.SHA1()
withUnsafeBytes(of: namespace.uuid) { hasher.update(bufferPointer: $0) }
hasher.update(data: Data(name.utf8))
let digest = Array(hasher.finalize()) // 20 bytes; take first 16
var b = Array(digest.prefix(16))
b[6] = (b[6] & 0x0F) | 0x50 // version 5
b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 variant
return UUID(uuid: (b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
}
}