16 KiB
| name | description | metadata | ||
|---|---|---|---|---|
| powersync-kotlin | PowerSync Kotlin SDK — schema, queries, sync lifecycle, and backend connectors |
|
PowerSync Kotlin SDK
Load this when building a Kotlin app (Android, JVM, KMP) with PowerSync.
Table of Contents
- Installation
- Quick Setup
- Query Patterns
- Writes and Transactions
- Compose Integration
- Sync Status
- Sync Streams
- Background Sync (Android)
Best practices for building apps with the PowerSync Kotlin SDK.
Supported targets: Android, JVM, iOS, macOS, watchOS, tvOS.
Installation
Add to build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
api("com.powersync:core:$powersyncVersion")
}
}
}
For Supabase connector:
commonMain.dependencies {
implementation("com.powersync:connector-supabase:$powersyncVersion")
}
From v1.12.0+ the PowerSync SQLite core extension is statically linked into com.powersync:core for all Apple targets (iOS, macOS, tvOS, watchOS), matching Android and JVM. No CocoaPod or Swift Package dependency on powersync-sqlite-core is needed. Upgrading from an older version? Remove any pod("powersync-sqlite-core") entry and any powersync-sqlite-core-swift SPM dependency.
Quick Setup
1. Define Schema
import com.powersync.db.schema.Column
import com.powersync.db.schema.Index
import com.powersync.db.schema.IndexedColumn
import com.powersync.db.schema.Schema
import com.powersync.db.schema.Table
val todos = Table(
name = "todos",
columns = listOf(
Column.text("description"),
Column.text("list_id"),
Column.integer("completed"), // booleans as INTEGER (0/1)
Column.text("created_at"), // dates as ISO TEXT
),
indexes = listOf(Index("list_idx", listOf(IndexedColumn("list_id"))))
)
val lists = Table(
name = "lists",
columns = listOf(
Column.text("name"),
Column.text("owner_id"),
Column.text("created_at"),
)
)
val schema = Schema(todos, lists)
Column types: Column.text, Column.integer, Column.real only — no boolean, date, or JSON native types.
Do NOT declare an id column — PowerSync adds it automatically as TEXT PRIMARY KEY. Declaring it throws an AssertionError.
No migrations required. Schema changes apply on next open. New columns start null; removed columns become inaccessible (data remains). Renaming = add new + remove old (data loss).
Special Table Types
// Local-only — not synced, not uploaded, persists across restarts
val drafts = Table.localOnly("drafts", listOf(Column.text("content")))
// Insert-only — writes uploaded, server never sends data back
val logs = Table.insertOnly("logs", listOf(Column.text("message")))
// Track previous values — available as entry.previousValues in uploadData
val todos = Table(
name = "todos",
columns = listOf(Column.text("description"), Column.integer("completed")),
trackPreviousValues = TrackPreviousValuesOptions(
columnFilter = listOf("completed"), // null = track all columns
onlyWhenChanged = true
)
)
// Track write metadata — adds _metadata column, available as entry.metadata in uploadData
val tasks = Table(
name = "tasks",
columns = listOf(Column.text("title")),
trackMetadata = true
)
// Ignore no-op updates (UPDATE that changes no values)
val items = Table(
name = "items",
columns = listOf(Column.text("name")),
ignoreEmptyUpdates = true
)
2. Create Backend Connector
import com.powersync.connectors.PowerSyncBackendConnector
import com.powersync.connectors.PowerSyncCredentials
import com.powersync.PowerSyncDatabase
import com.powersync.db.crud.UpdateType
class MyConnector : PowerSyncBackendConnector() {
override suspend fun fetchCredentials(): PowerSyncCredentials {
// Always fetch fresh credentials — do not cache stale tokens
return PowerSyncCredentials(
endpoint = "https://your-instance.powersync.journeyapps.com",
token = myAuthService.getToken(),
)
}
override suspend fun uploadData(database: PowerSyncDatabase) {
val transaction = database.getNextCrudTransaction() ?: return
try {
for (entry in transaction.crud) {
when (entry.op) {
UpdateType.PUT -> api.upsert(entry.table, entry.id, entry.opData)
UpdateType.PATCH -> api.update(entry.table, entry.id, entry.opData)
UpdateType.DELETE -> api.delete(entry.table, entry.id)
}
}
transaction.complete(null) // MUST call — otherwise the same transaction is returned forever
} catch (e: Exception) {
throw e // PowerSync backs off and retries automatically
}
}
}
Fatal upload errors: If uploadData always throws for a bad record, the queue stalls permanently. Detect unrecoverable errors (e.g. constraint violations) and call transaction.complete(null) to discard them. The Supabase connector handles Postgres error classes 22, 23, and 42501 automatically.
CrudEntry Fields
entry.id // String — row ID
entry.op // UpdateType — PUT | PATCH | DELETE
entry.opData // SqliteRow? — changed columns (null for DELETE)
entry.table // String — table name
entry.transactionId // Int? — groups ops from the same writeTransaction()
entry.previousValues // SqliteRow? — requires trackPreviousValues on table
entry.metadata // String? — requires trackMetadata on table
Op semantics: PUT = full insert/replace (all non-null columns), PATCH = partial update (changed columns only), DELETE = deletion (opData is null).
For batching multiple transactions at once, use database.getCrudBatch(limit) or database.getCrudTransactions(). Both follow the same complete() contract.
Supabase Connector
val connector = SupabaseConnector(
supabaseUrl = "https://your-project.supabase.co",
supabaseKey = "your-anon-key",
powerSyncEndpoint = "https://your-instance.powersync.journeyapps.com",
)
SupabaseConnector is open — override uploadCrudEntry and handleError for custom behaviour.
3. Initialize and Connect
val db = PowerSyncDatabase(
factory = factory, // platform-specific PersistentConnectionFactory
schema = schema,
dbFilename = "app.db",
scope = coroutineScope,
)
// Starts sync stream and uploadData loop in the background (non-blocking)
db.connect(connector)
// Pass sync parameters to the server if needed
db.connect(
connector = connector,
params = mapOf("userId" to JsonParam.String("abc123")),
)
// Wait for first sync before showing data-dependent UI
db.waitForFirstSync()
Use a single PowerSyncDatabase instance per database file. Multiple instances for the same file cause lock contention and missed watch updates — share via dependency injection.
Disconnect and Clear
db.disconnect() // stop syncing, keep local data
db.disconnectAndClear() // stop syncing, wipe synced tables (e.g. on sign-out)
db.disconnectAndClear(clearLocal = false, soft = true) // soft-wipe: same user can re-sync faster
db.close() // cannot be reused after this
disconnect() — temporary offline, token refresh, app backgrounding. Safe to reconnect as the same user. disconnectAndClear() — user sign-out or account switch, prevents stale data leaking to the next user. close() — app termination, instance cannot be reused after this call.
Query Patterns
Watch Queries (Reactive)
import com.powersync.db.getString
import com.powersync.db.getStringOptional
import com.powersync.db.getBoolean
fun watchTodos(listId: String): Flow<List<TodoItem>> =
db.watch(
sql = "SELECT * FROM todos WHERE list_id = ? ORDER BY id",
parameters = listOf(listId),
) { cursor ->
TodoItem(
id = cursor.getString("id"),
description = cursor.getString("description"),
completed = cursor.getBoolean("completed"),
completedAt = cursor.getStringOptional("completed_at"),
)
}
Collect in a ViewModel:
viewModelScope.launch {
db.watch("SELECT * FROM lists") { cursor ->
ListItem(id = cursor.getString("id"), name = cursor.getString("name"))
}.collect { _uiState.value = it }
}
React to table changes without re-running a query:
db.onChange(tables = setOf("todos", "lists")).collect { changedTables -> }
One-Time Queries
val todos = db.getAll("SELECT * FROM todos WHERE list_id = ?", listOf(listId)) { cursor ->
TodoItem(id = cursor.getString("id"), ...)
}
val todo = db.get("SELECT * FROM todos WHERE id = ?", listOf(id)) { cursor -> TodoItem(...) } // throws if not found
val todo = db.getOptional("SELECT * FROM todos WHERE id = ?", listOf(id)) { cursor -> TodoItem(...) } // null if not found
SqlCursor (by name)
cursor.getString("description") // throws if null
cursor.getStringOptional("completed_at") // null if null
cursor.getLong("count")
cursor.getLongOptional("optional_int")
cursor.getDouble("amount")
cursor.getBoolean("completed")
cursor.getBytes("blob_data")
Import extension functions from com.powersync.db.
Writes and Transactions
// Single operation — uuid() is a PowerSync built-in SQLite function
db.execute(
"INSERT INTO lists (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)",
listOf("My List", userId)
)
// Multiple operations atomically — auto-commits, auto-rollbacks on exception
db.writeTransaction { tx ->
tx.execute("DELETE FROM lists WHERE id = ?", listOf(listId))
tx.execute("DELETE FROM todos WHERE list_id = ?", listOf(listId))
}
ID generation — every table has id TEXT PRIMARY KEY added automatically:
db.execute("INSERT INTO todos (id, description) VALUES (uuid(), ?)", listOf("Buy milk"))
// or generate in Kotlin:
import com.benasher44.uuid.uuid4
db.execute("INSERT INTO todos (id, description) VALUES (?, ?)", listOf(uuid4().toString(), "Buy milk"))
Compose Integration
commonMain.dependencies {
implementation("com.powersync:compose:$powersyncVersion")
}
import com.powersync.compose.composeState
@Composable
fun GuardBySync(db: PowerSyncDatabase, content: @Composable () -> Unit) {
val status by db.currentStatus.composeState()
if (status.hasSynced == true) {
content()
return
}
val progress = status.downloadProgress
if (progress != null) {
LinearProgressIndicator(progress = progress.fraction)
Text("Downloaded ${progress.downloadedOperations} of ${progress.totalOperations}")
} else {
LinearProgressIndicator() // indeterminate while DB is opening
}
}
Sync Status
val status = db.currentStatus
status.connected // Boolean — sync stream is active
status.connecting // Boolean
status.downloading // Boolean
status.uploading // Boolean
status.hasSynced // Boolean? — null = DB still opening; true = at least one full sync completed
status.lastSyncedAt // Instant?
status.downloadProgress // SyncDownloadProgress? — non-null only while downloading
status.anyError // Any? — uploadError ?: downloadError
Observe as a Flow:
db.currentStatus.asFlow().collect { status: SyncStatusData -> }
hasSynced persists across app restarts once set.
Sync Priorities
Buckets can be assigned priorities (lower number = higher priority) on the server. Higher-priority data syncs first. See Prioritized Sync.
db.waitForFirstSync(priority = StreamPriority(1))
val entry = db.currentStatus.statusForPriority(StreamPriority(1))
entry.hasSynced // Boolean?
entry.lastSyncedAt // Instant?
val progress = status.downloadProgress?.untilPriority(StreamPriority(1))
progress?.fraction // Float 0.0–1.0
progress?.downloadedOperations // Int
progress?.totalOperations // Int
Sync Streams
Sync Streams are the recommended way to define what data syncs to each client. See Sync Config for server-side configuration (YAML definitions, parameters, CTEs) and Client-Side Usage for full Kotlin examples.
If auto_subscribe is not set to true in the sync config, subscribe to streams from client code:
import com.powersync.utils.JsonParam
// Create a stream handle and subscribe
val stream = db.syncStream(
name = "my_orders",
parameters = mapOf("userId" to JsonParam.String(currentUserId)),
)
val subscription = stream.subscribe(
ttl = 1.hours, // optional — keep data alive after unsubscribe
priority = StreamPriority(1), // optional — lower number = higher priority
)
// Wait for this specific stream to complete its first sync
subscription.waitForFirstSync()
// Check stream status
val streamStatus = db.currentStatus.forStream(subscription)
streamStatus?.subscription?.hasSynced // Boolean
streamStatus?.subscription?.lastSyncedAt // Instant?
streamStatus?.subscription?.active // Boolean
streamStatus?.subscription?.isDefault // Boolean
streamStatus?.subscription?.expiresAt // Instant?
// Unsubscribe when done — TTL starts running after this
subscription.unsubscribe()
// Or unsubscribe all subscriptions for a stream
stream.unsubscribeAll()
Same stream name with different parameters creates separate subscriptions. Subscribing while offline is supported — subscriptions are tracked locally and sent on next connect.
Background Sync (Android)
Share a single PowerSyncDatabase instance between the UI and any foreground service — do not create separate instances or use separate processes.
class SyncService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val db = (application as MyApp).database
serviceScope.launch { db.connect(connector) }
return START_STICKY
}
override fun onDestroy() { serviceScope.launch { db.disconnect() } }
}
Full example: demos/supabase-todolist/androidBackgroundSync
ORM Integrations
- Room (beta) — typed queries with compile-time validation:
com.powersync:powersync-room:$powersyncVersion· Docs - SQLDelight (beta) —
PowerSyncDriverimplementsSqlDriver:com.powersync:powersync-sqldelight:$powersyncVersion· Docs
Additional Resources
Only read these if the content above does not provide enough context for the task.
- Kotlin API docs — all available APIs
- Full SDK reference — full SDK documentation
- supabase-todolist — PowerSync + Supabase (KMP) example
- android-supabase-todolist — PowerSync + Supabase (Android) example