Client API — Kotlin
The Kotlin client is a full engine, not just the protocol core. Like
@simplysync/engine, it owns a local SQLite database, a typed schema, the HLC
clock, the outbox, a reactive query cache, and the sync loop — so you call
insert / update / query and collect reactive results into Compose, the same
way the React version works. It lives in
apps/todolist/kotlin
(com.simplysync.kotlin), built on the protocol port in the same package.
This page mirrors the React/TypeScript guide section-for-section. For the on-the-wire protocol and the versioned mnemonic identity migration, see Native clients. Pure JVM (no Android framework deps), so it unit-tests without an emulator.
Looking something up? Jump to the API reference or Common pitfalls.
#Mental model
- The local SQLite database is the source of truth. Reads come from it; writes land in it first, so the app is fully usable offline.
- Every write also produces an encrypted sync event. The engine queues it in a local outbox and pushes it when a relay is configured and reachable.
- Sync is automatic and opportunistic. The engine polls, pushes the outbox,
pulls remote events, decrypts them, and applies them last-writer-wins. You
rarely call
sync()yourself. - Queries are reactive.
liveQueryreturns aStateFlowthat re-emits when the rows it depends on change — from a local write or an incoming sync.
#Setup
#Clone the source module
Use the kotlin selective clone preset, then include its directory as a local
Gradle project:
bun run clone kotlin ../my-kotlin-sync// settings.gradle.kts
include(":simplysync-kotlin")
project(":simplysync-kotlin").projectDir =
file("../my-kotlin-sync/apps/todolist/kotlin")
// app/build.gradle.kts
dependencies {
implementation(project(":simplysync-kotlin"))
}It exposes kotlinx-serialization, coroutines, and OkHttp as API dependencies.
Android injects an android.database driver. A JVM app using the optional
JdbcSqliteDriver also adds
runtimeOnly("org.xerial:sqlite-jdbc:3.45.3.0"); desktop SQLite binaries are
deliberately not pulled into Android apps. The
Kotlin source README
includes the standalone test command.
Treat the protocol core as pinned — update it wholesale, don't fork it, or clients stop being able to sync with each other.
#Define a schema
A schema is { table -> { column -> Column } }. Column types come from the
engine's typed DSL and double as runtime validators.
val appSchema = Schema(mapOf(
"category" to mapOf(
"id" to Column.id("category"),
"name" to Column.nonEmptyString,
),
"entry" to mapOf(
"id" to Column.id("entry"),
"description" to Column.nonEmptyString,
"amountCents" to Column.finiteNumber,
"categoryId" to Column.nullable(Column.id("category")), // nullable foreign key
),
))You declare id per table. The engine adds three system columns to every
table automatically: createdAt, updatedAt (ISO/HLC strings), and isDeleted
(a 0 | 1 SQLite boolean). You never write CREATE TABLE.
Built-in column types: Column.string, .nonEmptyString,
.nonEmptyString1000, .positiveInt, .finiteNumber, .sqliteBoolean,
.simpleName, .mnemonic, and .id("Table"). Wrap any with Column.nullable(...)
to allow null, or Column.maxLength(n, ...) to bound a string.
#Create the engine
createSync(deps) takes the platform plumbing once, then returns a factory you
call with your schema and config — exactly like the TS createSync(deps)(schema, config).
val deps = SyncEngineDeps(
makeDriver = { name -> JdbcSqliteDriver(dbPath(name)) }, // platform SQLite (see Platform drivers)
secureStorage = AndroidSecureStorage(context), // OS secret store (see Platform drivers)
reloadApp = { /* re-evaluate app state after restore/reset */ },
)
val engine = createSync(deps)(appSchema, SyncEngineConfig(
name = "my-app",
transports = emptyList(), // local-only until you add a relay
syncIntervalMs = 3000, // poll cadence
schemaVersion = 1, // optional; default 1
))
engine.ready() // suspend: resolves once the DB is open and the owner is loadedKeep schemaVersion at 1 for additive table and column changes. Only bump it
when payload meaning changes incompatibly. Older clients durably retain a newer
payload without partially applying it, then replay it after an upgraded app
declares support.
What's safe to change after release — adding columns, renaming, removing, changing a type — is covered in Schema evolution.
On first run the engine mints a new owner (a fresh 12-word recovery phrase,
stored via secureStorage). On later runs it loads the existing one — including
a legacy sf1_ key, which still derives.
#Platform drivers
The engine is platform-agnostic: you inject a SQLite driver and a secret store.
SqliteDriver — a thin synchronous wrapper over the platform's SQLite:
interface SqliteDriver {
fun exec(sql: String, params: List<SqliteValue> = emptyList())
fun select(sql: String, params: List<SqliteValue> = emptyList()): List<Map<String, SqliteValue>>
fun <T> transaction(body: () -> T): T
}JdbcSqliteDriver(path) (libsqlite3-jdbc) is the default for JVM/desktop and the
tests (":memory:" for in-memory). On Android, inject a thin
android.database.sqlite-backed implementation instead.
SecureStorage — stores the recovery secret in the OS secret store
(getItem / setItem / removeItem). InMemorySecureStorage is for tests; on
Android, back it with the Keystore / EncryptedSharedPreferences.
#State management
#Writing data
Mutations are synchronous against local SQLite and return a MutationResult. Each
also enqueues an encrypted envelope for sync. Pass columns as vararg Pairs —
String, Int/Long, Double, Boolean, and null are accepted directly.
// insert — the engine generates the id and system columns
val result = engine.insert("entry",
"description" to "Coffee",
"amountCents" to -450,
"categoryId" to null,
)
when (result) {
is MutationResult.Ok -> println(result.value)
is MutationResult.Failure -> println(result.message)
}
// update — requires the id; updates only the fields you pass
engine.update("entry", "id" to id, "amountCents" to -500)
// upsert — insert or update by id
engine.upsert("entry", "id" to id, "description" to "Latte")Deletes are soft. Set the isDeleted system column and filter it out in your
queries:
engine.update("entry", "id" to id, "isDeleted" to sqliteTrue)Values are validated against the schema, so an invalid write returns
MutationResult.Failure(message) rather than corrupting the row.
#Querying data
Build a query once with createQuery from SQL plus a mapper that turns each Row
into a typed value. Double-quote table names so the reactive cache knows which
writes affect the query.
data class EntryRow(val id: String, val description: String, val amountCents: Double, val categoryName: String?)
val entriesQuery = engine.createQuery("""
select e.id as id, e.description as description, e.amountCents as amountCents,
c.name as categoryName
from "entry" e
left join "category" c on c.id = e.categoryId
where e."isDeleted" = 0
order by e."createdAt" desc
""") { row ->
EntryRow(row.string("id"), row.string("description"), row.double("amountCents"), row.stringOrNull("categoryName"))
}
val rows = engine.loadQuery(entriesQuery) // one-shot read → List<EntryRow>
val cached = engine.getQueryRows(entriesQuery) // cached snapshotRow has typed accessors: string / stringOrNull / int / long / double /
boolean / has.
#Reactive queries with Compose
engine.liveQuery(query) returns a StateFlow<List<Row>> — the Kotlin analog of
React's useQuery. It re-emits whenever a local write or an incoming sync changes
the data. engine.syncState is a StateFlow<SyncState> for status UI.
@Composable
fun Ledger(engine: SyncEngine) {
val entries by engine.liveQuery(entriesQuery).collectAsState()
val state by engine.syncState.collectAsState()
Column {
SyncBadge(state)
Button(onClick = { engine.insert("entry", "description" to "Tea", "amountCents" to -300) }) {
Text("Add")
}
LazyColumn {
items(entries) { e -> Text("${e.description}: ${e.amountCents}") }
}
}
}engine.liveQuery(q)→StateFlow<List<Row>>; collect it. Re-emits on a local write or an incoming sync. (The flow for a query is shared.)engine.syncState— aStateFlow<SyncState>(Syncing with a relay).engine.subscribeQuery(q) { … }— the low-level listenerliveQuerywraps.
#Identity management
#The owner & recovery key
A user's whole account derives from one secret. The engine manages it through
secureStorage and exposes the owner:
val owner = engine.appOwner() // suspend → AppOwner? (or engine.currentOwner)
owner?.id // public owner id used for relay routing
owner?.recoveryKey // the secret to back up — a recovery phrase, or a legacy sf1_ key
owner?.writeKey // bearer token presented to the relay (a capability)
owner?.mnemonic // the phrase, if the owner was derived from one
// UX: reveal the phrase on demand instead of gating onboarding on it. Simply
// Finance for iOS keeps "Show Recovery Phrase" in Settings behind Face ID; on
// Android use BiometricPrompt with the device credential as fallback. Show
// "Authenticating…" while the prompt is up, then offer Hide and Copy. Mark the
// copied clip sensitive (ClipDescription.EXTRA_IS_SENSITIVE) so Android 13+
// hides its preview. Prompt the user to save it when they first turn on sync,
// since that's when it matters.Surface owner.recoveryKey so the user can save it — it's the
only thing that can decrypt their data.
Interop and upgrades. New Kotlin mnemonic identities use the same
"SimplySync"SLIP-21 profile as TS/Swift. Existing Evolu-derived Android databases remain on their legacy profile; the engine detects and records that choice instead of silently changing owners. Full detail is in the interop boundary.
#Restore on a new device
Boot with the saved secret and sync from scratch:
engine.restoreAppOwner(recoveryKey) // refuses to wipe unless the relay confirms data
// UX, modelled on the Simply Finance iOS app's Restore screen:
// - Keep Restore disabled until the input looks complete (12 or 24 words, or an
// sf1_ key), and trim it before passing it in.
// - When ConnectivityManager reports no network, say so up front and offer a
// backup file instead, rather than letting the user wait for a timeout.
// - Flip an `isRestoring` state to show a spinner on the disabled button before
// launching the coroutine, so a second tap can't start a second restore.
// - On failure, show a plain message ("That phrase isn't valid.") and keep what
// the user typed so they can fix a word.
// - Once it returns, navigate straight into the app and show a small "Syncing
// your data…" banner until the first sync settles, instead of a full-screen
// wait. Writes made meanwhile are safe under last-writer-wins.
// Intentional switch to a new/empty owner: offer it only behind a confirm.
engine.restoreAppOwner(recoveryKey, force = true)For a BIP39 phrase, restore probes both the current cross-platform profile and the older Evolu-derived Android profile, then selects the owner that actually has relay data. If you already know the profile (including an offline forced restore), pass it explicitly:
engine.restoreAppOwner(
phrase,
MnemonicIdentityProfile.EVOLU_LEGACY_V1,
force = true,
)#Reset to a new owner
Reset mints a brand-new owner and wipes local data. It is the most
destructive call in the engine — the new owner has never synced anywhere, so no
relay can give the old data back. It refuses unless a relay is confirmed to hold
a copy, throwing IllegalStateException with the condition that failed
(unpushed changes, offline, no relay configured, relay holds nothing):
// UX: confirm first with a destructive dialog whose copy says exactly what goes
// and what stays, e.g. "This removes the recovery phrase and local data from this
// device only. Encrypted relay data is not deleted and can be restored with the
// phrase." Show progress while it runs; reloadApp replaces the UI.
try {
engine.resetAppOwner()
} catch (e: IllegalStateException) {
// Show e.message, let the user copy their recovery phrase or otherwise save
// the data, and only then force it.
engine.resetAppOwner(force = true)
}This port has no exportDatabase yet, so the backup step is the app's: the
refusal message is the prompt, and the app decides what "back up first" means on
that platform.
Both call your reloadApp afterward so the UI re-evaluates against the new owner.
#Syncing with a relay
#Connecting a relay & sync state
A relay is optional — without one the app is purely local-first. Configure one
(or several) via transports, at creation or at runtime:
engine.setTransports(listOf(Transport("https://relay.example.com")))Setting transports triggers an immediate sync and starts the background poller.
You can still force a sync (suspend):
engine.sync() // push the outbox, then pull + apply remote eventsWatch progress with engine.syncState (getSyncState() for a synchronous read):
SyncState |
Meaning |
|---|---|
Initial |
Idle, nothing synced yet. |
Syncing |
A push/pull is in flight. |
Synced(lastSyncedAt) |
Up to date. |
NotSynced(error, terminal, status) |
Failed. |
A terminal failure (auth/quota/bad request — 400/401/403/413) won't fix itself by retrying the same batch, so the engine backs the poller off.
With multiple relays, every one receives a full copy of the outbox and
SyncState stays Synced while any relay works. For the per-relay truth —
which relay is failing, and how full each one is against its per-owner storage
quota — use getRelayStatuses() (one GET /v1/status probe per relay):
for (s in engine.getRelayStatuses()) { // suspend
println("${s.url} reachable=${s.reachable} ${s.usedBytes}/${s.quotaBytes} bytes")
}Use the same HTTPS relay URL on every platform, whether the relay runs on Cloudflare or on your own server (Self-hosting).
#Differences from the React engine
- Raw SQL + a row mapper, not Kysely.
createQuerytakes a SQL string and a(Row) -> Tmapper instead of a typed query builder. StateFlow, not hooks. Compose collectsliveQuery(...).collectAsState()andsyncState.collectAsState()instead ofuseQuery/useSyncState.- Not ported: binary blobs and the fluent index builder. The
encrypted DB snapshot format is ported separately as
SnapshotCrypto. - A legacy mnemonic profile. New phrases interoperate with TS/Swift; Kotlin additionally retains Evolu-derived owners for safe Android upgrades (Identity management).
Next: Native clients (the cross-language map & interop boundary) · Sync & the relay (the wire protocol the engine speaks).