SharkTTY synchronise vos hôtes SSH enregistrés entre appareils. En ajoutant un
nouveau type de connexion à l'app, nous nous sommes posé une question dont la
réponse s'est révélée inconfortable : que se passe-t-il quand un enregistrement
écrit par la nouvelle app atteint un appareil qui fait encore tourner l'ancienne ?
Pour un montage Swift Codable
parfaitement ordinaire, la réponse était : la synchronisation de l'ancien appareil
gèle en silence — et si vous « réparez » cela de la façon évidente, c'est pire :
l'ancien appareil se met à détruire les données du nouveau. Aucune
de ces deux pannes n'exige de code exotique. Les deux sortent tout droit des
comportements par défaut.
Panne un : l'enum qui refuse l'avenir
Le montage que toute base de code Swift possède quelque part :
enum ConnectionMethod: String, Codable { case ssh, mosh }
struct Host: Codable {
let id: UUID
var name: String
var connectionMethod: ConnectionMethod
// …
}
// quelque part dans la couche de synchro :
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }
Un enum Codable à raw
String est un
monde clos : une valeur inconnue ne se replie pas, elle lève une
erreur. Le jour où une app plus récente écrit
"connectionMethod": "et",
chaque ancien lecteur lève sur cet enregistrement — et comme la liste entière se
décode d'un bloc, un enregistrement illisible signifie aucun enregistrement.
Le try? avale l'erreur,
l'import retourne aussitôt, et l'appareil garde à jamais sa liste périmée. Pas de
crash, pas de ligne de log, pas de bandeau. La synchro cesse simplement… d'être une
synchro, sur chaque appareil pas encore à jour — c'est-à-dire, pendant tout
déploiement réel, la plupart d'entre eux.
Panne deux : le décodeur tolérant qui détruit les données
Le réflexe est un décodage indulgent — valeur inconnue, valeur par défaut :
let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh // tolérant… et avec perte
Cela guérit le gel et pose une mine. Les systèmes de synchro ré-encodent : la
vieille app modifie un autre hôte, sérialise sa liste complète et l'envoie
— last-writer-wins. Mais elle avait décodé
"et" en
.ssh, c'est donc cela
qu'elle réécrit. Et les champs pour lesquels elle n'a jamais eu de propriétés — les
clés supplémentaires du nouvel enregistrement — ont été jetés par
Codable au décodage :
ils ne partent pas non plus dans l'envoi. Une seule modification sur un seul
appareil en retard, et la configuration de l'appareil plus récent est réécrite en
douce dans l'ancienne forme, sur tout le compte. Le gel, au moins, préservait les
données. Le décodeur tolérant les efface, poliment.
Le contrat : le vieux code ne doit jamais détruire ce que le nouveau a écrit
Protobuf a réglé cela il y a des décennies avec la préservation des champs
inconnus ; le Codable
de Swift vous donne les outils, pas le comportement par défaut. Deux gestes le
recréent.
Stocker la vérité du câble, exposer la vue typée. Chaque champ enum garde sa chaîne brute comme propriété stockée ; l'enum typé devient une vue calculée avec un repli réservé à l'affichage. Lire une valeur inconnue dégrade avec grâce ; la réécrire la reproduit à l'identique. Seule une modification délibérée de l'utilisateur — le setter — l'écrase :
private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh } // repli pour l'AFFICHAGE
set { connectionMethodRaw = newValue.rawValue } // les éditions délibérées gagnent
}
Garder les clés qu'on ne comprend pas. Une seconde passe avec un
conteneur à clés chaînes ramasse chaque clé qui n'est pas à vous dans un sac de
JSONValue (un petit
enum à sept cas : null, bool, int, double, string, array, object — décodez
Int64 avant
Double, sinon vos
ports reviennent en flottants). encode(to:)
écrit vos champs connus, puis rejoue le sac tel quel :
// décoder : ramasser l'avenir
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)
}
// encoder : rendre l'avenir, intact
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}
Les deux pièces en place, un vieux client devient un conduit sûr : il affiche ce qu'il peut, fait transiter le reste sans y toucher, et une flotte de versions mélangées — l'état normal du monde pendant des semaines autour de chaque sortie — cesse d'être un danger.
Verrouiller le contrat par les tests
Trois tests portent tout le contrat. Un fixture aller-retour : un
enregistrement d'une version « future » — valeur d'enum inconnue, scalaire inconnu,
objet imbriqué inconnu — doit se décoder sans erreur et se ré-encoder avec chaque
clé d'origine intacte. Un test d'édition délibérée : affecter la
propriété typée doit écraser la valeur brute pendant que les clés inconnues
survivent. Et un test étalon : un enregistrement aux seuls champs
connus doit s'encoder au bit près comme la sortie de l'ancien code — pas de clé
unknownFields, pas de
noms de stockage …Raw
qui fuient sur le câble. C'est ce dernier qui permet de refactoriser l'intérieur
sans bifurquer le format en douce.
Une mise en garde honnête pour finir : rien de tout cela ne répare les clients déjà expédiés sans ces protections. Leurs décodeurs refusent toujours l'avenir. La migration est ennuyeuse et incontournable — livrez d'abord les décodeurs tolérants et préservants, laissez-les se déployer, et alors seulement laissez circuler les nouvelles valeurs. L'évolution de schéma est un problème d'ordonnancement déguisé en sérialisation.
Ce durcissement arrive sur toute la couche de synchronisation de SharkTTY dans la prochaine mise à jour. Pas d'accord avec l'approche, ou brûlé autrement ? Le tableau de retours est ouvert.