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 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/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: 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..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( @@ -21,3 +22,7 @@ public actual class DatabaseDriverFactory( public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } + +@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 b2e8a15e..dda195e8 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -21,3 +21,7 @@ public actual class DatabaseDriverFactory { return db } } + +@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 6be048e5..eb2d67a9 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -17,6 +17,20 @@ 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 +@Throws(PowerSyncException::class) +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..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 { @@ -20,3 +21,7 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { } private val powersyncExtension: String by lazy { extractLib("powersync") } + +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = runWrapped { 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..10f73537 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -31,3 +31,10 @@ private val didLoadExtension by lazy { true } + +@ExperimentalPowerSyncAPI +@Throws(PowerSyncException::class) +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..4bc1e2bd --- /dev/null +++ b/integrations/room/README.md @@ -0,0 +1,59 @@ +# 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 + +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: + +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 which is backed by Room: + +```Kotlin +// 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, + 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](https://docs.powersync.com/usage/use-case-examples/raw-tables#capture-local-writes-with-triggers) + for details). +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. diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts new file mode 100644 index 00000000..eec59bf0 --- /dev/null +++ b/integrations/room/build.gradle.kts @@ -0,0 +1,111 @@ +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) + 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() + applyDefaultHierarchyTemplate() + + 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) + } + + 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. + nativeTest.orNull?.dependsOn(commonIntegrationTest) + } +} + +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") +} + +tasks.withType { + exclude { it.file.path.contains("build/generated") } +} + +tasks.withType { + exclude { it.file.path.contains("build/generated") } +} 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/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt new file mode 100644 index 00000000..e9267193 --- /dev/null +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -0,0 +1,137 @@ +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, TestDatabase.schema), + 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, TestDatabase.schema) + + 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")) + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } + } + + @Test + fun powersyncWriteRoomRead() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database, TestDatabase.schema) + + 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, TestDatabase.schema) + + 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/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt new file mode 100644 index 00000000..17bc4f6a --- /dev/null +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -0,0 +1,72 @@ +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.PendingStatement +import com.powersync.db.schema.PendingStatementParameter +import com.powersync.db.schema.RawTable +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( + 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), + ), + ), + ) + } +} + +// The Room compiler generates the `actual` implementations. +@Suppress("KotlinNoActualForExpect") +expect object TestDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): TestDatabase +} diff --git a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/Utils.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/Utils.kt new file mode 100644 index 00000000..5026631b --- /dev/null +++ b/integrations/room/src/commonIntegrationTest/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/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..3f8aad30 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -0,0 +1,150 @@ +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 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 + +/** + * 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, 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. + write { + action(it, emptyList()) + } + } + + 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 + * database, updating flows and triggering CRUD uploads. + */ + 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) { + 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 = transactor.inTransaction() + + override suspend fun usePrepared( + sql: String, + block: (SQLiteStatement) -> R, + ): R = transactor.usePrepared(sql, block) + + override fun isInTransactionSync(): Boolean = + runBlocking(context) { + isInTransaction() + } + + override fun usePreparedSync( + sql: String, + block: (SQLiteStatement) -> R, + ): R = + runBlocking(context) { + usePrepared(sql, block) + } +} 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..4738e752 --- /dev/null +++ b/integrations/room/src/jvmTest/kotlin/com/powersync/integrations/room/Utils.jvm.kt @@ -0,0 +1,6 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import androidx.room.RoomDatabase + +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 new file mode 100644 index 00000000..0d41041c --- /dev/null +++ b/integrations/room/src/nativeTest/kotlin/com/powersync/integrations/room/Utils.native.kt @@ -0,0 +1,6 @@ +package com.powersync.integrations.room + +import androidx.room.Room +import androidx.room.RoomDatabase + +actual fun createDatabaseBuilder(): RoomDatabase.Builder = 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..34ab3041 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,17 +39,20 @@ class SharedBuildPlugin : Plugin { binaries .withType() .configureEach { - linkTaskProvider.configure { dependsOn(unzipPowersyncFramework) } - linkerOpts("-framework", "powersync-sqlite-core") + val sharedFiles: FileCollection = powersyncFrameworkConfiguration - val frameworkRoot = - binariesFolder - .map { it.dir("framework/extracted/powersync-sqlite-core.xcframework/$abiName") } - .get() - .asFile.path + 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") - 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")