liters-mobile — embedding SQLite replication in a phone app

Derivative + upstream contributor — mrkurt/liters · July 2026

A 153 MB database sync could not finish inside an iOS background window. I adopted a stronger open-source base, then fixed the integration and correctness failures that real mobile use exposed.

The problem

noop-cloud mirrors my health database from my phone to a server. Right now a sync ships the whole thing: 766 MB on disk, about 153 MB compressed, 90 to 150 seconds. iOS gives a background refresh task a fraction of that, so the sync does not finish. It needs to send only the pages that changed.

I started writing that myself and got a working Swift module out of it. Then I found liters.

What liters already was

Kurt Mackey’s Rust library, about two weeks old when I found it. It reads and writes Litestream v0.5’s LTX format and bucket layout, so buckets it writes restore with stock litestream restore. It also exposes a writer, a replica and a manager to Swift and Kotlin through UniFFI. Replication is driven by explicit push() and sync() calls rather than a daemon, which is the right shape for a phone that only gets short scheduled windows to run in.

The core replication engine was already there, written by someone who had thought about it much longer than I had. So I stopped writing mine and focused on the integration, lifecycle, and correctness gaps between a library and a real mobile app.

Two things made it awkward to actually use. Without a license, the repository did not grant permission to copy and redistribute it. Kurt confirmed MIT and gave permission, so liters-mobile carries his copyright and keeps his commits with their original SHAs and authorship. And a handful of things did not survive contact with a real iOS app.

What needed fixing

Two SQLites in one process. rusqlite‘s bundled feature was pinned in [workspace.dependencies]. Workspace dependency features are additive, so a member crate cannot remove them. No build could link the platform SQLite instead. That matters because an iOS app using GRDB already links Apple’s libsqlite3, and two copies of SQLite in one process each keep their own unixInodeInfo table, so they can silently drop each other’s POSIX advisory locks. liters’ guarantee that no foreign checkpointer restarts the WAL underneath it is a long-running read lock, so losing it is a correctness problem, not just a warning: the WAL restarts, the frame liters resumed from is gone, and recovery uploads a full snapshot instead of a delta. Now a Cargo feature, on by default so nothing changes for existing users, with an iOS build switch. All 41 SQLite symbols the unbundled build leaves undefined resolve against the iOS SDK. Upstream #3.

A lock with no timeout. Replica::apply_spooled took SQLite’s EXCLUSIVE lock pair with fcntl(F_SETLKW), the blocking variant with no deadline or interruption point. liters has a cancel token, but it is an AtomicBool and nothing reads it while the thread is parked in a syscall, so a replica meeting a held lock waits forever on a platform that kills unresponsive processes. Replaced with deadline-bounded, cancellable F_SETLK polling and a LockBusy error that reports which process holds the lock. Upstream #4.

Uploads lost at suspend. Storage::into_config() hardcoded transport: None, so anything other than the manager ran on the built-in socket transport. That transport refuses https:// and cannot hand a transfer to a background URLSession. On iOS that means an upload in flight when the app suspends is gone. Separately, ForeignBody::read reported an empty chunk as Idle on every request, violating that state’s documented contract and spinning instead of surfacing an error.

No way to tell what a sync cost. verify() already decided per push whether a full snapshot was needed instead of an incremental delta, and recorded why, but none of it reached PushResult. A caller could not tell a small delta from a whole-database upload. It now carries snapshotted, a fixed-set snapshot_reason, and bytes_uploaded. No behaviour change; the values already existed.

Docs that contradicted their sources, and a broken fresh clone. Four in-tree claims disagreed with the sources they cite: LZ4 settings attributed to a call site that does not set them, “byte-compatible” where only format-compatible is achievable, a flags field described as reserved that is not on ltx main, and a claim about wal_autocheckpoint that holds only while a lock is held that gets released by design. Fixed alongside the LICENSE in upstream #2. Separately, a fresh clone could run neither cargo test --workspace nor make test, because the pinned litestream checkout the fixture tests read from was only ever fetched by CI. Upstream #5 makes the normal test command fetch that reference explicitly.

A restored replica that some SQLite builds could not read. The restore path copied the source database’s WAL-mode header into the replica, then deleted the WAL and shared-memory files that header said existed. Apple’s system SQLite rejects a read-only open in that state even though the bundled SQLite happened to accept it. The fix makes initial restore and incremental apply uphold the same documented rollback-journal contract. Upstream #6.

Code generation compiled into every phone target. UniFFI’s CLI feature was a normal dependency, so cross-compiling the library also built a host-only binding generator for every iOS and Android slice. Upstream #7 makes the CLI opt-in and enables it only where the build scripts actually invoke the generator.

Where it stands

The integrated branch passes 202 workspace tests. Its dedicated system-SQLite run passes 98 tests across 17 binaries and verifies that the tests actually link Apple’s libsqlite3 instead of silently re-enabling the bundled amalgamation.

Six changes are open upstream, #2 through #7. None are merged. The telemetry and transport branches I have not proposed at all.

The remaining validation is operational. Background upload is unsolved: iOS background URLSession is asynchronous and delegate-driven, and the UniFFI surface is entirely synchronous, so there is no seam for it yet. I also still need to measure how often a killed app forces a full re-upload instead of a delta. That number decides whether this is viable on a phone at all. The telemetry exists now. The measurement does not.