ZaatarLABS
Escríbenos →
← Todas las entradas
The Smart Budget · Ingeniería

Cómo funciona compartir en el hogar sin confiar en un servidor

Dos cuentas de iCloud, un presupuesto compartido y ningún backend bajo mi control. NSPersistentCloudKitContainer no podía hacerlo, así que escribí el motor de sincronización a mano. Esto es lo que hace falta de verdad.

14 min de lectura

The Smart Budget permite que un hogar comparta un mismo presupuesto. Dos personas, dos cuentas de iCloud separadas, ambas editando las mismas transacciones y presupuestos en tiempo real. La restricción que me puse: ningún servidor bajo mi control en medio. Tus datos de dinero se sincronizan directamente a través de CloudKit de Apple, de cuenta a cuenta. Yo nunca los veo, y no existe un backend que yo pueda filtrar, que me puedan requerir judicialmente o que puedan hackear.

Apple tiene un framework que parece hacer exactamente esto: NSPersistentCloudKitContainer con CKShare. Pasé tres días intentando que funcionara. Luego lo borré y escribí yo mismo el motor de sincronización. Esta es la historia de por qué, y de cómo es el reemplazo, con el código real.

El enfoque obvio, y el fallo

El camino oficial es este: respaldar Core Data con NSPersistentCloudKitContainer (NSPCKC), que replica automáticamente tu almacén en una base de datos privada de CloudKit, y luego llamar a share(...) para crear un CKShare para los objetos que quieres compartir.

Se cuelga. No de inmediato; eso es lo cruel. Funciona en la demo y muere en el caso real, el desordenado, cuando tu CKShare personalizado aterriza en la misma base de datos privada que el framework está replicando. Del comentario que ahora ocupa el lugar donde antes estaba NSPCKC:

/// Apple's `NSPersistentCloudKitContainer` is gone. It crashed when our custom `CKShare`
/// landed in the same private DB it was mirroring (it tries to ingest the share as a
/// `cloudkit.share` Core Data entity that doesn't exist in the model). Replacing it with
/// our own bare-CloudKit pipeline removes the framework collision and gives us one path
/// for all sync, single-user and household.

El framework ve un registro de tipo cloudkit.share en la base de datos que está replicando e intenta ingerirlo como si fuera una de tus entidades de Core Data. No existe ninguna entidad cloudkit.share en tu modelo, porque es un tipo de sistema de CloudKit. Y revienta.

Y el problema de fondo no era este fallo en concreto: era que no tenía forma de arreglarlo. Del documento con el plan de reescritura:

Tres días probando NSPersistentCloudKitContainer + CKShare para compartir en el hogar han producido un patrón recurrente: cada arreglo destapa un nuevo modo de fallo (bloqueos por zona no encontrada, bucles de «Can't find metadata», duplicados por la secuencia temporal de las cascadas, reinicios de CKKS con estado parcial que no se pueden recuperar). La replicación automática del framework es opaca: cuando falla no tenemos ninguna API para inspeccionarla ni corregirla; lo único que funciona es «purgarlo todo y empezar de cero».

Esa es la verdadera acusación. Cuando lo automático funciona, es magia. Cuando no, no hay ninguna rendija por la que meter las manos. Para una función en la que «empezar de cero» significa «borrar el presupuesto compartido de una familia», eso la descalifica. El resumen que hace el documento de diseño del intercambio que elegí: «cambiar la complejidad de pelear con el framework por la complejidad del control explícito». Elegiré el control explícito siempre que se trate del dinero de mis usuarios.

El reemplazo: dos modos, un motor

Reemplacé NSPCKC por una canalización de CloudKit a pelo: el envío, la descarga, la resolución de conflictos y la traducción de registros son míos. La sincronización personal de una sola cuenta y el uso compartido en el hogar pasan por el mismo motor; solo se diferencian en la zona y la base de datos de CloudKit a las que apuntan. El núcleo es un actor:

/// Custom CloudKit sync layer for ALL of the user's shareable data.
/// **Two modes, one engine:**
/// - **Personal mode** (default): syncs `Shared.sqlite` to a `personal-zone` in the
///   user's private CloudKit DB. No `CKShare`.
/// - **Household mode**: syncs to a `household-shared` zone in the owner's private DB,
///   with a `CKShare` attached.
/// **Conflict resolution:** last-writer-wins by `lastModified`.
actor HouseholdSync {
    static let shared = HouseholdSync()

Todo el truco de «sin servidor central» está en cómo un participante localiza los datos. Hay cuatro modos, y cada uno elige su zona y su base de datos:

enum Mode { case off; case personal; case householdOwner; case householdParticipant }

private var currentZoneID: CKRecordZone.ID? {
    switch currentMode {
    case .personal:
        return CKRecordZone.ID(zoneName: Self.personalZoneName, ownerName: CKCurrentUserDefaultName)
    case .householdOwner:
        return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: CKCurrentUserDefaultName)
    case .householdParticipant:
        let ownerName = UserDefaults.standard.string(forKey: Self.householdOwnerCKUserIDKey) ?? CKCurrentUserDefaultName
        return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: ownerName)
    default: return nil
    }
}
private var currentDatabase: CKDatabase? {
    switch currentMode {
    case .personal, .householdOwner: return container.privateCloudDatabase
    case .householdParticipant:      return container.sharedCloudDatabase
    default: return nil
    }
}

El propietario escribe en una zona household-shared de su propia base de datos privada. El participante lee y escribe en esa misma zona a través de su base de datos compartida, reconstruyendo el (zoneName, ownerName) del propietario a partir del ID de usuario de CloudKit del propietario, que se guardó al aceptar la invitación. Dos bases de datos privadas, unidas por un único share. Los servidores de Apple hacen la sincronización; ningún servidor de nadie hace la lógica.

Cuatro piezas alimentan ese motor.

LocalChangeWatcher: convertir guardados en operaciones de envío

Un observador de NSManagedObjectContextDidSave. Cada vez que se guarda el almacén compartido, construye operaciones de envío para los objetos insertados, actualizados o eliminados y lanza un envío. La parte sutil, la que sostiene todo, es lo que omite:

private func handle(_ notification: Notification) {
    guard SyncStatus.shared.isActive else { return }
    guard let sharedStore = controller.sharedStore else { return }
    // Skip saves emitted by HouseholdSync itself (pull's apply + push's metadata writeback)
    if let savingContext = notification.object as? NSManagedObjectContext,
       Self.skipContexts.contains(ObjectIdentifier(savingContext)) {
        return
    }
    for object in inserted.union(updated) {
        guard object.objectID.persistentStore == sharedStore else { continue }
        guard RecordTranslator.shareableEntityNames.contains(object.entity.name ?? "") else { continue }
        ops.append(.init(cloudKitRecordName: recordName, entityName: entityName,
                         kind: .upsert, objectIDURI: object.objectID.uriRepresentation().absoluteString))
    }
    Task {
        await PushQueue.shared.enqueue(ops)
        await HouseholdSync.shared.pushPendingChanges()
    }
}

Ese conjunto skipContexts usa ObjectIdentifier como clave, a propósito: leer context.transactionAuthor desde otra cola puede bloquear Core Data. Sin esta omisión, aplicar un cambio descargado provocaría a su vez un guardado, que lo volvería a enviar, que la otra persona descargaría y volvería a guardar… lo que nos lleva al mejor bug del proyecto (más abajo).

PushQueue: una cola FIFO duradera que se fusiona sola

La red se cae. Las apps se cierran a la fuerza. Así que la cola de envíos pendientes es un actor respaldado por un archivo que se escribe en disco de forma atómica con cada cambio, y elimina duplicados al entrar:

func enqueue(_ ops: [Operation]) async {
    for op in ops {
        // URI-based dedupe FIRST so a stale optimistic-UUID op for the same
        // managed object gets dropped.
        if let uri = op.objectIDURI {
            queue.removeAll { $0.objectIDURI == uri || $0.cloudKitRecordName == op.cloudKitRecordName }
        } else {
            queue.removeAll { $0.cloudKitRecordName == op.cloudKitRecordName }
        }
        queue.append(op)
    }
    await persist()
}

El contenido no se serializa: el envío vuelve a leer el estado actual del objeto gestionado en el momento de enviar. Así que si editas una transacción cinco veces en dos segundos, todo se reduce gratis a un único envío del estado final.

RecordTranslator: Core Data ⇄ CKRecord, en ambas direcciones

Funciones puras, sin E/S, que se ejecutan dentro del context.perform del llamador. Cada objeto compartible lleva un cloudKitRecordName estable para que ambos dispositivos acuerden una única identidad por fila lógica. Hacia fuera, solo codifica como referencias las relaciones a uno, y omite las que apuntan a un destino que aún no se ha enviado:

for (name, rel) in object.entity.relationshipsByName where !rel.isToMany {
    guard let target = object.value(forKey: name) as? NSManagedObject else { record[name] = nil; continue }
    guard let targetName = target.value(forKey: "cloudKitRecordName") as? String, !targetName.isEmpty else {
        continue   // target not pushed yet — next cycle fixes it
    }
    let targetID = CKRecord.ID(recordName: targetName, zoneID: zoneID)
    record[name] = CKRecord.Reference(recordID: targetID, action: .none)
}

Hacia dentro, hay una comprobación que parece un detalle menor y en realidad lo es todo:

let target = resolveReference(ref, destinationEntity: rel.destinationEntity!.name!, in: context)
if target != nil {
    object.setValue(target, forKey: name)
}
// else: target not pulled to this context yet — leave as-is. Critically we do NOT
// overwrite to nil and create orphan rows that dedupBudgets would group under a
// single nil-category bucket, summing 9 different-category Budgets into one row.

Si una transacción llega antes que la categoría a la que apunta, no debes dejar la relación en nil. Si lo haces, creas huérfanos, que el paso de deduplicación (el siguiente) fusionará después tan contento. Así es exactamente como una vez produje una sola fila de presupuesto de 6,800 AED a partir de nueve categorías que no tenían nada que ver.

Las partes difíciles

HouseholdDedup: converger sin coordinador

Cuando dos teléfonos están sin conexión y los dos crean una categoría «Supermercado», al sincronizarse aparecen duplicados. No hay servidor que arbitre. La solución es la convergencia: ambos dispositivos ejecutan el mismo paso determinista sobre los mismos datos y producen el mismo conjunto de eliminaciones, que después se propaga por la canalización de envío normal. Sin coordinación.

/// **Determinism:** both devices run dedup independently. As long as both pick the same
/// canonical for each group (we use lexicographically-smallest `cloudKitRecordName`,
/// preferring `isSystem` rows for Categories), they produce the same delete set →
/// converges to the same final state under LWW.

Antes de eliminar un duplicado, redirige todas las relaciones al superviviente y actualiza lastModified para que la fusión gane por «el último en escribir gana» en el dispositivo de la otra persona:

let canonical = pickCanonicalCategory(from: dupes)
for dupe in dupes where dupe.objectID != canonical.objectID {
    if let txns = dupe.transactions as? Set<Transaction> {
        for txn in txns { txn.category = canonical; txn.lastModified = now }
    }
    // budgets, splits, aliases, recurringSeries likewise…
    context.delete(dupe)
}

El incidente de los 6,800 AED me enseñó a ser paranoico con la clave de agrupación. Una versión temprana agrupaba los presupuestos sin categoría bajo la cadena literal "nil" y fusionaba nueve categorías distintas en una fila gigante. Ahora omite por completo los presupuestos sin categoría, y el traductor (arriba) se niega a crear esos nil desde el principio. Dos protecciones independientes para un solo bug, porque ese bug aparece como un número equivocado en el presupuesto de alguien, y no hay peor bug en una app de finanzas que un número equivocado presentado con total seguridad.

HouseholdShareAcceptance: el gancho de invitación que SwiftUI te esconde

Cuando la otra persona toca el enlace para compartir, iOS tiene que entregarle a tu app el CKShare.Metadata. Eso ocurre a través de un callback de UIWindowSceneDelegate, que una app pura de SwiftUI no tiene. Así que proporcionas uno mediante UIApplicationDelegateAdaptor:

func windowScene(
    _ windowScene: UIWindowScene,
    userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
    let metadata = cloudKitShareMetadata
    Task {
        do { try await HouseholdSync.shared.acceptShare(metadata: metadata) }
        catch { /* … */ }
    }
}

El mismo delegado gestiona los despertares por notificaciones push silenciosas: un CKNotification validado lanza una descarga + un envío y (un detalle que te ahorra horas) siempre informa .newData al final. Si informas .failed, iOS empieza a limitar tus notificaciones push en segundo plano, y de repente la sincronización «en tiempo real» llega con diez minutos de retraso.

HouseholdIdentity: saber quién eres, de forma síncrona

La mitad de la lógica necesita responder «¿cuáles de estos registros son míos?» sin ir a la red. Así que el ID de usuario de CloudKit del usuario actual se resuelve una vez y se guarda en caché en UserDefaults:

func refresh() async {
    do {
        let recordID = try await CKContainer(identifier: PersistenceController.cloudKitContainerID).userRecordID()
        currentUserID = recordID.recordName
        await runClaimSweepIfNeeded()
    } catch {
        Self.logger.info("Could not resolve CloudKit user ID (likely no iCloud account)…")
    }
}

Ese ID en caché se estampa en los registros como ownerCloudKitUserID y loggedByMemberID, y así es como «Salir del hogar» sabe qué filas conserva cada persona, y como el informe de gastos por miembro atribuye un café a la persona correcta de la pareja.

Crear y aceptar el share

Del lado del propietario: crear la zona, luego crear un solo CKShare sobre toda la zona (no uno por registro), cambiar tu rol, reiniciar el token de cambios, volver a encolar todo para que se vacíe en la nueva zona y eliminar la zona personal, que ya ha quedado obsoleta:

_ = try await privateDB.save(CKRecordZone(zoneID: householdZoneID))
let share = CKShare(recordZoneID: householdZoneID)
share[CKShare.SystemFieldKey.title] = "The Smart Budget — Household" as CKRecordValue
share.publicPermission = .none
_ = try await privateDB.save(share)

setRole(.owner)
UserDefaults.standard.removeObject(forKey: Self.serverChangeTokenKey)
await PushQueue.shared.clear()
await reenqueueAllLocalRecords(controller: controller)
await deletePersonalZoneServerSide()

Ese CKShare a pelo se presenta con el UICloudSharingController nativo de Apple: no necesitas NSPCKC para mostrar la interfaz de invitación del sistema, que es justo lo que todo el mundo cree que te ata al framework. Del lado del participante, aceptar y guardar el ID del propietario:

_ = try await container.accept(metadata)
// Owner's CKUserID is encoded in the share's zoneID — persist it so currentZoneID
// can construct the right (zoneName, ownerName) tuple on subsequent pushes/pulls.
let ownerName = metadata.share.recordID.zoneID.ownerName
UserDefaults.standard.set(ownerName, forKey: Self.householdOwnerCKUserIDKey)
setRole(.participant)

La resolución de conflictos en todo el sistema es «el último en escribir gana», según una marca de tiempo lastModified: lo local gana solo si es estrictamente más reciente; si no, se aplica lo remoto. Sencillo, y converge.

Dos batallas que el código todavía recuerda

La tormenta descontrolada de envíos a iCloud (corregida el 29 de junio de 2026). El conjunto skipContexts (de LocalChangeWatcher) se vaciaba en la cola equivocada respecto al bloque del observador. El vaciado perdía la carrera una y otra vez, así que cada escritura de metadatos se escapaba como un envío nuevo, que la otra persona descargaba y devolvía: un bucle de sincronización infinito machacando iCloud. El arreglo fue una sola línea sobre cuándo se vacía el conjunto de omisiones. Los bucles de retroalimentación en el código de sincronización no se anuncian; simplemente se comen en silencio tu presupuesto de uso.

La protección contra descargas obsoletas. Una descarga guarda una instantánea del modo y la zona actuales antes de empezar. Si acceptShare o disconnect cambian el modo a mitad de camino, los resultados de la descarga se descartan en lugar de malinterpretarse, porque un error de «zona inexistente» durante una descarga que empezaste en el modo anterior no es lo mismo que «tu pareja dejó de compartir», y tratarlo como esto último una vez llevó los datos de un cónyuge a la zona equivocada.

¿Lo extraería como SDK?

Aquí dentro se esconde un paquete CloudKitHouseholdSync limpio: HouseholdSync, PushQueue, RecordTranslator, LocalChangeWatcher, la deduplicación y el gancho de escena. La gente no deja de preguntar. Mi respuesta honesta: todavía no. Tiene supuestos propios de Smart Budget incorporados (la clave por nombre en minúsculas de la deduplicación, el orden de dependencias entre entidades) que tendría que generalizar con cuidado, y no quiero publicar una biblioteca de sincronización hasta que esté probada con hogares reales en producción. El plan de extracción está por escrito. Saldrá cuando se lo haya ganado.

La conclusión no es «nunca uses NSPersistentCloudKitContainer»: para una app de un solo usuario que solo quiere copia de seguridad en iCloud, es estupendo, y deberías usarlo. Es esta: en cuanto mezclas un CKShare personalizado en una base de datos privada replicada por Core Data, has salido del camino asfaltado, y el framework no te da herramientas para sobrevivir fuera de él. Ser dueño de la canalización significa más código. También es la única versión en la que, cuando algo falla con el dinero compartido de una familia, tengo una rendija por la que meter la mano y arreglarlo, en lugar de «purgarlo todo y empezar de cero».

The Smart Budget ya está disponible para iPhone. Compartir en el hogar es una función de Pro; el motor de sincronización personal que hay debajo es el mismo código, funcionando para todos. Nada pasa por un servidor bajo mi control.

— Omar

Escrito por Omar Al Homaidi · The Smart Budget · @zaatarlabs