SharkTTY syncs your saved SSH hosts between devices. While we were adding a new
connection type to the app, we asked a question that turned out to have an
uncomfortable answer: what happens when a host record written by the new app
reaches a device still running the old one? The answer, for a completely
ordinary Swift Codable
setup, was: the old device's sync silently freezes — and if you "fix" that the
obvious way, it gets worse: the old device starts destroying the new
device's data. Neither failure needs exotic code to produce. Both come
straight from defaults.
Failure one: the enum that refuses the future
The setup is the one every Swift codebase has somewhere:
enum ConnectionMethod: String, Codable { case ssh, mosh }
struct Host: Codable {
let id: UUID
var name: String
var connectionMethod: ConnectionMethod
// …
}
// somewhere in the sync layer:
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }
A String-backed
Codable enum is a
closed world: an unknown raw value doesn't fall back, it throws.
So the day a newer app writes "connectionMethod": "et",
every older reader throws on that one record — and because the whole list decodes
as a unit, one record it can't read means no records at all. The
try? swallows the error,
the import returns early, and the device keeps its stale list forever. No crash,
no log line, no banner. Sync just… stops being sync, on every device that hasn't
updated yet — which during any real rollout is most of them.
Failure two: the tolerant decoder that destroys data
The reflexive fix is a lenient decode — unknown value, pick a default:
let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh // tolerant… and lossy
This cures the freeze and plants a landmine. Sync systems re-encode: the old app
edits some other host, serializes its whole list, and uploads it —
last-writer-wins. But it decoded "et"
as .ssh, so that's what it
writes back. And the fields it never had properties for — the new record's extra
keys — were dropped by Codable
at decode time, so they aren't in the upload either. One edit on one out-of-date
device, and the newer device's configuration is quietly rewritten into the old
shape, account-wide. The freeze at least preserved the data. The tolerant decoder
erases it, politely.
The contract: old code must never destroy what new code wrote
Protobuf solved this decades ago with unknown-field preservation; Swift's
Codable gives you the
tools but not the default. Two moves recreate it.
Store the wire truth, expose the typed view. Each enum field keeps its raw string as the stored property; the typed enum becomes a computed view with a display-only fallback. Reading an unknown value degrades gracefully; writing it back reproduces it exactly. Only a deliberate user edit — the setter — overwrites it:
private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh } // fallback for DISPLAY
set { connectionMethodRaw = newValue.rawValue } // deliberate edits win
}
Keep the keys you don't understand. A second pass with a
string-keyed container collects every key that isn't yours into a bag of
JSONValue (a small
seven-case enum: null, bool, int, double, string, array, object — decode
Int64 before
Double, or your ports
come back as floats). encode(to:)
writes your known fields, then replays the bag verbatim:
// decode: collect the future
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)
}
// encode: give the future back, untouched
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}
With both in place, an old client is a safe conduit: it displays what it can, round-trips what it can't, and a mixed-version fleet — the normal state of the world for weeks around every release — stops being a hazard.
Pinning it down with tests
Three tests carry the whole contract. A round-trip fixture: a record
from a “future” version — unknown enum value, unknown scalar, unknown nested
object — must decode without error and re-encode with every original key intact.
A deliberate-edit test: setting the typed property must overwrite the
raw value while the unknown keys still survive. And a golden test: a
record with only known fields must encode byte-identically to the old code's
output — no unknownFields
key, no …Raw storage
names leaking onto the wire. That last one is what lets you refactor the internals
without silently forking the format.
One honest caveat to end on: none of this repairs clients that already shipped without it. Their decoders still refuse the future. The migration is boring and unavoidable — land the tolerant, preserving decoders first, let them roll out, and only then let the new values start flowing. Schema evolution is a sequencing problem wearing a serialization costume.
This hardening ships across SharkTTY's sync layer in the next update. Disagree with the approach, or been burned differently? The feedback board is open.