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/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 5746d644..9754c358 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,11 +10,13 @@ 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" ktor = "3.2.3" +serialization = "1.9.0" uuid = "0.8.4" powersync-core = "0.4.4" turbine = "1.2.1" @@ -30,6 +32,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" @@ -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..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 new file mode 100644 index 00000000..17f70dc6 --- /dev/null +++ b/integrations/room/build.gradle.kts @@ -0,0 +1,87 @@ +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") +} + +kotlin { + powersyncTargets() + + explicitApi() + + sourceSets { + all { + languageSettings { + optIn("com.powersync.ExperimentalPowerSyncAPI") + } + } + + commonMain.dependencies { + api(project(":core")) + api(libs.androidx.room.runtime) + + 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/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..49d23a9e --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -0,0 +1,99 @@ +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.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()) + } + } + + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T { + return db.useReaderConnection { + callback(RoomTransactionLease(it)) + } + } + + /** + * 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)) + } 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, +): SQLiteConnectionLease { + override suspend fun isInTransaction(): Boolean { + return transactor.inTransaction() + } + + override suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R + ): R { + return transactor.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..79444f81 --- /dev/null +++ b/integrations/room/src/commonTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -0,0 +1,131 @@ +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 +import kotlin.test.Test + +class PowerSyncRoomTest { + + lateinit var database: TestDatabase + + @BeforeTest + fun setup() { + val driver = BundledSQLiteDriver().also { + it.addPowerSyncExtension() + } + + 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, + 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() + } + + @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 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/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")