Contract 18

Settings

Settings are the most common persistent state in an app and the most commonly lost. The hand-written version is a Codable struct written with Data.write(to:): one crash mid-write and the file is empty; one renamed field and decoding fails and every preference silently resets; one older app version launched after a newer one and the newer fields are dropped on the next save; one invalid value set from code and it is persisted, and the next launch cannot read it. StoicSettings is the composition the schema and store layers were built for.

Origin

  • design/06. One schema declares the format, the defaults, every constraint and the migrations.
  • design/15. AtomicFile: temporary file, full sync, rename, previous generation kept, corrupt files quarantined.
  • What goes wrong in apps. All of the above, plus settings changed on two threads at once, and UI that does not update when a setting changes elsewhere.

API

let settings = try SettingsStore.open(at: path, schema: Prefs.schema)
settings.value.scrollSpeed                      // Observable
try settings.update { $0.scrollSpeed = 900 }    // validated, durable, then published
settings.issues                                 // what the last load repaired or warned about
settings.source                                 // .file, .previousGeneration, .defaults(reason), …

Semantics

Load. The file is read through AtomicFile with the previous generation kept, and decoded with the schema — leniently by default, so one invalid field falls back to its default with a repaired issue instead of discarding every other setting. A file that cannot be used at all (torn, not JSON, the wrong shape) is quarantined, never deleted, and the previous generation is tried; failing that, the defaults. source says which, and why.

Migration. The schema migrates old documents as it decodes them. An app-owned store then writes the migrated form back at once, so the old format does not linger — and because the previous generation is kept, the pre-migration file survives as .previous, a way back for a downgrade.

A newer format is read-only. A document whose version is newer than the schema knows was written by a newer app. It is never quarantined, fallen back over or overwritten: the store opens with the defaults (or what it can read, if the schema preserves unknown keys), source is .newerFormat(version:), and update throws. An older build launched by mistake cannot destroy what the newer one saved.

Update. update applies the change to a copy, validates the result against the schema (a value the schema would never have decoded is refused with every issue, and nothing changes), writes it sparsely — only what differs from the defaults — atomically and durably, and only then publishes it. A failed write leaves the value and the file as they were. Updates are serialised; observers see each committed value.

Formats. .appOwned (default): checksum envelope, write-back of migrations and repairs. .handEdited: plain JSON with comments allowed, nothing written back on load, and update rewrites the file without its comments — say so in the UI that offers both.

Failure modes

What happens if… Behaviour
there is no file Defaults; source .defaults(.missing); nothing written until an update
the app crashed mid-save The old file or the new one, never a mix (AtomicFile)
the file is torn or not JSON Quarantined; previous generation, else defaults; settings.recovered/reset
one field is invalid That field's default; repaired issue; the rest kept
the document is an older version Migrated; written back (app-owned); the old file kept as .previous
the document is a newer version Read-only; never overwritten; update throws .newerFormat
an update produces an invalid value Refused with every issue; value and file unchanged
the write fails (disk full, read-only volume) update throws .storage; value and file unchanged
two updates race Serialised; each sees the other's result

Events

settings.loaded (source, issue counts), settings.repaired, settings.recovered, settings.reset, settings.newer_format, settings.migrated, settings.saved, settings.rejected, settings.save_failed.

Testing

Every row above on the simulated file system, including a crash at every point of an update (old value or new, never a mix, never unreadable), and a newer document surviving an older store's open and attempted update.

All contracts