From 0d89b6264af192657c55ef56179dc08e09d4106d Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 10 Sep 2025 10:38:25 +0200 Subject: [PATCH 01/11] Room support --- build.gradle.kts | 2 + .../DatabaseDriverFactory.android.kt | 4 + .../DatabaseDriverFactory.appleNonWatchOs.kt | 5 + .../com/powersync/DatabaseDriverFactory.kt | 13 ++ .../powersync/DatabaseDriverFactory.jvm.kt | 5 + .../DatabaseDriverFactory.watchos.kt | 6 + gradle/libs.versions.toml | 10 +- integrations/room/README.md | 58 ++++++++ integrations/room/build.gradle.kts | 89 ++++++++++++ .../integrations/room/utils.android.kt | 7 + .../integrations/room/PowerSyncExtension.kt | 13 ++ .../integrations/room/RoomConnectionPool.kt | 130 ++++++++++++++++++ .../integrations/room/PowerSyncRoomTest.kt | 127 +++++++++++++++++ .../integrations/room/TestDatabase.kt | 52 +++++++ .../com/powersync/integrations/room/utils.kt | 5 + .../powersync/integrations/room/utils.jvm.kt | 9 ++ .../integrations/room/utils.native.kt | 8 ++ .../download-core-extension/build.gradle.kts | 45 ++++++ .../src/main/kotlin/SharedBuildPlugin.kt | 66 +++------ settings.gradle.kts | 3 + 20 files changed, 609 insertions(+), 48 deletions(-) create mode 100644 integrations/room/README.md create mode 100644 integrations/room/build.gradle.kts create mode 100644 integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/utils.android.kt create mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncExtension.kt create mode 100644 integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt create mode 100644 integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt create mode 100644 integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt create mode 100644 integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/utils.kt create mode 100644 integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt create mode 100644 integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt create mode 100644 internal/download-core-extension/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index b1ec5971..20d4aceb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,8 @@ plugins { alias(libs.plugins.keeper) apply false alias(libs.plugins.kotlin.atomicfu) apply false alias(libs.plugins.cocoapods) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.androidx.room) apply false id("org.jetbrains.dokka") version libs.versions.dokkaBase id("dokka-convention") } diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index ac3f1319..abcc0a2d 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -21,3 +21,7 @@ public actual class DatabaseDriverFactory( public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } + +public actual fun resolvePowerSyncLoadableExtensionPath(): String? { + return "libpowersync.so" +} diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index b2e8a15e..6215d3bb 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -21,3 +21,8 @@ public actual class DatabaseDriverFactory { return db } } + +@ExperimentalPowerSyncAPI +public actual fun resolvePowerSyncLoadableExtensionPath(): String? { + return powerSyncExtensionPath +} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 6be048e5..53781075 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -17,6 +17,19 @@ public expect class DatabaseDriverFactory { ): SQLiteConnection } +/** + * Resolves a path to the loadable PowerSync core extension library. + * + * This library must be loaded on all databases using the PowerSync SDK. On platforms where the + * extension is linked statically (only watchOS at the moment), this returns `null`. + * + * When using the PowerSync SDK directly, there is no need to invoke this method. It is intended for + * configuring external database connections not managed by PowerSync to work with the PowerSync + * SDK. + */ +@ExperimentalPowerSyncAPI +public expect fun resolvePowerSyncLoadableExtensionPath(): String? + @OptIn(ExperimentalPowerSyncAPI::class) internal fun openDatabase( factory: DatabaseDriverFactory, diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 5c759511..3eb3bed1 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -20,3 +20,8 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { } private val powersyncExtension: String by lazy { extractLib("powersync") } + +@ExperimentalPowerSyncAPI +public actual fun resolvePowerSyncLoadableExtensionPath(): String? { + return powersyncExtension +} diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index d92a9a82..46c1878a 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -31,3 +31,9 @@ private val didLoadExtension by lazy { true } + +@ExperimentalPowerSyncAPI +public actual fun resolvePowerSyncLoadableExtensionPath(): String? { + didLoadExtension + return null +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a0ed505..8d18a138 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,9 +10,11 @@ java = "17" # Dependencies kermit = "2.0.8" -kotlin = "2.2.10" +kotlin = "2.2.10" # Note: When updating, always update the first part of the ksp version too +ksp = "2.2.10-2.0.2" coroutines = "1.10.2" kotlinx-datetime = "0.7.1" +serialization = "1.9.0" kotlinx-io = "0.8.0" ktor = "3.2.3" uuid = "0.8.4" @@ -30,6 +32,7 @@ compose-preview = "1.9.0" compose-lifecycle = "2.9.2" androidxSqlite = "2.6.0-rc02" androidxSplashscreen = "1.0.1" +room = "2.8.0-rc02" # plugins android-gradle-plugin = "8.12.1" @@ -88,6 +91,7 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } @@ -97,6 +101,8 @@ supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref supabase-storage = { module = "io.github.jan-tennert.supabase:storage-kt", version.ref = "supabase" } androidx-sqlite-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidxSqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidxSqlite" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } # Sample - Android androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } @@ -143,3 +149,5 @@ keeper = { id = "com.slack.keeper", version.ref = "keeper" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +androidx-room = { id = "androidx.room", version.ref = "room" } diff --git a/integrations/room/README.md b/integrations/room/README.md new file mode 100644 index 00000000..7f26b422 --- /dev/null +++ b/integrations/room/README.md @@ -0,0 +1,58 @@ +# PowerSync Room integration + +This module provides the ability to use PowerSync with Room databases. This module aims for complete +Room support, meaning that: + +1. Changes synced from PowerSync automatically update your Room `Flow`s. +2. Room and PowerSync cooperate on the write connection, avoiding "database is locked errors". +3. Changes from Room trigger a CRUD upload. + +## Setup + +PowerSync can use an existing Room database, provided that the PowerSync core SQLite extension has +been loaded. To do that: + +1. Add a dependency on `androidx.sqlite:sqlite-bundled`. Using the SQLite version from the Android + framework will not work as it doesn't support loading extensions. +2. On your `RoomDatabase.Builder`, call `setDriver()` with a PowerSync-enabled driver: + ```Kotlin + val driver = BundledSQLiteDriver().also { + it.loadPowerSyncExtension() // Extension method by this module + } + + Room.databaseBuilder(...).setDriver(driver).build() + ``` +3. Configure raw tables for your Room databases. + +After these steps, you can open your Room database like you normally would. Then, you can use the +following method to obtain a `PowerSyncDatabase` instance that is backed by Room: + +```Kotlin +val pool = RoomConnectionPool(yourRoomDatabase) +val powersync = PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = Schema(...), // With Room, you need to use raw tables + identifier = "databaseName", // Prefer to use the same path/name as your Room database + logger = Logger, +) + +powersync.connect(...) +``` + +Changes from PowerSync (regardless of whether they've been made with `powersync.execute` or from a +sync operation) will automatically trigger updates in Room. + +To also transfer local writes to PowerSync, you need to + +1. Create triggers on your Room tables to insert into `ps_crud` (see the PowerSync documentation on + raw tables for details). +2. Listen for Room changes and invoke a helper method to transfer them to PowerSync: + ```Kotlin + yourRoomDatabase.getCoroutineScope().launch { + // list all your tables here + yourRoomDatabase.invalidationTracker.createFlow("users", "groups", /*...*/).collect { + pool.transferRoomUpdatesToPowerSync() + } + } + ``` diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts new file mode 100644 index 00000000..5dad0af4 --- /dev/null +++ b/integrations/room/build.gradle.kts @@ -0,0 +1,89 @@ +import com.powersync.plugins.sonatype.setupGithubRepository +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlinter) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlinSerialization) + id("com.powersync.plugins.sonatype") + id("dokka-convention") + id("com.powersync.plugins.sharedbuild") +} + +kotlin { + powersyncTargets() + + explicitApi() + + sourceSets { + all { + languageSettings { + optIn("com.powersync.ExperimentalPowerSyncAPI") + } + } + + commonMain.dependencies { + api(project(":core")) + api(libs.androidx.room.runtime) + api(libs.androidx.sqlite.bundled) + + implementation(libs.kotlinx.serialization.json) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.io) + implementation(libs.test.kotest.assertions) + implementation(libs.test.coroutines) + implementation(libs.test.turbine) + + implementation(libs.androidx.sqlite.bundled) + } + } +} + +dependencies { + // We use a room database for testing, so we apply the symbol processor on the test target. + val targets = listOf( + "jvm", + "macosArm64", + "macosX64", + "iosSimulatorArm64", + "iosX64", + "tvosSimulatorArm64", + "tvosX64", + "watchosSimulatorArm64", + "watchosX64" + ) + + targets.forEach { target -> + val capitalized = target.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + + add("ksp${capitalized}Test", libs.androidx.room.compiler) + } +} + +android { + namespace = "com.powersync.compose" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + } + kotlin { + jvmToolchain(17) + } +} + +setupGithubRepository() + +dokka { + moduleName.set("PowerSync Room Integration") +} diff --git a/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/utils.android.kt b/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/utils.android.kt new file mode 100644 index 00000000..0ac6ee4e --- /dev/null +++ b/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/utils.android.kt @@ -0,0 +1,7 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase + +actual fun createDatabaseBuilder(): RoomDatabase.Builder { + TODO("Android unit tests are unsupported, we test on JVM instead") +} diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncExtension.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncExtension.kt new file mode 100644 index 00000000..5c2d21c5 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/PowerSyncExtension.kt @@ -0,0 +1,13 @@ +package com.powersync.integrations.room + +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import com.powersync.resolvePowerSyncLoadableExtensionPath + +/** + * Configures this driver to load the PowerSync core SQLite extension on connections it opens. + */ +public fun BundledSQLiteDriver.loadPowerSyncExtension() { + resolvePowerSyncLoadableExtensionPath()?.let { + addExtension(it, "sqlite3_powersync_init") + } +} diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt new file mode 100644 index 00000000..978f1399 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -0,0 +1,130 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase +import androidx.room.Transactor +import androidx.room.execSQL +import androidx.room.useReaderConnection +import androidx.room.useWriterConnection +import androidx.sqlite.SQLiteStatement +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlin.coroutines.CoroutineContext + +/** + * A [SQLiteConnectionPool] implementation for the PowerSync SDK that is backed by a [RoomDatabase]. + * + * An instance of this class can be passed to [com.powersync.PowerSyncDatabase.opened], allowing + * PowerSync to wrap Room databases. + * + * Writes made from the wrapped PowerSync database, including writes made for the sync process, are + * forwarded to Room and will update your flows automatically. + * On the other hand, the PowerSync SDK needs to be notified about updates in Room. For that, use + * the [transferRoomUpdatesToPowerSync] method as a collector of a Room flow listening on all your + * tables. + */ +public class RoomConnectionPool( + private val db: RoomDatabase, +): SQLiteConnectionPool { + private val _updates = MutableSharedFlow>() + private var hasInstalledUpdateHook = false + + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { + // We can't obtain a list of all connections on Room. That's fine though, we expect this to + // be used with raw tables, and withAllConnections is only used to apply a PowerSync schema. + write { + action(it, emptyList()) + } + } + + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { + return db.useReaderConnection { + callback(RoomTransactionLease(it, currentCoroutineContext())) + } + } + + /** + * Makes pending updates tracked by Room's invalidation tracker available to the PowerSync + * database, updating flows and triggering CRUD uploads. + */ + public suspend fun transferRoomUpdatesToPowerSync() { + write { + // The end of the write callback invokes powersync_update_hooks('get') for this + } + } + + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { + return db.useWriterConnection { + if (!hasInstalledUpdateHook) { + hasInstalledUpdateHook = true + it.execSQL("SELECT powersync_update_hooks('install')") + } + + try { + callback(RoomTransactionLease(it, currentCoroutineContext())) + } finally { + val changed = it.usePrepared("SELECT powersync_update_hooks('get')") { stmt -> + check(stmt.step()) + json.decodeFromString>(stmt.getText(0)) + } + + val userTables = changed.filter { tbl -> + !tbl.startsWith("ps_") && !tbl.startsWith("room_") + }.toTypedArray() + + if (userTables.isNotEmpty()) { + db.invalidationTracker.refresh(*userTables) + } + + _updates.emit(changed) + } + } + } + + override val updates: SharedFlow> + get() = _updates + + override suspend fun close() { + // Noop, Room database managed independently + } + + private companion object { + val json = Json {} + } +} + +private class RoomTransactionLease( + private val transactor: Transactor, + /** + * The context to use for [runBlocking] calls to avoid the "Attempted to use connection on a + * different coroutine" error. + */ + private val context: CoroutineContext +): SQLiteConnectionLease { + override suspend fun isInTransaction(): Boolean { + return transactor.inTransaction() + } + + override suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R + ): R { + return transactor.usePrepared(sql, block) + } + + override fun isInTransactionSync(): Boolean { + return runBlocking(context) { + isInTransaction() + } + } + + override fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { + return runBlocking(context) { + usePrepared(sql, block) + } + } +} diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt new file mode 100644 index 00000000..243f4537 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -0,0 +1,127 @@ +package com.powersync.integrations.room + +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import app.cash.turbine.turbineScope +import co.touchlab.kermit.Logger +import co.touchlab.kermit.loggerConfigInit +import com.powersync.PowerSyncDatabase +import com.powersync.db.getString +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class PowerSyncRoomTest { + + lateinit var database: TestDatabase + + @BeforeTest + fun setup() { + val driver = BundledSQLiteDriver().also { + it.loadPowerSyncExtension() + } + + database = createDatabaseBuilder().setDriver(driver).build() + } + + @AfterTest + fun tearDown() { + database.close() + } + + @Test + fun roomWritePowerSyncRead() = runTest { + database.userDao().create(User(id = "test", name = "Test user")) + val logger = Logger(loggerConfigInit()) + + val powersync = PowerSyncDatabase.opened( + pool = RoomConnectionPool(database), + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + val row = powersync.get("SELECT * FROM user") { + User( + id = it.getString("id"), + name = it.getString("name") + ) + } + row shouldBe User(id = "test", name = "Test user") + + powersync.close() + } + + @Test + fun roomWritePowerSyncWatch() = runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + turbineScope { + val turbine = powersync.watch("SELECT * FROM user") { + User( + id = it.getString("id"), + name = it.getString("name") + ) + }.testIn(this) + + turbine.awaitItem() shouldHaveSize 0 + database.userDao().create(User("id", "name")) + pool.transferRoomUpdatesToPowerSync() // TODO: Would be cool if this wasn't necessary + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } + } + + @Test + fun powersyncWriteRoomRead() = runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + database.userDao().getAll() shouldHaveSize 0 + powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) + database.userDao().getAll() shouldHaveSize 1 + } + + @Test + fun powersyncWriteRoomWatch() = runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + turbineScope { + val turbine = database.userDao().watchAll().testIn(this) + turbine.awaitItem() shouldHaveSize 0 + + powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } + } +} diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt new file mode 100644 index 00000000..20fc1d62 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -0,0 +1,52 @@ +package com.powersync.integrations.room + +import androidx.room.ConstructedBy +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.RoomDatabaseConstructor +import com.powersync.db.schema.Schema +import kotlinx.coroutines.flow.Flow + +@Entity +data class User( + @PrimaryKey val id: String, + val name: String, +) + +@Dao +interface UserDao { + @Insert + suspend fun create(user: User) + + @Query("SELECT * FROM user") + suspend fun getAll(): List + + @Query("SELECT * FROM user") + fun watchAll(): Flow> + + @Delete + suspend fun delete(user: User) +} + + +@Database(entities = [User::class], version = 1) +@ConstructedBy(TestDatabaseConstructor::class) +abstract class TestDatabase: RoomDatabase() { + abstract fun userDao(): UserDao + + companion object { + val schema = Schema() + } +} + +// The Room compiler generates the `actual` implementations. +@Suppress("KotlinNoActualForExpect") +expect object TestDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): TestDatabase +} diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/utils.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/utils.kt new file mode 100644 index 00000000..5026631b --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/utils.kt @@ -0,0 +1,5 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase + +expect fun createDatabaseBuilder(): RoomDatabase.Builder diff --git a/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt new file mode 100644 index 00000000..fb4485ad --- /dev/null +++ b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt @@ -0,0 +1,9 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver + +actual fun createDatabaseBuilder(): RoomDatabase.Builder { + return Room.inMemoryDatabaseBuilder() +} diff --git a/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt b/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt new file mode 100644 index 00000000..c6d1d6c5 --- /dev/null +++ b/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt @@ -0,0 +1,8 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import androidx.room.RoomDatabase + +actual fun createDatabaseBuilder(): RoomDatabase.Builder { + return Room.inMemoryDatabaseBuilder() +} diff --git a/internal/download-core-extension/build.gradle.kts b/internal/download-core-extension/build.gradle.kts new file mode 100644 index 00000000..378f3f6c --- /dev/null +++ b/internal/download-core-extension/build.gradle.kts @@ -0,0 +1,45 @@ +import de.undercouch.gradle.tasks.download.Download + +// The purpose of this project is to share downloaded PowerSync artifacts between multiple other +// projects for testing. This avoids downloading them multiple times. +// This pattern has been adopted from https://docs.gradle.org/current/samples/sample_cross_project_output_sharing.html + +plugins { + alias(libs.plugins.downloadPlugin) +} + +val downloadPowersyncFramework by tasks.registering(Download::class) { + val url = libs.versions.powersync.core.map { coreVersion -> + "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/powersync-sqlite-core.xcframework.zip" + } + val binariesFolder = project.layout.buildDirectory.dir("binaries") + + src(url) + dest(binariesFolder.map { it.file("framework/powersync-sqlite-core.xcframework.zip") }) + onlyIfModified(true) +} + +val unzipPowerSyncFramework by tasks.registering(Exec::class) { + inputs.files(downloadPowersyncFramework.map { it.outputFiles }) + + val zipfile = downloadPowersyncFramework.get().dest + val destination = File(zipfile.parentFile, "extracted") + doFirst { + destination.deleteRecursively() + destination.mkdir() + } + + // We're using unzip here because the Gradle copy task doesn't support symlinks. + executable = "unzip" + args(zipfile.absolutePath) + workingDir(destination) + outputs.dir(destination) +} + +val powersyncFrameworkConfiguration by configurations.creating { + isCanBeResolved = false +} + +artifacts { + add(powersyncFrameworkConfiguration.name, unzipPowerSyncFramework) +} diff --git a/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt b/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt index 7905d11f..aff24451 100644 --- a/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt +++ b/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt @@ -1,56 +1,26 @@ package com.powersync.plugins.sharedbuild -import de.undercouch.gradle.tasks.download.Download +import org.gradle.kotlin.dsl.getValue import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.api.tasks.Exec +import org.gradle.api.file.FileCollection +import org.gradle.kotlin.dsl.creating +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.project import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.konan.target.Family -import java.io.File class SharedBuildPlugin : Plugin { override fun apply(project: Project) { - val binariesFolder = project.layout.buildDirectory.dir("binaries") + val powersyncFrameworkConfiguration by project.configurations.creating { + isCanBeConsumed = false + } - val coreVersion = - project.extensions - .getByType(VersionCatalogsExtension::class.java) - .named("libs") - .findVersion("powersync.core") - .get() - .toString() - - val frameworkUrl = - "https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v$coreVersion/powersync-sqlite-core.xcframework.zip" - - val downloadPowersyncFramework = - project.tasks.register("downloadPowersyncFramework", Download::class.java) { - src(frameworkUrl) - dest(binariesFolder.map { it.file("framework/powersync-sqlite-core.xcframework.zip") }) - onlyIfModified(true) - } - - val unzipPowersyncFramework = - project.tasks.register("unzipPowersyncFramework", Exec::class.java) { - dependsOn(downloadPowersyncFramework) - - val zipfile = downloadPowersyncFramework.get().dest - inputs.file(zipfile) - val destination = File(zipfile.parentFile, "extracted") - doFirst { - destination.deleteRecursively() - destination.mkdir() - } - - // We're using unzip here because the Gradle copy task doesn't support symlinks. - executable = "unzip" - args(zipfile.absolutePath) - workingDir(destination) - outputs.dir(destination) - } + project.dependencies { + powersyncFrameworkConfiguration(project(path = ":internal:download-core-extension", configuration = "powersyncFrameworkConfiguration")) + } project.extensions .getByType(KotlinMultiplatformExtension::class.java) @@ -69,14 +39,16 @@ class SharedBuildPlugin : Plugin { binaries .withType() .configureEach { - linkTaskProvider.configure { dependsOn(unzipPowersyncFramework) } + val sharedFiles: FileCollection = powersyncFrameworkConfiguration + + linkTaskProvider.configure { + inputs.files(sharedFiles) + } linkerOpts("-framework", "powersync-sqlite-core") - val frameworkRoot = - binariesFolder - .map { it.dir("framework/extracted/powersync-sqlite-core.xcframework/$abiName") } - .get() - .asFile.path + val frameworkRoot = sharedFiles.singleFile + .resolve("powersync-sqlite-core.xcframework/$abiName") + .path linkerOpts("-F", frameworkRoot) linkerOpts("-rpath", frameworkRoot) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4855b5c7..a005f022 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,9 +27,12 @@ plugins { rootProject.name = "powersync-root" +include(":internal:download-core-extension") + include(":core") include(":core-tests-android") include(":connectors:supabase") +include(":integrations:room") include(":static-sqlite-driver") include(":PowerSyncKotlin") From 5f58ffe8cdc8d0199b318dd5a8015fa0fc54cf39 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 10 Sep 2025 10:59:00 +0200 Subject: [PATCH 02/11] Add changelogs # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 1 + .../com/powersync/DatabaseDriverFactory.android.kt | 4 +--- .../DatabaseDriverFactory.appleNonWatchOs.kt | 4 +--- .../com/powersync/DatabaseDriverFactory.jvm.kt | 4 +--- integrations/room/README.md | 3 +++ integrations/room/gradle.properties | 3 +++ .../src/main/kotlin/SharedBuildPlugin.kt | 13 +++++++------ 7 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 integrations/room/gradle.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a95feb..3a03a821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Add `rawConnection` getter to `ConnectionContext`, which is a `SQLiteConnection` instance from `androidx.sqlite` that can be used to step through statements in a custom way. * Fix an issue where `watch()` would run queries more often than intended. +* Add an integration for the Room database library ([readme](integrations/room/README.md)). ## 1.5.1 diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index abcc0a2d..16358fba 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -22,6 +22,4 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } -public actual fun resolvePowerSyncLoadableExtensionPath(): String? { - return "libpowersync.so" -} +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so" diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 6215d3bb..4b94034e 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -23,6 +23,4 @@ public actual class DatabaseDriverFactory { } @ExperimentalPowerSyncAPI -public actual fun resolvePowerSyncLoadableExtensionPath(): String? { - return powerSyncExtensionPath -} +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powerSyncExtensionPath diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 3eb3bed1..92237838 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -22,6 +22,4 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { private val powersyncExtension: String by lazy { extractLib("powersync") } @ExperimentalPowerSyncAPI -public actual fun resolvePowerSyncLoadableExtensionPath(): String? { - return powersyncExtension -} +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powersyncExtension diff --git a/integrations/room/README.md b/integrations/room/README.md index 7f26b422..0dddd1e3 100644 --- a/integrations/room/README.md +++ b/integrations/room/README.md @@ -9,6 +9,9 @@ Room support, meaning that: ## Setup +Add a dependency on `com.powersync:integration-room` with the same version you use for the main +PowerSync SDK. + PowerSync can use an existing Room database, provided that the PowerSync core SQLite extension has been loaded. To do that: diff --git a/integrations/room/gradle.properties b/integrations/room/gradle.properties new file mode 100644 index 00000000..3346678e --- /dev/null +++ b/integrations/room/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=integration-room +POM_NAME=Room integration for PowerSync +POM_DESCRIPTION=Use PowerSync to sync data from Room databases. \ No newline at end of file diff --git a/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt b/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt index aff24451..34ab3041 100644 --- a/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt +++ b/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt @@ -43,15 +43,16 @@ class SharedBuildPlugin : Plugin { linkTaskProvider.configure { inputs.files(sharedFiles) + + val frameworkRoot = sharedFiles.singleFile + .resolve("powersync-sqlite-core.xcframework/$abiName") + .path + + linkerOpts("-F", frameworkRoot) + linkerOpts("-rpath", frameworkRoot) } linkerOpts("-framework", "powersync-sqlite-core") - val frameworkRoot = sharedFiles.singleFile - .resolve("powersync-sqlite-core.xcframework/$abiName") - .path - - linkerOpts("-F", frameworkRoot) - linkerOpts("-rpath", frameworkRoot) } } } From beb3beba38073e9362caf530c71c65f125d7b789 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 10 Sep 2025 11:11:36 +0200 Subject: [PATCH 03/11] Ignore generated code for linting --- .../DatabaseDriverFactory.android.kt | 1 + integrations/room/build.gradle.kts | 10 + .../{utils.android.kt => Utils.android.kt} | 0 .../integrations/room/RoomConnectionPool.kt | 58 +++--- .../integrations/room/PowerSyncRoomTest.kt | 179 ++++++++++-------- .../integrations/room/TestDatabase.kt | 3 +- .../integrations/room/{utils.kt => Utils.kt} | 0 .../room/{utils.jvm.kt => Utils.jvm.kt} | 5 +- .../room/{utils.native.kt => Utils.native.kt} | 4 +- 9 files changed, 137 insertions(+), 123 deletions(-) rename integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/{utils.android.kt => Utils.android.kt} (100%) rename integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/{utils.kt => Utils.kt} (100%) rename integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/{utils.jvm.kt => Utils.jvm.kt} (57%) rename integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/{utils.native.kt => Utils.native.kt} (75%) diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index 16358fba..b7805c35 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -22,4 +22,5 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } +@ExperimentalPowerSyncAPI public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so" diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts index 5dad0af4..15e4cd4a 100644 --- a/integrations/room/build.gradle.kts +++ b/integrations/room/build.gradle.kts @@ -1,5 +1,7 @@ import com.powersync.plugins.sonatype.setupGithubRepository import com.powersync.plugins.utils.powersyncTargets +import org.jmailen.gradle.kotlinter.tasks.FormatTask +import org.jmailen.gradle.kotlinter.tasks.LintTask plugins { alias(libs.plugins.kotlinMultiplatform) @@ -87,3 +89,11 @@ setupGithubRepository() dokka { moduleName.set("PowerSync Room Integration") } + +tasks.withType { + exclude { it.file.path.contains("build/generated") } +} + +tasks.withType { + exclude { it.file.path.contains("build/generated") } +} diff --git a/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/utils.android.kt b/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/Utils.android.kt similarity index 100% rename from integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/utils.android.kt rename to integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/Utils.android.kt diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt index 978f1399..35d03f00 100644 --- a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -29,7 +29,7 @@ import kotlin.coroutines.CoroutineContext */ public class RoomConnectionPool( private val db: RoomDatabase, -): SQLiteConnectionPool { +) : SQLiteConnectionPool { private val _updates = MutableSharedFlow>() private var hasInstalledUpdateHook = false @@ -41,11 +41,10 @@ public class RoomConnectionPool( } } - override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { - return db.useReaderConnection { + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T = + db.useReaderConnection { callback(RoomTransactionLease(it, currentCoroutineContext())) } - } /** * Makes pending updates tracked by Room's invalidation tracker available to the PowerSync @@ -57,8 +56,8 @@ public class RoomConnectionPool( } } - override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { - return db.useWriterConnection { + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = + db.useWriterConnection { if (!hasInstalledUpdateHook) { hasInstalledUpdateHook = true it.execSQL("SELECT powersync_update_hooks('install')") @@ -67,14 +66,17 @@ public class RoomConnectionPool( try { callback(RoomTransactionLease(it, currentCoroutineContext())) } finally { - val changed = it.usePrepared("SELECT powersync_update_hooks('get')") { stmt -> - check(stmt.step()) - json.decodeFromString>(stmt.getText(0)) - } - - val userTables = changed.filter { tbl -> - !tbl.startsWith("ps_") && !tbl.startsWith("room_") - }.toTypedArray() + val changed = + it.usePrepared("SELECT powersync_update_hooks('get')") { stmt -> + check(stmt.step()) + json.decodeFromString>(stmt.getText(0)) + } + + val userTables = + changed + .filter { tbl -> + !tbl.startsWith("ps_") && !tbl.startsWith("room_") + }.toTypedArray() if (userTables.isNotEmpty()) { db.invalidationTracker.refresh(*userTables) @@ -83,7 +85,6 @@ public class RoomConnectionPool( _updates.emit(changed) } } - } override val updates: SharedFlow> get() = _updates @@ -103,28 +104,25 @@ private class RoomTransactionLease( * The context to use for [runBlocking] calls to avoid the "Attempted to use connection on a * different coroutine" error. */ - private val context: CoroutineContext -): SQLiteConnectionLease { - override suspend fun isInTransaction(): Boolean { - return transactor.inTransaction() - } + private val context: CoroutineContext, +) : SQLiteConnectionLease { + override suspend fun isInTransaction(): Boolean = transactor.inTransaction() override suspend fun usePrepared( sql: String, - block: (SQLiteStatement) -> R - ): R { - return transactor.usePrepared(sql, block) - } + block: (SQLiteStatement) -> R, + ): R = transactor.usePrepared(sql, block) - override fun isInTransactionSync(): Boolean { - return runBlocking(context) { + override fun isInTransactionSync(): Boolean = + runBlocking(context) { isInTransaction() } - } - override fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { - return runBlocking(context) { + override fun usePreparedSync( + sql: String, + block: (SQLiteStatement) -> R, + ): R = + runBlocking(context) { usePrepared(sql, block) } - } } diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt index 243f4537..e5e7b2b3 100644 --- a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -14,14 +14,14 @@ import kotlin.test.BeforeTest import kotlin.test.Test class PowerSyncRoomTest { - lateinit var database: TestDatabase @BeforeTest fun setup() { - val driver = BundledSQLiteDriver().also { - it.loadPowerSyncExtension() - } + val driver = + BundledSQLiteDriver().also { + it.loadPowerSyncExtension() + } database = createDatabaseBuilder().setDriver(driver).build() } @@ -32,96 +32,107 @@ class PowerSyncRoomTest { } @Test - fun roomWritePowerSyncRead() = runTest { - database.userDao().create(User(id = "test", name = "Test user")) - val logger = Logger(loggerConfigInit()) - - val powersync = PowerSyncDatabase.opened( - pool = RoomConnectionPool(database), - scope = this, - schema = TestDatabase.schema, - identifier = "test", - logger = logger, - ) - - val row = powersync.get("SELECT * FROM user") { - User( - id = it.getString("id"), - name = it.getString("name") - ) - } - row shouldBe User(id = "test", name = "Test user") + fun roomWritePowerSyncRead() = + runTest { + database.userDao().create(User(id = "test", name = "Test user")) + val logger = Logger(loggerConfigInit()) + + val powersync = + PowerSyncDatabase.opened( + pool = RoomConnectionPool(database), + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) - powersync.close() - } + val row = + powersync.get("SELECT * FROM user") { + User( + id = it.getString("id"), + name = it.getString("name"), + ) + } + row shouldBe User(id = "test", name = "Test user") + + powersync.close() + } @Test - fun roomWritePowerSyncWatch() = runTest { - val logger = Logger(loggerConfigInit()) - val pool = RoomConnectionPool(database) - - val powersync = PowerSyncDatabase.opened( - pool = pool, - scope = this, - schema = TestDatabase.schema, - identifier = "test", - logger = logger, - ) - - turbineScope { - val turbine = powersync.watch("SELECT * FROM user") { - User( - id = it.getString("id"), - name = it.getString("name") + fun roomWritePowerSyncWatch() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = + PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, ) - }.testIn(this) - turbine.awaitItem() shouldHaveSize 0 - database.userDao().create(User("id", "name")) - pool.transferRoomUpdatesToPowerSync() // TODO: Would be cool if this wasn't necessary - turbine.awaitItem() shouldHaveSize 1 - turbine.cancel() + turbineScope { + val turbine = + powersync + .watch("SELECT * FROM user") { + User( + id = it.getString("id"), + name = it.getString("name"), + ) + }.testIn(this) + + turbine.awaitItem() shouldHaveSize 0 + database.userDao().create(User("id", "name")) + pool.transferRoomUpdatesToPowerSync() // TODO: Would be cool if this wasn't necessary + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } } - } @Test - fun powersyncWriteRoomRead() = runTest { - val logger = Logger(loggerConfigInit()) - val pool = RoomConnectionPool(database) - - val powersync = PowerSyncDatabase.opened( - pool = pool, - scope = this, - schema = TestDatabase.schema, - identifier = "test", - logger = logger, - ) - - database.userDao().getAll() shouldHaveSize 0 - powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) - database.userDao().getAll() shouldHaveSize 1 - } + fun powersyncWriteRoomRead() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = + PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + database.userDao().getAll() shouldHaveSize 0 + powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) + database.userDao().getAll() shouldHaveSize 1 + } @Test - fun powersyncWriteRoomWatch() = runTest { - val logger = Logger(loggerConfigInit()) - val pool = RoomConnectionPool(database) - - val powersync = PowerSyncDatabase.opened( - pool = pool, - scope = this, - schema = TestDatabase.schema, - identifier = "test", - logger = logger, - ) - - turbineScope { - val turbine = database.userDao().watchAll().testIn(this) - turbine.awaitItem() shouldHaveSize 0 + fun powersyncWriteRoomWatch() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = + PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) - powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) - turbine.awaitItem() shouldHaveSize 1 - turbine.cancel() + turbineScope { + val turbine = database.userDao().watchAll().testIn(this) + turbine.awaitItem() shouldHaveSize 0 + + powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } } - } } diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt index 20fc1d62..e17e319c 100644 --- a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -34,10 +34,9 @@ interface UserDao { suspend fun delete(user: User) } - @Database(entities = [User::class], version = 1) @ConstructedBy(TestDatabaseConstructor::class) -abstract class TestDatabase: RoomDatabase() { +abstract class TestDatabase : RoomDatabase() { abstract fun userDao(): UserDao companion object { diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/utils.kt b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/Utils.kt similarity index 100% rename from integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/utils.kt rename to integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/Utils.kt diff --git a/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/Utils.jvm.kt similarity index 57% rename from integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt rename to integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/Utils.jvm.kt index fb4485ad..4738e752 100644 --- a/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt +++ b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/Utils.jvm.kt @@ -2,8 +2,5 @@ package com.powersync.integrations.room import androidx.room.Room import androidx.room.RoomDatabase -import androidx.sqlite.driver.bundled.BundledSQLiteDriver -actual fun createDatabaseBuilder(): RoomDatabase.Builder { - return Room.inMemoryDatabaseBuilder() -} +actual fun createDatabaseBuilder(): RoomDatabase.Builder = Room.inMemoryDatabaseBuilder() diff --git a/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt b/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/Utils.native.kt similarity index 75% rename from integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt rename to integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/Utils.native.kt index c6d1d6c5..0d41041c 100644 --- a/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/utils.native.kt +++ b/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/Utils.native.kt @@ -3,6 +3,4 @@ package com.powersync.integrations.room import androidx.room.Room import androidx.room.RoomDatabase -actual fun createDatabaseBuilder(): RoomDatabase.Builder { - return Room.inMemoryDatabaseBuilder() -} +actual fun createDatabaseBuilder(): RoomDatabase.Builder = Room.inMemoryDatabaseBuilder() From 0a24691dd2ac54c10199e6bb5ec2fd2ad9fcfe39 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 10 Sep 2025 11:53:23 +0200 Subject: [PATCH 04/11] Remove android unit tests --- integrations/room/build.gradle.kts | 12 ++++++++++++ .../com/powersync/integrations/room/Utils.android.kt | 7 ------- .../powersync/integrations/room/PowerSyncRoomTest.kt | 0 .../com/powersync/integrations/room/TestDatabase.kt | 0 .../kotlin/com/powersync/integrations/room/Utils.kt | 0 5 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/Utils.android.kt rename integrations/room/src/{commonTest => commonIntegrationTest}/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt (100%) rename integrations/room/src/{commonTest => commonIntegrationTest}/kotlin/com/powersync/integrations/room/TestDatabase.kt (100%) rename integrations/room/src/{commonTest => commonIntegrationTest}/kotlin/com/powersync/integrations/room/Utils.kt (100%) diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts index 15e4cd4a..607cff85 100644 --- a/integrations/room/build.gradle.kts +++ b/integrations/room/build.gradle.kts @@ -43,6 +43,18 @@ kotlin { implementation(libs.androidx.sqlite.bundled) } + + val commonIntegrationTest by creating { + dependsOn(commonTest.get()) + } + + // We're putting the native libraries into our JAR, so integration tests for the JVM can run as part of the unit + // tests. + jvmTest.get().dependsOn(commonIntegrationTest) + + // We have special setup in this build configuration to make these tests link the PowerSync extension, so they + // can run integration tests along with the executable for unit testing. + appleTest.orNull?.dependsOn(commonIntegrationTest) } } diff --git a/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/Utils.android.kt b/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/Utils.android.kt deleted file mode 100644 index 0ac6ee4e..00000000 --- a/integrations/room/src/androidUnitTest/kotlin/com/powersync/integrations/room/Utils.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.powersync.integrations.room - -import androidx.room.RoomDatabase - -actual fun createDatabaseBuilder(): RoomDatabase.Builder { - TODO("Android unit tests are unsupported, we test on JVM instead") -} diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt similarity index 100% rename from integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt rename to integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt similarity index 100% rename from integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt rename to integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt diff --git a/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/Utils.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/Utils.kt similarity index 100% rename from integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/Utils.kt rename to integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/Utils.kt From f4d49e32bf3f87d9c64c788e50a7cda57d82cf8b Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 15 Sep 2025 09:27:00 +0200 Subject: [PATCH 05/11] Link to docs --- integrations/room/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integrations/room/README.md b/integrations/room/README.md index 0dddd1e3..1c6e8274 100644 --- a/integrations/room/README.md +++ b/integrations/room/README.md @@ -48,8 +48,9 @@ sync operation) will automatically trigger updates in Room. To also transfer local writes to PowerSync, you need to -1. Create triggers on your Room tables to insert into `ps_crud` (see the PowerSync documentation on - raw tables for details). +1. Create triggers on your Room tables to insert into `ps_crud` (see the + [PowerSync documentation on raw tables](https://docs.powersync.com/usage/use-case-examples/raw-tables#capture-local-writes-with-triggers) + for details). 2. Listen for Room changes and invoke a helper method to transfer them to PowerSync: ```Kotlin yourRoomDatabase.getCoroutineScope().launch { From b70029bdc3a9bb473e6d1dd6d3e753017c467547 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 17 Sep 2025 11:54:35 +0200 Subject: [PATCH 06/11] Fix Room tests only running on the JVM --- integrations/room/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts index 607cff85..eec59bf0 100644 --- a/integrations/room/build.gradle.kts +++ b/integrations/room/build.gradle.kts @@ -16,8 +16,8 @@ plugins { kotlin { powersyncTargets() - explicitApi() + applyDefaultHierarchyTemplate() sourceSets { all { @@ -54,7 +54,7 @@ kotlin { // We have special setup in this build configuration to make these tests link the PowerSync extension, so they // can run integration tests along with the executable for unit testing. - appleTest.orNull?.dependsOn(commonIntegrationTest) + nativeTest.orNull?.dependsOn(commonIntegrationTest) } } From b3dc0ac21119aca41e1e7cdcff7169b8ab7a218e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 17 Sep 2025 12:21:19 +0200 Subject: [PATCH 07/11] Review feedback --- .../DatabaseDriverFactory.android.kt | 2 ++ .../DatabaseDriverFactory.appleNonWatchOs.kt | 1 + .../com/powersync/DatabaseDriverFactory.kt | 1 + .../DatabaseDriverFactory.watchos.kt | 1 + integrations/room/README.md | 11 ++++--- .../integrations/room/PowerSyncRoomTest.kt | 9 +++--- .../integrations/room/TestDatabase.kt | 23 +++++++++++++- .../integrations/room/RoomConnectionPool.kt | 30 ++++++++++++++++--- 8 files changed, 64 insertions(+), 14 deletions(-) diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index b7805c35..a1221e29 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -3,6 +3,7 @@ package com.powersync import android.content.Context import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlin.Throws @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") public actual class DatabaseDriverFactory( @@ -23,4 +24,5 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { } @ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so" diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index 4b94034e..dda195e8 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -23,4 +23,5 @@ public actual class DatabaseDriverFactory { } @ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powerSyncExtensionPath diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index 53781075..eb2d67a9 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -28,6 +28,7 @@ public expect class DatabaseDriverFactory { * SDK. */ @ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) public expect fun resolvePowerSyncLoadableExtensionPath(): String? @OptIn(ExperimentalPowerSyncAPI::class) diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 46c1878a..10f73537 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -33,6 +33,7 @@ private val didLoadExtension by lazy { } @ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) public actual fun resolvePowerSyncLoadableExtensionPath(): String? { didLoadExtension return null diff --git a/integrations/room/README.md b/integrations/room/README.md index 1c6e8274..8621389b 100644 --- a/integrations/room/README.md +++ b/integrations/room/README.md @@ -28,18 +28,21 @@ been loaded. To do that: 3. Configure raw tables for your Room databases. After these steps, you can open your Room database like you normally would. Then, you can use the -following method to obtain a `PowerSyncDatabase` instance that is backed by Room: +following method to obtain a `PowerSyncDatabase` instance which is backed by Room: ```Kotlin -val pool = RoomConnectionPool(yourRoomDatabase) +// With Room, you need to use raw tables (https://docs.powersync.com/usage/use-case-examples/raw-tables). +// This is because Room verifies your schema at runtime, and PowerSync-managed views will not +// pass those checks. +val schema = Schema(...) +val pool = RoomConnectionPool(yourRoomDatabase, schema) val powersync = PowerSyncDatabase.opened( pool = pool, scope = this, - schema = Schema(...), // With Room, you need to use raw tables + schema = schema, identifier = "databaseName", // Prefer to use the same path/name as your Room database logger = Logger, ) - powersync.connect(...) ``` diff --git a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt index e5e7b2b3..e9267193 100644 --- a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -39,7 +39,7 @@ class PowerSyncRoomTest { val powersync = PowerSyncDatabase.opened( - pool = RoomConnectionPool(database), + pool = RoomConnectionPool(database, TestDatabase.schema), scope = this, schema = TestDatabase.schema, identifier = "test", @@ -62,7 +62,7 @@ class PowerSyncRoomTest { fun roomWritePowerSyncWatch() = runTest { val logger = Logger(loggerConfigInit()) - val pool = RoomConnectionPool(database) + val pool = RoomConnectionPool(database, TestDatabase.schema) val powersync = PowerSyncDatabase.opened( @@ -85,7 +85,6 @@ class PowerSyncRoomTest { turbine.awaitItem() shouldHaveSize 0 database.userDao().create(User("id", "name")) - pool.transferRoomUpdatesToPowerSync() // TODO: Would be cool if this wasn't necessary turbine.awaitItem() shouldHaveSize 1 turbine.cancel() } @@ -95,7 +94,7 @@ class PowerSyncRoomTest { fun powersyncWriteRoomRead() = runTest { val logger = Logger(loggerConfigInit()) - val pool = RoomConnectionPool(database) + val pool = RoomConnectionPool(database, TestDatabase.schema) val powersync = PowerSyncDatabase.opened( @@ -115,7 +114,7 @@ class PowerSyncRoomTest { fun powersyncWriteRoomWatch() = runTest { val logger = Logger(loggerConfigInit()) - val pool = RoomConnectionPool(database) + val pool = RoomConnectionPool(database, TestDatabase.schema) val powersync = PowerSyncDatabase.opened( diff --git a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt index e17e319c..17bc4f6a 100644 --- a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -10,6 +10,9 @@ import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor +import com.powersync.db.schema.PendingStatement +import com.powersync.db.schema.PendingStatementParameter +import com.powersync.db.schema.RawTable import com.powersync.db.schema.Schema import kotlinx.coroutines.flow.Flow @@ -40,7 +43,25 @@ abstract class TestDatabase : RoomDatabase() { abstract fun userDao(): UserDao companion object { - val schema = Schema() + val schema = + Schema( + RawTable( + name = "user", + put = + PendingStatement( + "INSERT INTO user (id, name) VALUES (?, ?)", + listOf( + PendingStatementParameter.Id, + PendingStatementParameter.Column("name"), + ), + ), + delete = + PendingStatement( + "DELETE FROM user WHERE id = ?", + listOf(PendingStatementParameter.Id), + ), + ), + ) } } diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt index 35d03f00..3f8aad30 100644 --- a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -8,9 +8,11 @@ import androidx.room.useWriterConnection import androidx.sqlite.SQLiteStatement import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool +import com.powersync.db.schema.Schema import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlin.coroutines.CoroutineContext @@ -23,16 +25,23 @@ import kotlin.coroutines.CoroutineContext * * Writes made from the wrapped PowerSync database, including writes made for the sync process, are * forwarded to Room and will update your flows automatically. - * On the other hand, the PowerSync SDK needs to be notified about updates in Room. For that, use - * the [transferRoomUpdatesToPowerSync] method as a collector of a Room flow listening on all your - * tables. + * + * On the other hand, the PowerSync SDK needs to be notified about updates in Room. For that, a + * schema parameter can be used in the constructor. It will call [syncRoomUpdatesToPowerSync] to + * collect a Room flow on all tables. Alternatively, [transferPendingRoomUpdatesToPowerSync] can be + * called after issuing writes in Room to transfer them to PowerSync. */ public class RoomConnectionPool( private val db: RoomDatabase, + schema: Schema? = null, ) : SQLiteConnectionPool { private val _updates = MutableSharedFlow>() private var hasInstalledUpdateHook = false + init { + schema?.let { syncRoomUpdatesToPowerSync(it) } + } + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { // We can't obtain a list of all connections on Room. That's fine though, we expect this to // be used with raw tables, and withAllConnections is only used to apply a PowerSync schema. @@ -50,12 +59,25 @@ public class RoomConnectionPool( * Makes pending updates tracked by Room's invalidation tracker available to the PowerSync * database, updating flows and triggering CRUD uploads. */ - public suspend fun transferRoomUpdatesToPowerSync() { + public suspend fun transferPendingRoomUpdatesToPowerSync() { write { // The end of the write callback invokes powersync_update_hooks('get') for this } } + /** + * Registers a Room listener on all tables mentioned in the [schema] and invokes + * [transferPendingRoomUpdatesToPowerSync] when they change. + */ + public fun syncRoomUpdatesToPowerSync(schema: Schema) { + db.getCoroutineScope().launch { + val tables = schema.rawTables.map { it.name }.toTypedArray() + db.invalidationTracker.createFlow(*tables, emitInitialState = false).collect { + transferPendingRoomUpdatesToPowerSync() + } + } + } + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = db.useWriterConnection { if (!hasInstalledUpdateHook) { From a82f851ace6370c620b23375e7bc2c79f4d38a1f Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 17 Sep 2025 13:35:25 +0200 Subject: [PATCH 08/11] Update readme --- integrations/room/README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/integrations/room/README.md b/integrations/room/README.md index 8621389b..4bc1e2bd 100644 --- a/integrations/room/README.md +++ b/integrations/room/README.md @@ -54,12 +54,6 @@ To also transfer local writes to PowerSync, you need to 1. Create triggers on your Room tables to insert into `ps_crud` (see the [PowerSync documentation on raw tables](https://docs.powersync.com/usage/use-case-examples/raw-tables#capture-local-writes-with-triggers) for details). -2. Listen for Room changes and invoke a helper method to transfer them to PowerSync: - ```Kotlin - yourRoomDatabase.getCoroutineScope().launch { - // list all your tables here - yourRoomDatabase.invalidationTracker.createFlow("users", "groups", /*...*/).collect { - pool.transferRoomUpdatesToPowerSync() - } - } - ``` +2. Pass the schema as a second parameter to the `RoomConnectionPool` constructor. This will make the + pool notify PowerSync on Room writes for every raw table mentioned in the schema. + Alternatively, call `transferPendingRoomUpdatesToPowerSync` after writes in Room. From c629745c960fe7d7917bcd2447453e0a696f002d Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 17 Sep 2025 13:38:54 +0200 Subject: [PATCH 09/11] update top-level readme too --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0790682d..7944a088 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This is the PowerSync client SDK for Kotlin. This SDK currently supports the fol - iOS - macOS - watchOS +- tvOS If you need support for additional targets, please reach out! @@ -32,6 +33,11 @@ and API documentation [here](https://powersync-ja.github.io/powersync-kotlin/). 1. Retrieve a token to connect to the PowerSync service. 2. Apply local changes on your backend application server (and from there, to your backend database). +- [integrations](./integrations/) + - [room](./integrations/room/README.md): Allows using the [Room database library](https://developer.android.com/jetpack/androidx/releases/room) + with PowerSync, making it easier to run typed queries on the database. + + ## Demo Apps / Example Projects The easiest way to test the PowerSync KMP SDK is to run one of our demo applications. @@ -41,12 +47,6 @@ Demo applications are located in the [`demos/`](./demos) directory. See their re - [demos/supabase-todolist](./demos/supabase-todolist/README.md): A simple to-do list application demonstrating the use of the PowerSync Kotlin Multiplatform SDK and the Supabase connector. - [demos/android-supabase-todolist](./demos/android-supabase-todolist/README.md): A simple to-do list application demonstrating the use of the PowerSync Kotlin Multiplatform SDK and the Supabase connector in an Android application. -## Current Limitations / Future work - -Current limitations: - -- Integration with SQLDelight schema and API generation (ORM) is not yet supported. - ## Installation Add the PowerSync Kotlin Multiplatform SDK to your project by adding the following to your `build.gradle.kts` file: From ab24db4bb57eef77c5059593ad8034b08aa85842 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 17 Sep 2025 14:43:54 +0200 Subject: [PATCH 10/11] JVM fixes --- .../jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 92237838..68a9bc47 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -2,6 +2,7 @@ package com.powersync import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import com.powersync.db.runWrapped @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "SqlNoDataSourceInspection") public actual class DatabaseDriverFactory { @@ -22,4 +23,5 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { private val powersyncExtension: String by lazy { extractLib("powersync") } @ExperimentalPowerSyncAPI -public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powersyncExtension +@Throws(PowerSyncException::class) +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = runWrapped { powersyncExtension } From e27f4da01b3cdc15eb8deb398732a083d748e144 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 17 Sep 2025 16:47:14 +0200 Subject: [PATCH 11/11] Increase test timeout --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10da910f..ed23c2fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - os: windows-latest targets: jvmTest runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v4