Engineering

Your shared database needs a driver per platform, and that is the easy half

The driver is a one-line expect/actual. Threading, file location, and the OS deleting your file are the rest of it.

MobileByteSensei 8 min read
kmpstoragearchitecture

TL;DR

Every SQLDelight tutorial shows you the driver expect/actual. It is four lines per platform and it works on the first try, which is why it feels like the whole job.

It is the smallest of the differences. The ones that cost you are: the Android driver is synchronous on whatever thread you call it from, the iOS file lives in a directory the OS is allowed to empty, and the wasm driver has no file at all. None of those produce a compile error, and all three produce production bugs that look like your query logic.

Share the schema and the queries. Do not share the assumption that a write that returned has survived.

The case this post is about

An offline-first reading list. One SQLDelight schema in commonMain, one repository, three targets — Android, iOS, wasmJs. The standard setup:

// commonMain
expect class DriverFactory {
    fun create(): SqlDriver
}

// androidMain
actual class DriverFactory(private val context: Context) {
    actual fun create(): SqlDriver =
        AndroidSqliteDriver(Database.Schema, context, "reading.db")
}

// iosMain
actual class DriverFactory {
    actual fun create(): SqlDriver =
        NativeSqliteDriver(Database.Schema, "reading.db")
}

Both compiled. Both passed the repository tests. The feature shipped, and then three separate bug reports came in that had nothing in common except that they were all this.

The symptom

Android. An ANR, on a mid-range device, in a list screen that does one insert on scroll:

ANR in com.example.reading
Reason: Input dispatching timed out
  at app.cash.sqldelight.driver.android.AndroidSqliteDriver.execute
  at com.example.ReadingRepository.markRead
  at com.example.ReadingViewModel$onScroll$1.invokeSuspend

iOS. Users reporting their reading list "randomly empty". Not corrupt — empty. Fresh database, schema intact, zero rows, no error anywhere in the logs.

wasmJs. A clean failure at startup, which is the only one of the three that behaved well:

IllegalStateException: no such file or directory

Three symptoms, three platforms, and the shared code is identical in all three. The temptation is to treat them as three bugs.

Why the obvious fix failed

"Wrap the repository in Dispatchers.IO." Fixes the ANR and hides the actual rule. There is no Dispatchers.IO in commonMain for all targets — wasm has no thread pool to dispatch to — so the fix that works on Android is not available where you would need to repeat it.

"The iOS database is corrupting." It is not. It is being deleted, intact, by the system. A corruption theory sends you to integrity checks and WAL settings, which all report healthy, because nothing is wrong with the file. There just isn't one.

"Catch the wasm exception and fall back to in-memory." A reasonable instinct, and it buries the only platform that told you the truth plainly. The exception is correct: there is no filesystem.

"Use a cross-platform storage library that handles this." Still has to resolve these same three questions; it just resolves them for you, with defaults you did not pick. For a cache that is fine. For anything a user would be upset to lose, you want to have chosen.

The actual mechanism

The driver is the thinnest part of the platform difference. Three thicker ones sit behind it, and all three are invisible to the type system.

Threading is a property of the driver, not of your code. AndroidSqliteDriver executes synchronously on the calling thread. NativeSqliteDriver maintains its own connection pool. Neither signature says so — both execute calls look identical in commonMain — so the same repository method is main-thread-unsafe on one platform and fine on another.

"Where the file lives" is a platform policy, not a path. On Android, context resolves to an app-private directory the OS does not reclaim. On iOS, the default is where NSSQLiteStoreType puts it — and if that resolves under Caches/, iOS is allowed to empty it under storage pressure. That is documented behaviour, not a bug. Your rows are gone and the system did exactly what it said it would.

Durability is per-platform and unenforceable in common code. A write on Android that returns has hit a real file. The same write on wasm has hit an OPFS or IndexedDB-backed store whose flush semantics differ, and in a browser tab that can be killed without notice.

And the sub-case that hides all three: there is a fourth driver, and it is the one your tests use. inMemoryDriver has no file, no connection pool, no storage pressure and no tab to close — so every property above is absent from it, in the one place you would expect to catch them.

// commonTest — green on all five targets, and proves nothing about any of the three
@Test fun savesDraft() = runTest {
    val db = Database(inMemoryDriver(Database.Schema))
    DraftRepository(db).save(draft)
    assertEquals(draft, DraftRepository(db).load(draft.id))
}

That test passes on Android, iOS, desktop and wasm. It is also the same driver on all four, so the matrix that normally earns its cost — running shared tests against genuinely different runtimes — is running one runtime four times.

Which reframes what commonTest can do here. Query correctness and mapping logic: yes, and the in-memory driver is the right tool. Threading, durability and file placement: no, by construction, because the driver under test is the one with none of them. Those need a test that constructs the real driver per platform — which means androidInstrumentedTest and iosTest, not commonTest, and that is a cost worth paying only for the handful of operations where losing a row matters.

Three platform strata under one shared schema: the driver expect/actual is the thin top layer, while threading model, file-location policy, and durability guarantee differ underneath and none of them appear in the common type signature

The shape of the problem:

expect/actual makes the platform difference compile. It does not make the platform difference visible. Anything the driver does not express in its signature — threading, location, durability — your shared code is silently assuming.

The fix

Three changes, each pushing a platform fact into a place the compiler or the caller can see.

Make suspension the contract, not an Android workaround. The repository suspends, and the dispatcher is a platform fact the expect provides:

// commonMain
expect val dbDispatcher: CoroutineDispatcher

class ReadingRepository(private val q: ReadingQueries) {
    suspend fun markRead(id: Long) = withContext(dbDispatcher) {
        q.markRead(id)
    }
}
// androidMain / iosMain
actual val dbDispatcher: CoroutineDispatcher = Dispatchers.IO

// wasmJsMain — single-threaded; the dispatcher is the main one, deliberately
actual val dbDispatcher: CoroutineDispatcher = Dispatchers.Main

Now the ANR is structurally impossible on Android, and wasm's single-threadedness is a declared choice rather than a missing API.

Name the directory explicitly on iOS. Do not accept the default:

// iosMain — Application Support, which the OS does not reclaim. Not Caches.
actual class DriverFactory {
    actual fun create(): SqlDriver {
        val dir = NSFileManager.defaultManager.URLForDirectory(
            directory = NSApplicationSupportDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null, create = true, error = null
        )!!.path!!
        return NativeSqliteDriver(
            schema = Database.Schema,
            name = "reading.db",
            onConfiguration = { it.copy(extendedConfig =
                it.extendedConfig.copy(basePath = dir)) }
        )
    }
}

One more thing belongs here and is easy to miss: that directory is backed up to iCloud by default. For a cache of remote data, mark it excluded; for user-authored content, leaving it backed up is the right call. Either way it is a decision, and nothing will prompt you to make it.

Declare durability where the caller can read it. If wasm cannot promise a write survived, the shared API should not pretend otherwise:

// commonMain
enum class Durability { Durable, BestEffort }
expect val storageDurability: Durability

A sync layer that knows it is on BestEffort keeps the server as the source of truth and does not treat local state as confirmed. That is a one-line check that only exists because the platform difference was made visible instead of merely compiling.

How we proved it

Each fix needed a different kind of evidence, and one of them I got wrong first.

The ANR. Reproduced with strict mode rather than by scrolling and hoping — a detectDiskReads/detectDiskWrites penalty on the main thread turns a timing-dependent ANR into a deterministic violation. After the withContext change, zero violations across the list screen.

The iOS deletion. This is the one I initially "proved" wrong. My first check was to confirm the file existed after a write, which it always did — a test that cannot fail for the reason the bug happens. The real check is the path, not the file:

before:  …/Library/Caches/reading.db        ← OS may reclaim
after:   …/Library/Application Support/reading.db

A one-line assertion on the resolved directory, run on device. The original symptom is rare and pressure-dependent; asserting the policy is deterministic and runs in milliseconds. When a bug depends on the OS choosing to act, test the condition you control, not the outcome you are waiting for.

wasm durability. No clever test — a tab kill mid-write, repeated, with the row count read back on reload. It loses writes. That is the documented behaviour, and the point of measuring it was to stop treating it as a defect to be fixed.

One limit worth stating: none of this makes the three platforms behave identically. It makes the differences declared. The repository still has three durability profiles; the win is that the sync layer now knows which one it is standing on.

What this generalizes to

A compile-time abstraction hides exactly the properties that are not in the signature. This is the general failure of expect/actual over platform resources. You get a type that matches on both sides and a threading model, lifetime, and failure mode that do not. Ask, for every platform resource: what does this do that its signature does not say?

The platform that fails loudest is doing you a favour. wasm threw at startup; iOS silently emptied a database over weeks. The loud one cost an hour. Rank your platforms by how clearly they fail, and let the clearest one teach you what the others are hiding.

"The OS is allowed to do that" is a design input. Cache directories get reclaimed, background tasks get killed, tabs get closed. These are published contracts, and reading them is cheaper than discovering them through a support queue.

Test the policy, not the symptom. A bug that fires when the OS decides to act is untestable by reproduction and trivially testable by assertion — assert the directory, the flag, the mode you chose. The symptom is the OS's business; the policy is yours.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS