From a21055cf1d186fcec523f7c0d0490c01f709597c Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 13:42:27 +0200 Subject: [PATCH 1/4] Another attempt to get Room working --- build.gradle.kts | 2 + gradle/libs.versions.toml | 8 +- integrations/room/build.gradle.kts | 86 +++++++++++++++++++ .../integrations/room/RoomConnectionPool.kt | 74 ++++++++++++++++ .../integrations/room/PowerSyncRoomTest.kt | 12 +++ .../integrations/room/TestDatabase.kt | 32 +++++++ .../com/powersync/integrations/room/utils.kt | 5 ++ .../powersync/integrations/room/utils.jvm.kt | 10 +++ settings.gradle.kts | 1 + 9 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 integrations/room/build.gradle.kts 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 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5746d644..02d74186 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,8 @@ java = "17" # Dependencies kermit = "2.0.6" -kotlin = "2.2.0" +kotlin = "2.2.0" # Note: When updating, always update the first part of the ksp version too +ksp = "2.2.0-2.0.2" coroutines = "1.10.2" kotlinx-datetime = "0.7.1" kotlinx-io = "0.8.0" @@ -30,6 +31,7 @@ compose-preview = "1.8.3" compose-lifecycle = "2.9.1" androidxSqlite = "2.6.0-rc01" androidxSplashscreen = "1.0.1" +room = "2.8.0-rc01" # plugins android-gradle-plugin = "8.11.1" @@ -97,6 +99,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 +147,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/build.gradle.kts b/integrations/room/build.gradle.kts new file mode 100644 index 00000000..07a90b1f --- /dev/null +++ b/integrations/room/build.gradle.kts @@ -0,0 +1,86 @@ +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.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinter) + alias(libs.plugins.ksp) + id("com.powersync.plugins.sonatype") + id("dokka-convention") +} + +kotlin { + powersyncTargets() + + explicitApi() + + sourceSets { + all { + languageSettings { + optIn("com.powersync.ExperimentalPowerSyncAPI") + } + } + + commonMain.dependencies { + api(project(":core")) + api(libs.androidx.room.runtime) + } + + 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/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..d2aa83d0 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -0,0 +1,74 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase +import androidx.room.Transactor +import androidx.sqlite.SQLiteStatement +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +internal class RoomConnectionPool( + private val db: RoomDatabase, +): SQLiteConnectionPool { + private val _updates = MutableSharedFlow>() + + 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. + action(write(), emptyList()) + } + + override suspend fun read(): SQLiteConnectionLease { + return obtainLease(true) + } + + override suspend fun write(): SQLiteConnectionLease { + return obtainLease(false) + } + + private suspend fun obtainLease(readonly: Boolean): SQLiteConnectionLease { + val obtainedLease = CompletableDeferred() + coroutineScope { + launch { + db.useConnection(readonly) { transactor -> + val completer = CompletableDeferred() + obtainedLease.complete(RoomTransactionLease(transactor, completer)) + completer.await() + } + } + } + + return obtainedLease.await() + } + + override val updates: SharedFlow> + get() = _updates + + override suspend fun close() { + // Noop, Room database managed independently + } +} + +private class RoomTransactionLease( + private val transactor: Transactor, + private val completer: CompletableDeferred +): 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 suspend fun close() { + completer.complete(Unit) + } +} 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..7dfcfed2 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -0,0 +1,12 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import kotlin.test.Test + +class PowerSyncRoomTest { + + fun createDatabase() { + Room + } + +} \ No newline at end of file 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..ec9a95e6 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -0,0 +1,32 @@ +package com.powersync.integrations.room + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase + +@Database(entities = [User::class], version = 1) +abstract class TestDatabase: RoomDatabase() { + abstract fun userDao(): UserDao +} + +@Dao +interface UserDao { + @Query("INSERT INTO user (id, name) VALUES (uuid(), :name)") + fun create(name: String) + + @Query("SELECT * FROM user") + fun getAll(): List + + @Delete + fun delete(user: User) +} + +@Entity +data class User( + @PrimaryKey val id: String, + val name: String, +) 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..c953f1e3 --- /dev/null +++ b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt @@ -0,0 +1,10 @@ +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() + .setDriver(BundledSQLiteDriver()) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 77b4160c..0b5295cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ include(":connectors:supabase") include(":PowerSyncKotlin") include(":compose") +include(":integrations:room") include(":demos:android-supabase-todolist") include(":demos:supabase-todolist") From b08ac40f76cb0e930ba1a3075302ae8ea1a4a881 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 14:15:17 +0200 Subject: [PATCH 2/4] First test --- integrations/room/build.gradle.kts | 2 - .../integrations/room/RoomConnectionPool.kt | 19 +++---- .../integrations/room/PowerSyncRoomTest.kt | 53 +++++++++++++++++-- .../integrations/room/TestDatabase.kt | 42 ++++++++++----- .../powersync/integrations/room/utils.jvm.kt | 1 - 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts index 07a90b1f..b016f71c 100644 --- a/integrations/room/build.gradle.kts +++ b/integrations/room/build.gradle.kts @@ -4,8 +4,6 @@ import com.powersync.plugins.utils.powersyncTargets plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.android.library) - alias(libs.plugins.jetbrainsCompose) - alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlinter) alias(libs.plugins.ksp) id("com.powersync.plugins.sonatype") 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 d2aa83d0..e46c9293 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 @@ -6,13 +6,15 @@ import androidx.sqlite.SQLiteStatement import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch -internal class RoomConnectionPool( +public class RoomConnectionPool( private val db: RoomDatabase, + private val scope: CoroutineScope, ): SQLiteConnectionPool { private val _updates = MutableSharedFlow>() @@ -32,17 +34,16 @@ internal class RoomConnectionPool( private suspend fun obtainLease(readonly: Boolean): SQLiteConnectionLease { val obtainedLease = CompletableDeferred() - coroutineScope { - launch { - db.useConnection(readonly) { transactor -> - val completer = CompletableDeferred() - obtainedLease.complete(RoomTransactionLease(transactor, completer)) - completer.await() - } + scope.launch { + db.useConnection(readonly) { transactor -> + val completer = CompletableDeferred() + obtainedLease.complete(RoomTransactionLease(transactor, completer)) + completer.await() } } - return obtainedLease.await() + val lease = obtainedLease.await() + return lease } override val updates: SharedFlow> 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 7dfcfed2..719fce30 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 @@ -1,12 +1,57 @@ package com.powersync.integrations.room -import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import co.touchlab.kermit.Logger +import co.touchlab.kermit.LoggerConfig +import co.touchlab.kermit.loggerConfigInit +import com.powersync.PowerSyncDatabase +import com.powersync.addPowerSyncExtension +import com.powersync.db.getString +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test class PowerSyncRoomTest { - fun createDatabase() { - Room + lateinit var database: TestDatabase + + @BeforeTest + fun setup() { + val driver = BundledSQLiteDriver().also { + it.addPowerSyncExtension() + } + + database = createDatabaseBuilder().setDriver(driver).build() + } + + @AfterTest + fun tearDown() { + database.close() } -} \ No newline at end of file + @Test + fun roomWritePowerSyncRead() = runTest { + database.userDao().create(User(id = "test", name = "Test user")) + val logger = Logger(loggerConfigInit()) + + val powersync = PowerSyncDatabase.opened( + pool = RoomConnectionPool(database, this), + scope = this, + schema = TestDatabase.schema, + group = PowerSyncDatabase.databaseGroup(logger, "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() + } +} 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 ec9a95e6..ca856b4c 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 @@ -1,32 +1,48 @@ 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 -@Database(entities = [User::class], version = 1) -abstract class TestDatabase: RoomDatabase() { - abstract fun userDao(): UserDao -} +@Entity +data class User( + @PrimaryKey val id: String, + val name: String, +) @Dao interface UserDao { - @Query("INSERT INTO user (id, name) VALUES (uuid(), :name)") - fun create(name: String) + @Insert + suspend fun create(user: User) @Query("SELECT * FROM user") - fun getAll(): List + suspend fun getAll(): List @Delete - fun delete(user: User) + suspend fun delete(user: User) } -@Entity -data class User( - @PrimaryKey val id: String, - val name: String, -) + +@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/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/utils.jvm.kt index c953f1e3..fb4485ad 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 @@ -6,5 +6,4 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver actual fun createDatabaseBuilder(): RoomDatabase.Builder { return Room.inMemoryDatabaseBuilder() - .setDriver(BundledSQLiteDriver()) } From 3f7537adee119222bf595d523eb90af2bfff1129 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 14:37:46 +0200 Subject: [PATCH 3/4] Use callbacks in Room driver --- .../integrations/room/RoomConnectionPool.kt | 40 ++++++------------- .../integrations/room/PowerSyncRoomTest.kt | 2 +- 2 files changed, 13 insertions(+), 29 deletions(-) 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 e46c9293..0eff2238 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 @@ -2,48 +2,37 @@ package com.powersync.integrations.room import androidx.room.RoomDatabase import androidx.room.Transactor +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.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch public class RoomConnectionPool( private val db: RoomDatabase, - private val scope: CoroutineScope, ): SQLiteConnectionPool { private val _updates = MutableSharedFlow>() 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. - action(write(), emptyList()) - } - - override suspend fun read(): SQLiteConnectionLease { - return obtainLease(true) + write { + action(it, emptyList()) + } } - override suspend fun write(): SQLiteConnectionLease { - return obtainLease(false) + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { + return db.useReaderConnection { + callback(RoomTransactionLease(it)) + } } - private suspend fun obtainLease(readonly: Boolean): SQLiteConnectionLease { - val obtainedLease = CompletableDeferred() - scope.launch { - db.useConnection(readonly) { transactor -> - val completer = CompletableDeferred() - obtainedLease.complete(RoomTransactionLease(transactor, completer)) - completer.await() - } + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T { + return db.useWriterConnection { + callback(RoomTransactionLease(it)) } - - val lease = obtainedLease.await() - return lease } override val updates: SharedFlow> @@ -56,7 +45,6 @@ public class RoomConnectionPool( private class RoomTransactionLease( private val transactor: Transactor, - private val completer: CompletableDeferred ): SQLiteConnectionLease { override suspend fun isInTransaction(): Boolean { return transactor.inTransaction() @@ -68,8 +56,4 @@ private class RoomTransactionLease( ): R { return transactor.usePrepared(sql, block) } - - override suspend fun close() { - completer.complete(Unit) - } } 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 719fce30..cb20476c 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 @@ -37,7 +37,7 @@ class PowerSyncRoomTest { val logger = Logger(loggerConfigInit()) val powersync = PowerSyncDatabase.opened( - pool = RoomConnectionPool(database, this), + pool = RoomConnectionPool(database), scope = this, schema = TestDatabase.schema, group = PowerSyncDatabase.databaseGroup(logger, "test"), From 4004fa388566f15fb5d33e143e2db7f49cc02e48 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 22 Aug 2025 15:43:00 +0200 Subject: [PATCH 4/4] Finish tests --- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 2 +- .../kotlin/com/powersync/db/Queries.kt | 22 +++-- .../powersync/db/driver/RawConnectionLease.kt | 8 -- .../db/driver/SQLiteConnectionPool.kt | 10 -- .../db/internal/ConnectionContext.kt | 98 +++++++++++++------ .../db/internal/InternalDatabaseImpl.kt | 10 +- .../db/internal/PowerSyncTransaction.kt | 44 +++++---- .../powersync/DatabaseDriverFactory.jvm.kt | 2 +- gradle/libs.versions.toml | 2 + integrations/room/README.md | 58 +++++++++++ integrations/room/build.gradle.kts | 3 + .../integrations/room/RoomConnectionPool.kt | 42 +++++++- .../integrations/room/PowerSyncRoomTest.kt | 74 ++++++++++++++ .../integrations/room/TestDatabase.kt | 4 + 14 files changed, 297 insertions(+), 82 deletions(-) create mode 100644 integrations/room/README.md diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 5699012e..d645f664 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -107,7 +107,7 @@ internal class PowerSyncDatabaseImpl( logger.d { "PowerSyncVersion: $powerSyncVersion" } internalDb.writeTransaction { tx -> - tx.getOptional("SELECT powersync_init()") {} + tx.async.getOptional("SELECT powersync_init()") {} } updateSchemaInternal(schema) diff --git a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt index 2796e7d1..a256e20b 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/Queries.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/Queries.kt @@ -13,22 +13,15 @@ import kotlin.time.Duration.Companion.milliseconds public fun interface ThrowableTransactionCallback { @Throws(PowerSyncException::class, kotlinx.coroutines.CancellationException::class) - public fun execute(transaction: PowerSyncTransaction): R + public suspend fun execute(transaction: PowerSyncTransaction): R } public fun interface ThrowableLockCallback { @Throws(PowerSyncException::class, kotlinx.coroutines.CancellationException::class) - public fun execute(context: ConnectionContext): R + public suspend fun execute(context: ConnectionContext): R } -public interface Queries { - public companion object { - /** - * The default throttle duration for [onChange] and [watch] operations. - */ - public val DEFAULT_THROTTLE: Duration = 30.milliseconds - } - +public interface QueryRunner { /** * Executes a write query (INSERT, UPDATE, DELETE). * @@ -94,6 +87,15 @@ public interface Queries { parameters: List? = listOf(), mapper: (SqlCursor) -> RowType, ): RowType? +} + +public interface Queries : QueryRunner { + public companion object { + /** + * The default throttle duration for [onChange] and [watch] operations. + */ + public val DEFAULT_THROTTLE: Duration = 30.milliseconds + } /** * Returns a [Flow] that emits whenever the source tables are modified. diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt index 03405b20..ebbfb9d9 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/RawConnectionLease.kt @@ -19,10 +19,6 @@ internal class RawConnectionLease( } override suspend fun isInTransaction(): Boolean { - return isInTransactionSync() - } - - override fun isInTransactionSync(): Boolean { checkNotCompleted() return connection.inTransaction() } @@ -31,10 +27,6 @@ internal class RawConnectionLease( sql: String, block: (SQLiteStatement) -> R ): R { - return usePreparedSync(sql, block) - } - - override fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { checkNotCompleted() return connection.prepare(sql).use(block) } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt index ab8e16a2..630d7aab 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/SQLiteConnectionPool.kt @@ -27,10 +27,6 @@ public interface SQLiteConnectionLease { */ public suspend fun isInTransaction(): Boolean - public fun isInTransactionSync(): Boolean { - return runBlocking { isInTransaction() } - } - /** * Prepares [sql] as statement and runs [block] with it. * @@ -38,12 +34,6 @@ public interface SQLiteConnectionLease { */ public suspend fun usePrepared(sql: String, block: (SQLiteStatement) -> R): R - public fun usePreparedSync(sql: String, block: (SQLiteStatement) -> R): R { - return runBlocking { - usePrepared(sql, block) - } - } - public suspend fun execSQL(sql: String) { usePrepared(sql) { it.step() diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt index 6c70780b..9a9bc399 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt @@ -3,13 +3,17 @@ package com.powersync.db.internal import androidx.sqlite.SQLiteStatement import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException +import com.powersync.db.QueryRunner import com.powersync.db.SqlCursor import com.powersync.db.StatementBasedCursor import com.powersync.db.driver.SQLiteConnectionLease +import kotlinx.coroutines.runBlocking public interface ConnectionContext { // TODO (breaking): Make asynchronous, create shared superinterface with Queries + public val async: QueryRunner + @Throws(PowerSyncException::class) public fun execute( sql: String, @@ -38,13 +42,53 @@ public interface ConnectionContext { ): RowType } -@ExperimentalPowerSyncAPI -internal class ConnectionContextImplementation( - private val rawConnection: SQLiteConnectionLease, -) : ConnectionContext { +/** + * An implementation of a [ConnectionContext] that delegates to a [QueryRunner] via [runBlocking]. + */ +internal abstract class BaseConnectionContextImplementation(): ConnectionContext { override fun execute( sql: String, parameters: List?, + ): Long = runBlocking { async.execute(sql, parameters) } + + override fun getOptional( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): RowType? = runBlocking { + async.getOptional(sql, parameters, mapper) + } + + override fun getAll( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): List = + runBlocking { + async.getAll(sql, parameters, mapper) + } + + override fun get( + sql: String, + parameters: List?, + mapper: (SqlCursor) -> RowType, + ): RowType = runBlocking { + async.get(sql, parameters, mapper) + } +} + +@OptIn(ExperimentalPowerSyncAPI::class) +internal class ConnectionContextImplementation(lease: SQLiteConnectionLease): BaseConnectionContextImplementation() { + override val async = ContextQueryRunner(lease) +} + +@OptIn(ExperimentalPowerSyncAPI::class) +internal class ContextQueryRunner( + private val rawConnection: SQLiteConnectionLease +): QueryRunner { + override suspend fun execute( + sql: String, + parameters: List? ): Long { withStatement(sql, parameters) { while (it.step()) { @@ -58,45 +102,43 @@ internal class ConnectionContextImplementation( } } - override fun getOptional( + override suspend fun get( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType? = - withStatement(sql, parameters) { stmt -> - if (stmt.step()) { - mapper(StatementBasedCursor(stmt)) - } else { - null - } - } + mapper: (SqlCursor) -> RowType + ): RowType = getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null) - override fun getAll( + override suspend fun getAll( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType, - ): List = - withStatement(sql, parameters) { stmt -> - buildList { - val cursor = StatementBasedCursor(stmt) - while (stmt.step()) { - add(mapper(cursor)) - } + mapper: (SqlCursor) -> RowType + ): List = withStatement(sql, parameters) { stmt -> + buildList { + val cursor = StatementBasedCursor(stmt) + while (stmt.step()) { + add(mapper(cursor)) } } + } - override fun get( + override suspend fun getOptional( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType = getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null) + mapper: (SqlCursor) -> RowType + ): RowType? = withStatement(sql, parameters) { stmt -> + if (stmt.step()) { + mapper(StatementBasedCursor(stmt)) + } else { + null + } + } - private inline fun withStatement( + private suspend inline fun withStatement( sql: String, parameters: List?, crossinline block: (SQLiteStatement) -> T, ): T { - return rawConnection.usePreparedSync(sql) { stmt -> + return rawConnection.usePrepared(sql) { stmt -> stmt.bind(parameters) block(stmt) } diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt index 64817577..44396452 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt @@ -36,7 +36,7 @@ internal class InternalDatabaseImpl( parameters: List?, ): Long = writeLock { context -> - context.execute(sql, parameters) + context.async.execute(sql, parameters) } override suspend fun updateSchema(schemaJson: String) { @@ -44,7 +44,7 @@ internal class InternalDatabaseImpl( runWrapped { pool.withAllConnections { writer, readers -> writer.runTransaction { tx -> - tx.getOptional( + tx.async.getOptional( "SELECT powersync_replace_schema(?);", listOf(schemaJson), ) {} @@ -63,19 +63,19 @@ internal class InternalDatabaseImpl( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): RowType = readLock { connection -> connection.get(sql, parameters, mapper) } + ): RowType = readLock { connection -> connection.async.get(sql, parameters, mapper) } override suspend fun getAll( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): List = readLock { connection -> connection.getAll(sql, parameters, mapper) } + ): List = readLock { connection -> connection.async.getAll(sql, parameters, mapper) } override suspend fun getOptional( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): RowType? = readLock { connection -> connection.getOptional(sql, parameters, mapper) } + ): RowType? = readLock { connection -> connection.async.getOptional(sql, parameters, mapper) } override fun onChange( tables: Set, diff --git a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt index 1b0fcac5..f5adc3fd 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt @@ -2,6 +2,7 @@ package com.powersync.db.internal import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncException +import com.powersync.db.QueryRunner import com.powersync.db.SqlCursor import com.powersync.db.driver.SQLiteConnectionLease @@ -9,50 +10,57 @@ public interface PowerSyncTransaction : ConnectionContext @ExperimentalPowerSyncAPI internal class PowerSyncTransactionImpl( - private val lease: SQLiteConnectionLease, -) : PowerSyncTransaction, - ConnectionContext { - private val delegate = ConnectionContextImplementation(lease) + lease: SQLiteConnectionLease, +) : PowerSyncTransaction, BaseConnectionContextImplementation() { + override val async = AsyncPowerSyncTransactionImpl(lease) +} + +@OptIn(ExperimentalPowerSyncAPI::class) +internal class AsyncPowerSyncTransactionImpl( + private val lease: SQLiteConnectionLease +): QueryRunner { + + private val delegate = ContextQueryRunner(lease) - private fun checkInTransaction() { - if (!lease.isInTransactionSync()) { + private suspend fun checkInTransaction() { + if (!lease.isInTransaction()) { throw PowerSyncException("Tried executing statement on a transaction that has been rolled back", cause = null) } } - override fun execute( + override suspend fun execute( sql: String, - parameters: List?, + parameters: List? ): Long { checkInTransaction() return delegate.execute(sql, parameters) } - override fun getOptional( + override suspend fun get( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType? { + mapper: (SqlCursor) -> RowType + ): RowType { checkInTransaction() - return delegate.getOptional(sql, parameters, mapper) + return delegate.get(sql, parameters, mapper) } - override fun getAll( + override suspend fun getAll( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType, + mapper: (SqlCursor) -> RowType ): List { checkInTransaction() return delegate.getAll(sql, parameters, mapper) } - override fun get( + override suspend fun getOptional( sql: String, parameters: List?, - mapper: (SqlCursor) -> RowType, - ): RowType { + mapper: (SqlCursor) -> RowType + ): RowType? { checkInTransaction() - return delegate.get(sql, parameters, mapper) + return delegate.getOptional(sql, parameters, mapper) } } diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 1573a37a..bc330ede 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -10,7 +10,7 @@ public actual class DatabaseDriverFactory { } public actual fun BundledSQLiteDriver.addPowerSyncExtension() { - addExtension(powersyncExtension, "sqlite3_powersync_init") + addExtension("/Users/simon/src/powersync-sqlite-core/target/debug/libpowersync.dylib", "sqlite3_powersync_init") } private val powersyncExtension: String by lazy { extractLib("powersync") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02d74186..9754c358 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ coroutines = "1.10.2" kotlinx-datetime = "0.7.1" kotlinx-io = "0.8.0" ktor = "3.2.3" +serialization = "1.9.0" uuid = "0.8.4" powersync-core = "0.4.4" turbine = "1.2.1" @@ -90,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" } diff --git a/integrations/room/README.md b/integrations/room/README.md new file mode 100644 index 00000000..97727883 --- /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.addPowerSyncExtension() // Extension method by the PowerSync SDK + } + + Room.databaseBuilder(...).setDriver(driver).build() + ``` +3. Configure raw tables for your Room databases. + +After these steps, you can open your Room database. 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 + group = PowerSyncDatabase.databaseGroup(logger, "databaseName"), + 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").collect { + pool.transferRoomUpdatesToPowerSync() + } + } + ``` diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts index b016f71c..17f70dc6 100644 --- a/integrations/room/build.gradle.kts +++ b/integrations/room/build.gradle.kts @@ -6,6 +6,7 @@ plugins { 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") } @@ -25,6 +26,8 @@ kotlin { commonMain.dependencies { api(project(":core")) api(libs.androidx.room.runtime) + + implementation(libs.kotlinx.serialization.json) } commonTest.dependencies { 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 0eff2238..49d23a9e 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 @@ -2,6 +2,7 @@ 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 @@ -9,16 +10,19 @@ import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.serialization.json.Json 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 { + db.invalidationTracker action(it, emptyList()) } } @@ -29,9 +33,41 @@ 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() { + 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 { - callback(RoomTransactionLease(it)) + if (!hasInstalledUpdateHook) { + hasInstalledUpdateHook = true + it.execSQL("SELECT powersync_update_hooks('install')") + } + + try { + callback(RoomTransactionLease(it)) + } 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) + } } } @@ -41,6 +77,10 @@ public class RoomConnectionPool( override suspend fun close() { // Noop, Room database managed independently } + + private companion object { + val json = Json {} + } } private class RoomTransactionLease( 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 cb20476c..79444f81 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 @@ -1,13 +1,17 @@ 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.LoggerConfig import co.touchlab.kermit.loggerConfigInit import com.powersync.PowerSyncDatabase import com.powersync.addPowerSyncExtension import com.powersync.db.getString +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -54,4 +58,74 @@ class PowerSyncRoomTest { 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, + group = PowerSyncDatabase.databaseGroup(logger, "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, + group = PowerSyncDatabase.databaseGroup(logger, "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, + group = PowerSyncDatabase.databaseGroup(logger, "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 index ca856b4c..20fc1d62 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 @@ -11,6 +11,7 @@ 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( @@ -26,6 +27,9 @@ interface UserDao { @Query("SELECT * FROM user") suspend fun getAll(): List + @Query("SELECT * FROM user") + fun watchAll(): Flow> + @Delete suspend fun delete(user: User) }