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.