Offline-First KMP · Part 15

Shared Logic Is Not Free

Cover Image for Shared Logic Is Not Free
Android
androidkmpoffline-firstkotlin-jsnodejscoroutines
·10 min read

"One module, three runtimes" is true for the rules. It is not free at the type boundary.

TL;DR We ship one Kotlin module to iOS, Android, and a Node.js backend, so the business rules exist in exactly one place and cannot drift. That is real and worth it. But sharing it charges a toll in three currencies. Two are paid at the Kotlin-to-JavaScript boundary: numbers lose precision (Long becomes Double), and coroutines become Promises driving a transaction on a connection the server owns, so every way out has to end in a rollback. The third comes from one engine reading two different databases: every value arrives as a string. Design the API narrow, and pay each toll on purpose.

Part 15 of the Offline-First KMP series: field notes from a production winery app built on Kotlin Multiplatform (KMP). Part 11 lands the headline: our backend runs the same Kotlin engine our phones run, compiled to JavaScript. This post is the honest invoice for that, the three costs the headline skips.

The Setup: One Kotlin Module, Three Runtimes

The engine that validates events and derives state is one KMP module. It already compiled to Android and iOS; we added a single JavaScript target, and the same source now builds a Node library, published as an npm package the backend imports. This post is about what happens at the seam where compiled Kotlin meets hand-written JavaScript.

Toll 1: Kotlin Long Becomes a JavaScript Double

Kotlin has a real Long. JavaScript has one number type, an IEEE 754 double, and Kotlin/JS does not smuggle a 64-bit integer across intact. So anything that is a Long on the Kotlin side crosses the boundary as a Double.

You can see it in the facade signatures. A timestamp that is a Long internally is exposed to JavaScript as a Double, and narrowed back with .toLong() on the inside:

public fun validateIntake(
    // ...
    // Long on the inside, Double at the boundary
    intakeDateMillis: Double?,
): Array<String> =
    validator.validateIntake(/* ... */, intakeDateMillis?.toLong())

A Double carries integers exactly up to 2^53, about 9 × 10¹⁵:

Which Longs survive as a Double
Epoch-milliseconds timestampabout 1.8 × 10¹²: fits with room to spare
Large countercan pass 2^53: rounded silently
Synthetic id packed into an integercan pass 2^53: rounded silently
Nanosecond clockabout 1.8 × 10¹⁸: already past it

So for our data this is safe. But "safe for our data" is a decision, not a freebie: past that range the boundary silently rounds, and you will not get a compile error telling you so. Know which of your numbers are genuinely 64-bit, and keep them out of the arithmetic that crosses.

Toll 2: Every Database Value Arrives as a String

The engine reads its database through one small interface, and that interface has exactly one way to read a column:

/** Platform-agnostic row accessor for query results. */
public interface Row {
    // every column, always a string
    public fun getString(name: String): String?
}

Numbers, booleans, timestamps, JSON: the engine receives them all as strings and parses them itself. The reason is not the JavaScript boundary, or not only. The same engine reads SQLite on the phone and Postgres on the server, and the two disagree about types: a boolean comes back as 1 or 0 from one and as true or false from the other, and dates and JSON come back in different shapes too. A string is the one form both databases can always produce, so the shared code asks for nothing else. On the server, the bridge that NestJS hands the engine does the flattening before the engine sees a value:

Server bridge: Postgres value → string
DatestoISOString()
Numbers and booleansString(value)
JSON columnsJSON.stringify

So this toll is not strictly the price of crossing into JavaScript, and the phone pays it as well. It is the price of one engine over two databases, which is the same bargain as one engine over three runtimes, seen from the storage side.

The phone's upload path has a cousin of this problem: PowerSync keeps every local value as text, so we keep a small, deliberate coercion layer that turns a string into the right JSON shape: a numeric-looking string becomes a JSON number, a value that starts with { or [ gets parsed as structured JSON, and everything else stays a string. There is even a variant that keeps numeric-looking values as strings on purpose, for the columns where "10.4" must not become a float. Part 6 tells that story, and Part 7 is the bug you get from putting one column on the wrong side of that split.

Toll 3: Coroutines Become Promises, and the Rollback Is Yours

Kotlin's concurrency is coroutines; JavaScript's is Promises. A suspend function cannot cross as-is, so the facade wraps each one in GlobalScope.promise { }, which turns a suspending body into a real Promise the Node code can await (the facade itself is below). The GlobalScope is deliberate rather than the usual code smell: the caller is Node, there is no parent Kotlin scope to inherit, and the returned Promise is the lifetime handle.

What crosses with it is a transaction. processEvent runs its whole dispatch as one database transaction, on a connection the server owns, opened and closed by Kotlin through the bridge. So every way out of that block has to end in a rollback, and Kotlin decides what "every way out" means.

We learned that from a TODO(). Early on, a few event types were still unimplemented branches, and TODO() throws NotImplementedError: an Error, not an Exception. Both the dispatcher and the bridge adapter caught Exception:

A TODO() branch throws NotImplementedError
catch (e: Exception)
back then, in two places
the Error walks past both catches
no rollback, no PROCESSING_FAILURE
event row stuck at NOT_PROCESSED
Transaction left to the host.
catch (e: Throwable)
the fix: one word, twice
the Error is caught like any failure
rollback runs, the failure is recorded
row locks released
Kotlin closes what it opened.

Our server has a backstop, a finally that rolls back any judge connection still open. A host without one keeps the transaction open, with its row locks held.

The same review asked which other exit could skip the cleanup, and the answer was cancellation. A cancelled coroutine cannot suspend again: its next await throws CancellationException at once, and the rollback is an await on the server's Promise. Nothing cancels these calls today, but the first timeout someone adds will. So the adapter finishes its rollback outside cancellation:

js.beginTransaction().await()
try {
    val result = block()
    js.commitTransaction().await()
    return result
} catch (e: Throwable) {          // not Exception: a TODO() is an Error
    withContext(NonCancellable) { // finish even if we were cancelled
        js.rollbackTransaction().await()
    }
    throw e
}

Design a Narrow Facade, and Let the Host Own the Database

None of this is an argument against the approach. It is an argument for building the boundary on purpose. The engine writes every SQL statement itself, but never opens a database connection. It hands each statement to one small interface, DbExecutor, and each host implements that interface to run the SQL on its own database:

One engine, two hosts, one database interface
On the phone
Kotlin all the way down, no gate
database PowerSync over SQLite
implements PowerSyncDbExecutor
runs the engine's SQL on the device
calls the engine's suspend functions
Provisional projection.
On the server
NestJS, through the JavaScript gate
database Postgres, via TypeORM
implements JsDbExecutor in TypeScript
runs the engine's SQL on the server
calls WineryJudgeJs: strings in, Promises out
Authoritative state.

On the phone there is no gate at all. The app wires the engine to SQLite and calls the same processor the server runs, with typed Kotlin. Same judge, different weight: on the phone its answer is only provisional, a projection that holds until the server's authoritative state syncs back:

// Wiring (Koin): the phone's DbExecutor is PowerSync over SQLite
single { PowerSyncDbExecutor(get<PowerSyncDatabase>()) }
single { WineryProcessorEngine(get<PowerSyncDbExecutor>(), /* ... */) }

// Renaming a docket on the phone (simplified)
// 1. check: a local no is real
val errors = validationEngine.validateDocket(/* ... */ name = event.name)
if (errors.isNotEmpty()) return Result.failure(/* ... */)
// 2. witness: the event uploads
eventEngine.recordDocketUpdate(workspaceId, docketId, event, userId)
// 3. provisional projection: the same rules, stays on the phone
processorEngine.processDocketUpdate(workspaceId, event)

Typed events and suspend functions: no strings, no Promises. The server runs the same engine but reaches it through the gate, in two small pieces. First, in Kotlin, one exported class whose methods take strings and numbers. The event arrives as raw JSON and is decoded inside, where the sealed classes and generics are real again:

@JsExport
public class WineryJudgeJs(db: JsDbExecutor) {
    // Promises in, suspend out, rollback guaranteed
    private val dbAdapter = JsDbExecutorAdapter(db)

    public fun processEvent(
        eventId: String,
        workspaceId: String,
        eventType: String,
        eventData: String,      // raw JSON, decoded inside
        schemaVersion: Int,
    ): Promise<Unit> = GlobalScope.promise {
        // decode and validate, then write: Kotlin generates the SQL
        dbAdapter.execute(
            "UPDATE docket SET name = ? WHERE id = ?",
            listOf(name, docketId),
        )
    }
}

Then, in TypeScript, the host. It never writes SQL. It runs what Kotlin sends, on a connection it owns (simplified):

const db: JsDbExecutor = {
  // Toll 2: every value becomes a string
  getAll: async (sql, params) =>
    (await runner.query(sql, params)).map(row => ({
      getString: name => toText(row[name]),
    })),
  // Kotlin's SQL, run as-is
  execute: async (sql, params) => { await runner.query(sql, params) },
  // connection affinity: one pooled connection, kept until
  // commit or rollback
  beginTransaction: async () => {
    runner = dataSource.createQueryRunner()
    await runner.connect()
    await runner.startTransaction()
  },
  // commitTransaction, rollbackTransaction, lockRows
}

const judge = new WineryJudgeJs(db)
await judge.processEvent(
  event.id, workspaceId, eventType, JSON.stringify(eventData), version,
)

That inversion is what lets one Kotlin module drive a Node backend without dragging Kotlin's runtime assumptions across the wire with it.

The Takeaways

  • "Shared logic, zero drift" is true for the logic, and only the logic. One implementation of the rules is a real, large win: client and server cannot disagree, because there is no second copy to disagree. That property survives the boundary perfectly. The types do not.
  • A cross-language boundary is a boundary; budget for it. Numbers narrow to Double, and coroutines become Promises, with a rollback you must guarantee on every exit path. One level down, one engine over two databases means every value arrives as a string. None of these throws a compile error at you, so each one is a decision you make on purpose or a bug you find in production.
  • Narrow the gate deliberately. Cross primitives and JSON strings, decode to real types on the inside, and let the host own the database connection. Anyone who sells you the boundary as seamless is selling you the headline and skipping the invoice.