453 lines
16 KiB
Markdown
453 lines
16 KiB
Markdown
---
|
||
name: powersync-kotlin
|
||
description: PowerSync Kotlin SDK — schema, queries, sync lifecycle, and backend connectors
|
||
metadata:
|
||
tags: kotlin, android, ios, sqlite, offline-first
|
||
---
|
||
|
||
# PowerSync Kotlin SDK
|
||
|
||
> **Load this when** building a Kotlin app (Android, JVM, KMP) with PowerSync.
|
||
|
||
## Table of Contents
|
||
- [Installation](#installation)
|
||
- [Quick Setup](#quick-setup)
|
||
- [Query Patterns](#query-patterns)
|
||
- [Writes and Transactions](#writes-and-transactions)
|
||
- [Compose Integration](#compose-integration)
|
||
- [Sync Status](#sync-status)
|
||
- [Sync Streams](#sync-streams)
|
||
- [Background Sync (Android)](#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
|
||
kotlin {
|
||
sourceSets {
|
||
commonMain.dependencies {
|
||
api("com.powersync:core:$powersyncVersion")
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
For Supabase connector:
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
// 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
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
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)
|
||
|
||
```kotlin
|
||
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:
|
||
|
||
```kotlin
|
||
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:
|
||
|
||
```kotlin
|
||
db.onChange(tables = setOf("todos", "lists")).collect { changedTables -> }
|
||
```
|
||
|
||
### One-Time Queries
|
||
|
||
```kotlin
|
||
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)
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
// 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:
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
commonMain.dependencies {
|
||
implementation("com.powersync:compose:$powersyncVersion")
|
||
}
|
||
```
|
||
|
||
```kotlin
|
||
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
|
||
|
||
```kotlin
|
||
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:
|
||
|
||
```kotlin
|
||
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](https://docs.powersync.com/usage/use-case-examples/prioritized-sync.md).
|
||
|
||
```kotlin
|
||
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](references/sync-config.md) for server-side configuration (YAML definitions, parameters, CTEs) and [Client-Side Usage](https://docs.powersync.com/sync/streams/client-usage.md) for full Kotlin examples.
|
||
|
||
If `auto_subscribe` is not set to `true` in the sync config, subscribe to streams from client code:
|
||
|
||
```kotlin
|
||
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.
|
||
|
||
```kotlin
|
||
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`](https://github.com/powersync-ja/powersync-kotlin/tree/main/demos/supabase-todolist/androidBackgroundSync)
|
||
|
||
## ORM Integrations
|
||
|
||
- **Room** (beta) — typed queries with compile-time validation: `com.powersync:powersync-room:$powersyncVersion` · [Docs](https://docs.powersync.com/client-sdks/orms/kotlin/room.md)
|
||
- **SQLDelight** (beta) — `PowerSyncDriver` implements `SqlDriver`: `com.powersync:powersync-sqldelight:$powersyncVersion` · [Docs](https://docs.powersync.com/client-sdks/orms/kotlin/sqldelight.md)
|
||
|
||
## Additional Resources
|
||
|
||
Only read these if the content above does not provide enough context for the task.
|
||
|
||
- [Kotlin API docs](https://powersync-ja.github.io/powersync-kotlin/) — all available APIs
|
||
- [Full SDK reference](https://docs.powersync.com/client-sdk-references/kotlin-multiplatform.md) — full SDK documentation
|
||
- [supabase-todolist](https://github.com/powersync-ja/powersync-kotlin/tree/main/demos/supabase-todolist) — PowerSync + Supabase (KMP) example
|
||
- [android-supabase-todolist](https://github.com/powersync-ja/powersync-kotlin/tree/main/demos/android-supabase-todolist) — PowerSync + Supabase (Android) example
|