Volver al blog de desarrollo Informe de trampa

Codable hacia delante: los clientes viejos jamás deben destruir lo nuevo

SharkTTY sincroniza tus hosts SSH guardados entre dispositivos. Mientras añadíamos un nuevo tipo de conexión a la app, nos hicimos una pregunta cuya respuesta resultó incómoda: ¿qué pasa cuando un registro escrito por la app nueva llega a un dispositivo que aún ejecuta la vieja? Para un montaje Swift Codable completamente corriente, la respuesta fue: la sincronización del dispositivo viejo se congela en silencio — y si lo «arreglas» de la manera obvia, empeora: el dispositivo viejo empieza a destruir los datos del nuevo. Ninguno de los dos fallos requiere código exótico. Ambos salen directamente de los valores por defecto.

Fallo uno: el enum que rechaza el futuro

El montaje que toda base de código Swift tiene en alguna parte:

enum ConnectionMethod: String, Codable { case ssh, mosh }

struct Host: Codable {
    let id: UUID
    var name: String
    var connectionMethod: ConnectionMethod
    // …
}

// en algún lugar de la capa de sincronización:
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }

Un enum Codable con raw String es un mundo cerrado: un valor desconocido no degrada, lanza. Así que el día en que una app más nueva escribe "connectionMethod": "et", cada lector antiguo lanza en ese registro — y como la lista entera se decodifica como una unidad, un registro ilegible significa ningún registro. El try? se traga el error, la importación retorna temprano y el dispositivo se queda con su lista rancia para siempre. Sin crash, sin línea de log, sin aviso. La sincronización simplemente… deja de sincronizar, en cada dispositivo que aún no se ha actualizado — que durante cualquier despliegue real son la mayoría.

Fallo dos: el decodificador tolerante que destruye datos

El arreglo reflejo es una decodificación indulgente — valor desconocido, elige un valor por defecto:

let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh   // tolerante… y con pérdida

Esto cura la congelación y planta una mina. Los sistemas de sincronización re-codifican: la app vieja edita otro host, serializa su lista completa y la sube — last-writer-wins. Pero decodificó "et" como .ssh, así que eso es lo que reescribe. Y los campos para los que nunca tuvo propiedades — las claves extra del registro nuevo — fueron descartados por Codable al decodificar, así que tampoco van en la subida. Una edición en un dispositivo desactualizado, y la configuración del dispositivo más nuevo queda reescrita en silencio a la forma vieja, en toda la cuenta. La congelación al menos preservaba los datos. El decodificador tolerante los borra, con educación.

El contrato: el código viejo jamás debe destruir lo que escribió el nuevo

Protobuf resolvió esto hace décadas con la preservación de campos desconocidos; el Codable de Swift te da las herramientas pero no el valor por defecto. Dos movimientos lo recrean.

Almacena la verdad del cable, expón la vista tipada. Cada campo enum conserva su cadena cruda como propiedad almacenada; el enum tipado pasa a ser una vista calculada con un fallback solo para mostrar. Leer un valor desconocido degrada con gracia; reescribirlo lo reproduce exactamente. Solo una edición deliberada del usuario — el setter — lo sobrescribe:

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // fallback para MOSTRAR
    set { connectionMethodRaw = newValue.rawValue }                  // las ediciones deliberadas ganan
}

Guarda las claves que no entiendes. Una segunda pasada con un contenedor de claves de cadena recoge cada clave que no es tuya en una bolsa de JSONValue (un pequeño enum de siete casos: null, bool, int, double, string, array, object — decodifica Int64 antes que Double, o tus puertos volverán como flotantes). encode(to:) escribe tus campos conocidos y luego reproduce la bolsa tal cual:

// decodificar: recoger el futuro
let known = Set(CodingKeys.allCases.map(\.rawValue))
let any = try decoder.container(keyedBy: AnyCodingKey.self)
for key in any.allKeys where !known.contains(key.stringValue) {
    unknownFields[key.stringValue] = try any.decode(JSONValue.self, forKey: key)
}

// codificar: devolver el futuro, intacto
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
    try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}

Con ambas piezas, un cliente viejo es un conducto seguro: muestra lo que puede, hace round-trip de lo que no, y una flota de versiones mezcladas — el estado normal del mundo durante semanas alrededor de cada lanzamiento — deja de ser un peligro.

Clavar el contrato con tests

Tres tests sostienen todo el contrato. Un fixture de round-trip: un registro de una versión «futura» — valor de enum desconocido, escalar desconocido, objeto anidado desconocido — debe decodificar sin error y re-codificar con cada clave original intacta. Un test de edición deliberada: asignar la propiedad tipada debe sobrescribir el valor crudo mientras las claves desconocidas sobreviven. Y un test dorado: un registro con solo campos conocidos debe codificar byte a byte idéntico a la salida del código viejo — sin clave unknownFields, sin nombres de almacenamiento …Raw filtrándose al cable. Ese último es el que te permite refactorizar las tripas sin bifurcar el formato en silencio.

Una advertencia honesta para cerrar: nada de esto repara a los clientes que ya se publicaron sin ello. Sus decodificadores siguen rechazando el futuro. La migración es aburrida e inevitable — publica primero los decodificadores tolerantes y preservadores, deja que se desplieguen, y solo entonces permite que los valores nuevos empiecen a fluir. La evolución de esquemas es un problema de orden disfrazado de serialización.

Este endurecimiento llega a toda la capa de sincronización de SharkTTY en la próxima actualización. ¿No estás de acuerdo con el enfoque, o te quemaste de otra forma? El tablón de comentarios está abierto.