La pesadilla de CloudKit: el diario de LiftCoach se quedó en junio
ContenidoContents
Quería empezar a probar la sincronización por CloudKit.
LiftCoach es la app que estoy desarrollando. Sirve para apuntar entrenos, rutinas y nutrición, en el iPhone, el iPad y el Apple Watch. Todavía no está en el App Store. La llevo en TestFlight y la uso de verdad mientras la hago: el diario es el mío, y los fallos también.
El iPad no mostraba la nutrición ni el historial de entrenos. En el iPhone estaba todo, y es ahí donde apunto el día a día. LiftCoach sincroniza con iCloud. Si un aparato no enseña el diario, lo normal es pensar que aún no ha terminado de bajar. Esta vez no estaba bajando. No había nada que bajar.
Cómo entré en el iPad
Lo enchufé por USB, lo desbloqueé y acepté el ordenador. Para leer los archivos de una app hace falta el modo de desarrollador, en Ajustes. En el iPad ya estaba. En el iPhone tuve que activarlo antes de poder hacer lo mismo.
Desde el Mac, devicectl lista el aparato y copia un archivo sin instalar nada encima:
1xcrun devicectl device copy from \
2 --device <id-del-ipad> \
3 --domain-type appGroupDataContainer \
4 --domain-identifier group.com.sergiocomeron.LiftApp \
5 --source "Library/Application Support/default.store" \
6 --destination ./default.store
El primer sitio donde miré estaba vacío. El contenedor de la app, en Application Support, no tenía base de datos. SwiftData, con el grupo por defecto, no guarda ahí. Guarda en el grupo de la app, group.com.sergiocomeron.LiftApp, en Library/Application Support/default.store. Al lado van default.store-wal y default.store-shm. Si te llevas solo el archivo grande, te dejas fuera lo que aún no se ha volcado. Las tres piezas, o la lectura miente.
Con eso, sqlite3 sobre la copia. No hace falta abrir la app ni adivinar la pantalla.
Las tablas del diario salen del modelo: ZFOODLOG, ZWORKOUT, ZWATERLOG, ZCAFFEINELOG, ZRECIPELOG. Un COUNT(*) ya decía que el iPad no tenía el diario. Tenía un puñado de filas de primavera y principios de junio.
Lo de CloudKit está en tablas que pone el contenedor, no el modelo:
ANSCKEVENTes el diario de sincronización. El tipo 0 es el arranque, el 1 una bajada, el 2 una subida.ZSUCCEEDED,ZERRORDOMAINyZERRORCODEdicen si entró. Las fechas son de Core Data, segundos desde el 1 de enero de 2001. Para verlas en hora local, súmale978307200y pásalas pordatetime(..., 'unixepoch', 'localtime').ANSCKRECORDMETADATAes la cola.ZNEEDSUPLOADes lo que falta por subir.ZNEEDSCLOUDDELETEes lo que falta por borrar en iCloud.ANSCKRECORDZONEMETADATAdice si la zona se cree al día:ZNEEDSIMPORTy si hay token de cambios.
La consulta que más usé fue la de la última subida:
1SELECT Z_PK, ZSUCCEEDED, ZERRORDOMAIN, ZERRORCODE,
2 datetime(ZENDEDAT + 978307200, 'unixepoch', 'localtime')
3FROM ANSCKEVENT
4WHERE ZCLOUDKITEVENTTYPE = 2
5ORDER BY Z_PK;
Ahí se veía el corte. Subidas que terminaban bien hasta el 18 de junio, y a partir de entonces una detrás de otra con CKErrorDomain y código 2, en un segundo, con cero registros guardados.
Los logs del propio iPad no pude sacarlos. log collect contra el dispositivo pide root, y el log del shell del Mac es otra orden. El otro lado lo leí en el panel de CloudKit, entorno de producción, filtrando la hora de aquellas subidas. La misma operación, vista desde el servidor.
Luego repetí la copia en el iPhone. Mismas consultas, otros números: el diario entero en local, y la misma cola atascada.
iCloud se había parado el 18 de junio
El teléfono tenía el diario entero en local. La copia de iCloud, no. La última subida que había entrado de verdad era del 18 de junio, a las 12:23. A partir de ese minuto, cada intento duraba alrededor de un segundo, devolvía error y no guardaba nada.
Detrás de esa hora se había quedado el resto: comidas, entrenos, agua, cafeína, rutinas. Meses de uso, solo en el iPhone. El iPad, cuando miraba a iCloud, recibía el trozo de junio y lo daba por bueno.
Un paquete, o ninguno
LiftCoach guarda el diario con SwiftData y lo sincroniza con CloudKit, en la base privada. El contenedor no manda las filas de una en una. Las junta en un lote, y la zona lo acepta entero o no lo acepta. Si un registro del lote es inválido, no entra ninguno.
Por eso el fallo se veía vacío. Cero registros guardados, el diario intacto en el teléfono, y al rato otro intento igual. Abrir la app no desatascaba nada: volvía a enviar el mismo lote y volvía a recibir el mismo rechazo.
En el panel de CloudKit el estado era USER_ERROR y el error, BAD_REQUEST. El tipo de registro no salía. En la base privada el contenido va cifrado, y el log devuelve el sobre, __pcs_data, no el nombre del modelo. Sabía que el servidor había rechazado la petición. No sabía cuál de los miles de registros la había roto.
La documentación del despliegue de esquema lo dice con una frase que aquí importa: copiar el esquema a producción copia tipos, campos e índices, no los registros. El camino de vuelta, del teléfono a producción, es otro. Y estaba cerrado.
Primero el log decía «borrar»
El primer intento que conseguí leer no era un guardado. La operación era RecordDelete.
En la cola local había borrados de filas que en el teléfono ya no existían. Casi ninguna se había subido jamás. iCloud rechazaba ese borrado. Como el lote es de todo o nada, detrás no subía ni una comida.
Esos borrados no habían llegado a iCloud, así que el rechazo no había borrado nada allí. El diario del teléfono seguía entero. Con la app cerrada, y una copia del almacén aparte, quité de la cola las marcas de borrado. No toqué las comidas ni los entrenos.
Al abrir otra vez, la operación ya no era un borrado. Era un RecordSave. También la rechazó, en el mismo segundo, con el mismo BAD_REQUEST.
Producción no conocía el modelo de ahora
Desde junio el modelo había crecido. Historial de recetas. Una comida marcada como previa al entreno, durante o después. El código de barras de un alimento propio. Una serie con hora de completado, descanso y duración.
En el entorno de desarrollo, CloudKit crea el tipo y el campo cuando guardas un registro que los trae. En producción no. Producción solo cambia cuando despliegas el esquema, y ese paso no mueve datos ni borra los que ya hay.
El primer despliegue añadió tipos que faltaban. El guardado del iPhone siguió fallando. Las pruebas no rellenaban los campos opcionales, y CloudKit no añade un campo que el registro no envía. El lote real sí los llevaba. Con uno desconocido basta: la zona entera rebota.
Preparé otra tanda de registros de prueba, en un almacén temporal de una build de desarrollo, no en el diario. Esta vez el contexto del entreno, el código de barras y la serie completada iban con valor. El diálogo de despliegue ya no salía vacío. Desplegué y abrí el iPhone.
En torno a un minuto la cola de subida pasó a cero. El recuento de comidas y de entrenos no se movió. Lo que estaba en el teléfono pasó a estar en iCloud. Nada de lo que ya estaba se perdió por el camino.
El iPad creía que junio era todo
Cuando el iPad sincronizó por primera vez, iCloud solo podía entregarle el trozo de junio. Un entreno terminado llegaba antes que sus series. Sin series, la app lo leía como un entreno abandonado y lo dejaba marcado para borrar. Esos borrados tampoco salieron, por el mismo atasco. El daño fue local: la copia del iPad se dio por al día. Para ella, aquella zona corta era la zona completa.
Actualizar la app encima de esa copia no servía. Iba a seguir creyendo que no le faltaba nada, y además tenía cambios propios esperando a subir. Borré LiftCoach solo en el iPad e instalé de nuevo la build de TestFlight que ya usaba el teléfono. Una instalación nueva no hereda esa copia. Bajó la zona entera.
Ahí aparecieron la nutrición y el historial. El entreno del sábado llegó con las series hechas. En el iPhone no desinstalé nada: era la única copia completa hasta que la subida terminó.
Lo que miro a partir de ahora
Una build de TestFlight habla con el CloudKit de producción. Una build de desarrollo habla con el de desarrollo. Guardar un registro de prueba en el segundo no arregla el primero hasta que no pulsas Deploy Schema Changes. Y ese botón, si el diálogo sale a cero, no está haciendo nada: desarrollo y producción ya coinciden, y el campo que te falta no ha llegado a ninguno de los dos.
A partir de ahora, si el modelo crece, el orden es este. Un registro de prueba con el campo relleno, mirar que el despliegue lista tipos o campos, desplegar, y solo entonces abrir el aparato que tiene el diario. Un segundo dispositivo que haya sincronizado mientras el primero estaba atascado no está esperando. Está convencido de que ya lo tiene todo.
Este ha sido mi domingo. Para no aburrirnos.