Sincronizar SwiftData con un servidor propio usando HistoryObserver
Arturo Rivas Arias
🔄 Sincronizar una base de datos local con un servidor parece sencillo hasta que la aplicación tiene que funcionar sin conexión. Mientras no hay red, el usuario puede crear, editar y borrar datos. Cuando la conexión vuelve, necesitamos saber exactamente qué ha cambiado para enviar solo esas operaciones. Consultar toda la base de datos cada vez puede servir en una prueba sencilla, pero termina desperdiciando tiempo, batería y datos móviles.
📚 SwiftData cuenta con un histórico desde iOS 18. Cada vez que un ModelContext guarda cambios, el almacén registra una transacción con sus inserciones, actualizaciones y eliminaciones. El problema era saber cuándo convenía consultar ese historial. En iOS 27, HistoryObserver cubre justo esa parte: avisa cuando aparecen transacciones nuevas que cumplen los filtros que hemos indicado.
⚠️ Conviene dejar clara una diferencia desde el principio. HistoryObserver no sincroniza nada por sí solo. Su propiedad observable eventCounter aumenta cuando detecta cambios relevantes, pero seguimos siendo responsables de consultar el historial con fetchHistory(_:), preparar la petición de red y guardar el token de la última transacción confirmada. El observador da la señal; nuestra capa de sincronización hace el trabajo.
🧪 A fecha de publicación, HistoryObserver forma parte de las API beta de las versiones 27 de los sistemas de Apple. Los ejemplos necesitan el SDK correspondiente y pueden requerir pequeños ajustes cuando llegue la versión definitiva. Aun así, su funcionamiento se apoya en conceptos que ya existen en SwiftData desde iOS 18: transacciones, cambios, autores y tokens de historial.
🌿 Para el ejemplo utilizaremos el inventario de un jardín botánico. Cada planta tendrá un UUID compartido con el servidor. No debemos utilizar PersistentIdentifier para identificarla fuera del dispositivo, porque ese valor pertenece al almacén local de SwiftData y no está pensado como identificador público de una API.
import Foundation
import SwiftData
@Model
final class PlantSpecimen {
// Identificador estable compartido con el servidor.
@Attribute(.unique, .preserveValueOnDeletion)
var syncID: UUID
var commonName: String
var greenhouse: String
var lastWateredAt: Date?
var modifiedAt: Date
init(
syncID: UUID = UUID(),
commonName: String,
greenhouse: String,
lastWateredAt: Date? = nil,
modifiedAt: Date = .now
) {
self.syncID = syncID
self.commonName = commonName
self.greenhouse = greenhouse
self.lastWateredAt = lastWateredAt
self.modifiedAt = modifiedAt
}
}
🪦 La opción .preserveValueOnDeletion es esencial para sincronizar borrados. Cuando una planta desaparece del almacén ya no podemos volver a consultarla, pero SwiftData conserva los atributos que hayamos marcado dentro del registro de eliminación. En este caso solo necesitamos syncID, suficiente para indicar al servidor qué planta debe borrar.
🔐 No conviene preservar más información de la necesaria. Estos valores permanecen en el histórico incluso después de eliminar el modelo, así que guardar nombres, notas privadas o cualquier dato sensible haría que siguieran almacenados más tiempo sin aportar nada a la sincronización.
🏷️ El siguiente paso consiste en marcar el origen de cada escritura. Los cambios realizados por el usuario tendrán el autor app; los que descarguemos del servidor, el autor server. Así evitamos que una actualización remota aplicada en SwiftData vuelva a enviarse al servidor como si fuera una edición nueva.
enum TransactionAuthor {
static let app = "app"
static let server = "server"
}
// Este es el contexto que SwiftUI recibe normalmente desde el entorno.
container.mainContext.author = TransactionAuthor.app
// Las importaciones utilizan otro contexto y otro autor.
func makeImportContext(container: ModelContainer) -> ModelContext {
let context = ModelContext(container)
context.author = TransactionAuthor.server
return context
}
🚨 Crear un contexto nuevo con el autor correcto no sirve si las vistas siguen escribiendo mediante container.mainContext. Hay que configurar el contexto que realmente guarda cada cambio. Este detalle es fácil de pasar por alto y provoca que el observador no encuentre las transacciones esperadas.
👀 Para mantener la sincronización fuera de la interfaz utilizaremos un actor global. El gestor conserva tanto el HistoryObserver como el token devuelto por withContinuousObservation. Si no guardamos ese segundo token, la observación termina y dejamos de recibir avisos.
import Observation
import SwiftData
@globalActor
actor PlantSyncActor {
static let shared = PlantSyncActor()
}
@PlantSyncActor
final class PlantSyncEngine {
private let container: ModelContainer
private let backend: any PlantBackend
private let scope: SyncScope
private var historyObserver: HistoryObserver?
private var observationToken: ObservationTracking.Token?
private var isSyncing = false
private var needsAnotherPass = false
init(
container: ModelContainer,
backend: any PlantBackend,
scope: SyncScope
) {
self.container = container
self.backend = backend
self.scope = scope
}
func start() throws {
let observer = try HistoryObserver(
observedModels: [PlantSpecimen.self],
authors: [TransactionAuthor.app],
modelContainer: container,
isolation: PlantSyncActor.shared
)
historyObserver = observer
observationToken = withContinuousObservation(options: [.didSet]) {
[weak self, observer] _ in
// Este acceso registra la propiedad que queremos observar.
_ = observer.eventCounter
Task { @PlantSyncActor [weak self] in
await self?.scheduleSync()
}
}
// También procesa los cambios pendientes de una ejecución anterior.
Task { @PlantSyncActor [weak self] in
await self?.scheduleSync()
}
}
private func scheduleSync() async {
if isSyncing {
needsAnotherPass = true
return
}
isSyncing = true
defer { isSyncing = false }
repeat {
needsAnotherPass = false
do {
try await uploadPendingHistory()
} catch {
// La aplicación registrará el error y programará otro intento.
break
}
} while needsAnotherPass
}
}
🧵 El actor evita que dos avisos cercanos lancen dos sincronizaciones sobre el mismo token. eventCounter no es un número de transacción y tampoco significa que haya llegado un único cambio. Solo indica que puede existir trabajo pendiente. Por eso el gestor vuelve siempre al historial, que es la fuente real de información.
🚀 start() inicia además una primera pasada sin esperar al siguiente aviso. Imagina que una petición falló, el usuario cerró la aplicación y la abre horas después: las transacciones siguen guardadas, pero quizá no se produzca ningún cambio nuevo que haga aumentar el contador. La comprobación inicial recupera ese trabajo pendiente.
🔖 Cada transacción incluye un DefaultHistoryToken. Funciona como un marcador: guardamos el último token procesado y, en la siguiente pasada, pedimos únicamente las transacciones posteriores. El ejemplo utiliza DefaultHistoryToken porque trabaja con el almacén predeterminado de SwiftData; un almacén personalizado puede proporcionar su propio tipo de token.
struct SyncScope: Sendable {
let accountID: String
let storeName: String
var tokenKey: String {
"plant-sync.\(accountID).\(storeName).history-token"
}
}
private enum TokenStore {
static func load(for scope: SyncScope) -> DefaultHistoryToken? {
guard let data = UserDefaults.standard.data(
forKey: scope.tokenKey
) else {
return nil
}
return try? JSONDecoder().decode(
DefaultHistoryToken.self,
from: data
)
}
static func save(
_ token: DefaultHistoryToken,
for scope: SyncScope
) throws {
let data = try JSONEncoder().encode(token)
UserDefaults.standard.set(data, forKey: scope.tokenKey)
}
static func reset(for scope: SyncScope) {
UserDefaults.standard.removeObject(forKey: scope.tokenKey)
}
}
👤 El token se guarda por cuenta y por almacén. Una única clave global puede dar problemas al cerrar sesión, cambiar de usuario o abrir otra configuración de SwiftData. Los tokens solo son válidos para el almacén al que pertenecen.
🔍 Al consultar el historial hay que filtrar siempre por autor, también durante la primera sincronización. Si solo añadimos el filtro cuando ya existe un token, la primera consulta recuperará además los cambios importados desde el servidor y volveremos a enviarlos.
func pendingTransactions(
in context: ModelContext,
after token: DefaultHistoryToken?
) throws -> [DefaultHistoryTransaction] {
let author = TransactionAuthor.app
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let token {
descriptor.predicate = #Predicate { transaction in
transaction.token > token && transaction.author == author
}
} else {
descriptor.predicate = #Predicate { transaction in
transaction.author == author
}
}
return try context.fetchHistory(descriptor)
}
📦 Las transacciones contienen cambios ordenados cronológicamente. En las inserciones y actualizaciones podemos usar changedPersistentIdentifier para consultar el estado actual del modelo. Si una planta se ha editado cinco veces antes de recuperar la conexión, no hace falta enviar las cinco versiones: para una sincronización basada en estado normalmente basta con conservar la más reciente.
struct PlantRecord: Codable, Sendable {
let id: UUID
let name: String
let greenhouse: String
let lastWateredAt: Date?
let modifiedAt: Date
}
struct PlantSyncBatch: Codable, Sendable {
var records: [PlantRecord]
var deletedIDs: [UUID]
var isEmpty: Bool {
records.isEmpty && deletedIDs.isEmpty
}
}
enum PlantSyncError: Error {
case missingDeletionIdentifier
}
func makeBatch(
from transactions: [DefaultHistoryTransaction],
context: ModelContext
) throws -> PlantSyncBatch {
var recordsByID: [UUID: PlantRecord] = [:]
var deletedIDs: Set<UUID> = []
for transaction in transactions {
for change in transaction.changes {
switch change {
case .insert(_ as DefaultHistoryInsert<PlantSpecimen>),
.update(_ as DefaultHistoryUpdate<PlantSpecimen>):
let persistentID = change.changedPersistentIdentifier
let descriptor = FetchDescriptor<PlantSpecimen>(
predicate: #Predicate { plant in
plant.persistentModelID == persistentID
}
)
guard let plant = try context.fetch(descriptor).first else {
continue
}
deletedIDs.remove(plant.syncID)
recordsByID[plant.syncID] = PlantRecord(
id: plant.syncID,
name: plant.commonName,
greenhouse: plant.greenhouse,
lastWateredAt: plant.lastWateredAt,
modifiedAt: plant.modifiedAt
)
case .delete(
let deletion as DefaultHistoryDelete<PlantSpecimen>
):
guard let id = deletion.tombstone[\.syncID] as? UUID else {
throw PlantSyncError.missingDeletionIdentifier
}
recordsByID[id] = nil
deletedIDs.insert(id)
default:
break
}
}
}
return PlantSyncBatch(
records: Array(recordsByID.values),
deletedIDs: Array(deletedIDs)
)
}
🧩 Los objetos @Model no deben viajar directamente por la capa de red. Están ligados a su ModelContext y su forma responde al almacenamiento local, no necesariamente al contrato del servidor. PlantRecord actúa como objeto de transferencia de datos, o DTO, y mantiene separadas ambas responsabilidades.
🗑️ En una eliminación no queda un modelo que podamos consultar. El valor conservado tras el borrado se obtiene mediante el tombstone de SwiftData, que podríamos describir en español como el registro residual de la eliminación. Su subíndice devuelve (any Sendable)?, por lo que necesitamos convertir el resultado expresamente a UUID. Si ese identificador falta, no debemos avanzar el token: hacerlo perdería el borrado para siempre.
🌐 Al enviar el lote aparece la regla más importante de todo el flujo: el token solo avanza después de que el servidor haya confirmado la operación. Si lo guardamos antes y la petición falla, perderemos cambios. Si la aplicación se cierra justo después de recibir la respuesta pero antes de guardar el token, repetirá el mismo lote al abrirse de nuevo.
import CryptoKit
protocol PlantBackend: Sendable {
func push(
_ batch: PlantSyncBatch,
idempotencyKey: String
) async throws
}
private func idempotencyKey(
for token: DefaultHistoryToken
) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(token)
let digest = SHA256.hash(data: data)
return digest.map {
String(format: "%02x", $0)
}.joined()
}
extension PlantSyncEngine {
private func uploadPendingHistory() async throws {
let context = ModelContext(container)
let previousToken = TokenStore.load(for: scope)
let transactions = try pendingTransactions(
in: context,
after: previousToken
)
guard let newestToken = transactions.last?.token else {
return
}
let batch = try makeBatch(
from: transactions,
context: context
)
if !batch.isEmpty {
try await backend.push(
batch,
idempotencyKey: try idempotencyKey(for: newestToken)
)
}
try TokenStore.save(newestToken, for: scope)
}
}
🔁 La clave de idempotencia permite que el servidor reconozca un reintento y no aplique dos veces el mismo lote. Aquí la obtenemos a partir del último token. En un sistema más complejo suele ser preferible una cola de salida persistente, donde cada lote conserva su propio UUID hasta recibir la confirmación del servidor.
⬇️ Todo lo anterior cubre el camino del dispositivo al servidor. La descarga necesita otro marcador, emitido esta vez por el servicio remoto. La aplicación solicita los cambios posteriores a ese marcador, los guarda con un contexto cuyo autor sea server y solo entonces confirma la nueva posición. Mezclar el token local de SwiftData con el marcador remoto complica mucho la recuperación ante errores.
⚖️ HistoryObserver tampoco resuelve los conflictos. Si dos dispositivos editan la misma planta sin conexión, el servidor debe decidir qué versión conserva. Se puede aceptar la última escritura, utilizar versiones con ETag, combinar campos o pedir una resolución manual. Confiar únicamente en modifiedAt es cómodo, pero el reloj del dispositivo puede estar desajustado; una versión asignada por el servidor suele ser más fiable.
🧯 Un token deja de ser válido si se elimina la parte del historial a la que apuntaba. SwiftData responde entonces con historyTokenExpired. La salida segura consiste en descartar el token y hacer una reconciliación completa con el servidor. Tratarlo como si no hubiera cambios podría dejar ambos lados en estados distintos.
📱 Tampoco podemos confiar en que el observador mantenga viva la aplicación. iOS puede suspender el proceso en cualquier momento. Además de observar el historial, una aplicación real debería sincronizar al arrancar, al volver al primer plano y cuando recupere la conexión. Los reintentos necesitan una espera con incremento exponencial para no saturar el servidor con peticiones continuas.
🧪 Las pruebas más útiles son las que fuerzan los fallos: editar sin conexión, borrar un modelo antes de sincronizar, recibir dos avisos seguidos, cerrar la aplicación después de la respuesta del servidor, cambiar de cuenta o intentar continuar desde un token caducado. Si después de todos esos casos el dispositivo y el servidor acaban con los mismos datos, la base de la sincronización es sólida.
🎯 La idea final es sencilla: HistoryObserver avisa, fetchHistory(_:) entrega los cambios, el token marca hasta dónde hemos llegado y el servidor confirma lotes que se pueden repetir sin duplicar efectos. No resuelve por sí solo todos los conflictos posibles, pero ofrece una base práctica para sincronizar SwiftData sin recorrer toda la base de datos en cada intento.