An app with no server
Seaglass has no account and no server. Everything a person tracks sits in a SQLite database on their phone. The privacy argument for that is easy to make, so this post is about the less obvious consequence: when you run a backend, you also run the migrations, and you can fix a bad one on your side. Here a bad migration runs on a phone I’ll never see, against data that exists nowhere else.
Most of the interesting engineering in the app went into making that safe. It comes down to one decision: the database and the backup are two separate schemas, and the backup is the one I promise to keep.
Two schemas, two sets of rules
The SQLite schema is an implementation detail. It changes through numbered migrations, tracked in SQLite’s user_version, and a released migration is never edited or removed:
// Each entry upgrades the schema by one version. Never edit or remove a
// released migration; add a new one.
export const MIGRATIONS: readonly string[] = [
// v1
`CREATE TABLE trackers ( ... );`,
// the next change is a new entry here, never an edit above
];
That’s the ordinary part. The part that matters is that nothing outside the database depends on those tables. Export and import are the only code that maps between the tables and a separate, versioned format (src/backup/export.ts and import.ts). I wrote the rule down in the repo: the format is its own contract, not a dump of the tables. That way the database can change freely, and a rename or a split table never reaches a file someone saved a year ago.
The promises
Every path that moves data out of the app or back in uses the same format: the .seaglass export file, the iCloud backup, and restoring onto a new phone. Having one format means there’s one thing to get right. The promises are:
- Backwards, always. A backup from any released version imports into every newer version.
- Forwards, where possible. A backup from a newer version imports into an older one if the newer format only added things. Otherwise the older app refuses it with a clear message and writes nothing.
To keep those promises the format has a version, { major, minor }, and rules for bumping it:
- Minor bump (
1.0to1.1): additive only. New optional fields with a written default, or new values for a field that already has a fallback. Older apps keep working. - Major bump (
1.xto2.0): anything else, including renames, removals and changed meaning. I’d rather avoid these. One needs a written reason in the PR and an upgrade step insrc/backup/upgrade.tsthat turns the previous major’s JSON into the new one. Upgrade steps are never edited after release.
Rules in a doc only hold if something enforces them, so there are two checks in CI:
- Golden fixtures. Every released version gets a file in
fixtures/backups/(v1.0.json, and so on). CI imports every fixture on every change, and fixtures are never edited or deleted. If a change breaks reading a v1.0 file, the build fails, and that is a much cheaper place to find out than a one-star review. - A schema snapshot.
schema.test.tssnapshots the schema of the current version. Changing the format without bumping the version fails the test, so I can’t make a “small” edit and forget the contract.
One honest caveat: Seaglass hasn’t shipped yet, so no real backups exist. Until the first release, v1.0 and its fixture can still change in place. The rules apply from the first release on.
Encoding choices that age well
A few decisions in the format exist because the alternatives break later:
- Money is integer minor units plus an ISO 4217 currency code, never floats.
1400and"USD", not14.00. - Dates come in two kinds. Quit dates and run boundaries are local moments, so they keep their offset (
2025-10-18T21:00:00-04:00). Someone who quit at 9 p.m. shouldn’t see their start time move because they flew somewhere. Everything else (createdAt,occurredAt,exportedAt) is UTC. Importers accept any offset and store UTC, so records sort and compare correctly. - IDs are UUID strings and enums are strings, never numbers. A UUID can be created on any phone without coordination, which also makes merging possible later.
Importing a file you didn’t write
An import has to assume the file might be old, new, hand-edited or damaged. The importer goes through these steps in order:
- Not JSON, or
formatisn’tseaglass-backup: refuse. - Encrypted: ask for the passcode, then continue with the decrypted JSON.
version.majornewer than this app: refuse and write nothing.- Older major: run the upgrade steps up to the current one.
- Validate everything against the current schema.
- Newer minor: import, and warn that some details may not come through.
- Show a summary and ask whether to replace or merge.
- Write it all in one transaction.
Two of those are deliberate about failure. Step 5 refuses the whole file if any record is invalid and says which part is wrong; I never import half a file, because a partially restored history is worse than an error. Step 8 uses a single transaction, so a crash halfway through leaves the database as it was.
For values this version doesn’t recognize, the importer degrades instead of failing: an unknown preset is kept as it is and shown as “Something else” with its name, an unknown cost.period becomes day, an unknown entry kind becomes note, an intensity outside 1 to 5 becomes null, and unknown fields are ignored. Each fallback is written down in the format doc, so a later minor version can add a value without breaking older apps.
Merge is simple on purpose. New records are added, and for an id that exists on both sides, the one with the later updatedAt wins. Because IDs are UUIDs, two phones that never talked to each other still merge cleanly.
Only preferences travel in the file, from an allowlist in settings.ts. Purchase state and device state are never exported, and the importer ignores any other key, so a hand-edited backup can’t flip the unlock.
Encryption in its own envelope
A backup can be protected with a passcode. The encrypted file is an envelope with its own version, separate from the data format, so I can change the encryption without touching the data:
{
"format": "seaglass-backup-encrypted",
"envelope": 1,
"kdf": { "name": "scrypt", "N": 32768, "r": 8, "p": 1, "salt": "<base64>" },
"cipher": { "name": "AES-256-GCM", "iv": "<base64>" },
"ciphertext": "<base64>"
}
The key comes from scrypt over the passcode, and the payload is AES-256-GCM with a fresh random nonce for every file. A few details I had to get right:
- The passcode is normalized to Unicode NFC before hashing, so the same passcode typed on an iOS keyboard and an Android keyboard produces the same bytes.
- The scrypt settings are stored in the file, so they can go up later without breaking old files. Because files can come from anywhere, the importer refuses settings that would need more than 256 MiB of memory and treats the file as damaged, instead of letting a crafted file crash the app.
- A wrong passcode and a damaged file look the same, because both fail the GCM tag check. The app asks again.
- It’s plain scrypt and AES-GCM in pure JavaScript (the
@noblelibraries), so it runs identically on iOS, Android and in Jest, with no native module. The tests check my output against Node’s built-in crypto, which means anyone can open a file without my code.
A new envelope version gets its own golden fixture, the same as a new data version.
The cloud copy is the same file, on purpose
The iCloud backup is the same file, written to the app’s private iCloud container. It is deliberately not passcode-encrypted: an automatic backup can’t prompt for a passcode, and a forgotten passcode would make the backup useless on the very phone it exists for. It lives in the person’s own account, behind their Apple ID. Conflicts are last-write-wins by the writing phone’s clock, with one guard: a phone won’t overwrite a backup from a different device whose exportedAt is in the future. That is a heuristic, not a real sync protocol, and I’m fine with it for a counter one person uses. Android’s Google Drive version isn’t built yet.
Purchases without a database
The same principle applied to the one-time unlock: the app has no record of what someone owns. It asks the store at launch and whenever it returns to the foreground, and trusts the answer. There’s no “purchased” flag to get out of sync, so a refund or a revoked promo code takes effect the next time the app asks. Free codes are App Store offer codes and Play promo codes redeemed as a zero-price purchase of the same product, so they follow the same path.
What it costs
The tradeoffs are real. There’s no sync across devices beyond restoring a backup. I have no usage data and no crash reports of my own. I can’t recover a forgotten passcode. And every one of the rules above is a rule I’d have skipped if a server could quietly patch my mistakes.
I don’t think of that last part as a downside. Writing the compatibility rules down, and having CI hold me to them, is work I’d want done on a normal backend project too. The server version of this app would have let me skip it, and that’s the reason I’m glad it doesn’t have one.