diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e0d7bd..b1c49507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Remove internal SQLDelight and SQLiter dependencies. * 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. +* Add an integration for the Room database library ([readme](integrations/room/README.md)). ## 1.5.1 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..b7805c35 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -21,3 +21,6 @@ public actual class DatabaseDriverFactory( public fun BundledSQLiteDriver.addPowerSyncExtension() { addExtension("libpowersync.so", "sqlite3_powersync_init") } + +@ExperimentalPowerSyncAPI +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..4b94034e 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -21,3 +21,6 @@ public actual class DatabaseDriverFactory { return db } } + +@ExperimentalPowerSyncAPI +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..53781075 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -17,6 +17,19 @@ public expect class DatabaseDriverFactory { ): SQLiteConnection } +/** + * Resolves a path to the loadable PowerSync core extension library. + * + * This library must be loaded on all databases using the PowerSync SDK. On platforms where the + * extension is linked statically (only watchOS at the moment), this returns `null`. + * + * When using the PowerSync SDK directly, there is no need to invoke this method. It is intended for + * configuring external database connections not managed by PowerSync to work with the PowerSync + * SDK. + */ +@ExperimentalPowerSyncAPI +public expect fun resolvePowerSyncLoadableExtensionPath(): String? + @OptIn(ExperimentalPowerSyncAPI::class) internal fun openDatabase( factory: DatabaseDriverFactory, diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 5c759511..92237838 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -20,3 +20,6 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { } private val powersyncExtension: String by lazy { extractLib("powersync") } + +@ExperimentalPowerSyncAPI +public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powersyncExtension diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index d92a9a82..46c1878a 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -31,3 +31,9 @@ private val didLoadExtension by lazy { true } + +@ExperimentalPowerSyncAPI +public actual fun resolvePowerSyncLoadableExtensionPath(): String? { + didLoadExtension + return null +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9224ed7f..33d561e4 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..1c6e8274 --- /dev/null +++ b/integrations/room/README.md @@ -0,0 +1,62 @@ +# 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 that is backed by Room: + +```Kotlin +val pool = RoomConnectionPool(yourRoomDatabase) +val powersync = PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = Schema(...), // With Room, you need to use raw tables + identifier = "databaseName", // Prefer to use the same path/name as your Room database + logger = Logger, +) + +powersync.connect(...) +``` + +Changes from PowerSync (regardless of whether they've been made with `powersync.execute` or from a +sync operation) will automatically trigger updates in Room. + +To also transfer local writes to PowerSync, you need to + +1. Create triggers on your Room tables to insert into `ps_crud` (see the + [PowerSync documentation on raw tables](https://docs.powersync.com/usage/use-case-examples/raw-tables#capture-local-writes-with-triggers) + for details). +2. Listen for Room changes and invoke a helper method to transfer them to PowerSync: + ```Kotlin + yourRoomDatabase.getCoroutineScope().launch { + // list all your tables here + yourRoomDatabase.invalidationTracker.createFlow("users", "groups", /*...*/).collect { + pool.transferRoomUpdatesToPowerSync() + } + } + ``` diff --git a/integrations/room/build.gradle.kts b/integrations/room/build.gradle.kts new file mode 100644 index 00000000..607cff85 --- /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() + + 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. + appleTest.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..e5e7b2b3 --- /dev/null +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -0,0 +1,138 @@ +package com.powersync.integrations.room + +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import app.cash.turbine.turbineScope +import co.touchlab.kermit.Logger +import co.touchlab.kermit.loggerConfigInit +import com.powersync.PowerSyncDatabase +import com.powersync.db.getString +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class PowerSyncRoomTest { + lateinit var database: TestDatabase + + @BeforeTest + fun setup() { + val driver = + BundledSQLiteDriver().also { + it.loadPowerSyncExtension() + } + + database = createDatabaseBuilder().setDriver(driver).build() + } + + @AfterTest + fun tearDown() { + database.close() + } + + @Test + fun roomWritePowerSyncRead() = + runTest { + database.userDao().create(User(id = "test", name = "Test user")) + val logger = Logger(loggerConfigInit()) + + val powersync = + PowerSyncDatabase.opened( + pool = RoomConnectionPool(database), + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + val row = + powersync.get("SELECT * FROM user") { + User( + id = it.getString("id"), + name = it.getString("name"), + ) + } + row shouldBe User(id = "test", name = "Test user") + + powersync.close() + } + + @Test + fun roomWritePowerSyncWatch() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = + PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + turbineScope { + val turbine = + powersync + .watch("SELECT * FROM user") { + User( + id = it.getString("id"), + name = it.getString("name"), + ) + }.testIn(this) + + turbine.awaitItem() shouldHaveSize 0 + database.userDao().create(User("id", "name")) + pool.transferRoomUpdatesToPowerSync() // TODO: Would be cool if this wasn't necessary + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } + } + + @Test + fun powersyncWriteRoomRead() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = + PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + database.userDao().getAll() shouldHaveSize 0 + powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) + database.userDao().getAll() shouldHaveSize 1 + } + + @Test + fun powersyncWriteRoomWatch() = + runTest { + val logger = Logger(loggerConfigInit()) + val pool = RoomConnectionPool(database) + + val powersync = + PowerSyncDatabase.opened( + pool = pool, + scope = this, + schema = TestDatabase.schema, + identifier = "test", + logger = logger, + ) + + turbineScope { + val turbine = database.userDao().watchAll().testIn(this) + turbine.awaitItem() shouldHaveSize 0 + + powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) + turbine.awaitItem() shouldHaveSize 1 + turbine.cancel() + } + } +} diff --git a/integrations/room/src/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..e17e319c --- /dev/null +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -0,0 +1,51 @@ +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/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..35d03f00 --- /dev/null +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -0,0 +1,128 @@ +package com.powersync.integrations.room + +import androidx.room.RoomDatabase +import androidx.room.Transactor +import androidx.room.execSQL +import androidx.room.useReaderConnection +import androidx.room.useWriterConnection +import androidx.sqlite.SQLiteStatement +import com.powersync.db.driver.SQLiteConnectionLease +import com.powersync.db.driver.SQLiteConnectionPool +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlin.coroutines.CoroutineContext + +/** + * A [SQLiteConnectionPool] implementation for the PowerSync SDK that is backed by a [RoomDatabase]. + * + * An instance of this class can be passed to [com.powersync.PowerSyncDatabase.opened], allowing + * PowerSync to wrap Room databases. + * + * Writes made from the wrapped PowerSync database, including writes made for the sync process, are + * forwarded to Room and will update your flows automatically. + * On the other hand, the PowerSync SDK needs to be notified about updates in Room. For that, use + * the [transferRoomUpdatesToPowerSync] method as a collector of a Room flow listening on all your + * tables. + */ +public class RoomConnectionPool( + private val db: RoomDatabase, +) : SQLiteConnectionPool { + private val _updates = MutableSharedFlow>() + private var hasInstalledUpdateHook = false + + override suspend fun withAllConnections(action: suspend (SQLiteConnectionLease, List) -> R) { + // We can't obtain a list of all connections on Room. That's fine though, we expect this to + // be used with raw tables, and withAllConnections is only used to apply a PowerSync schema. + write { + action(it, emptyList()) + } + } + + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T = + db.useReaderConnection { + callback(RoomTransactionLease(it, currentCoroutineContext())) + } + + /** + * Makes pending updates tracked by Room's invalidation tracker available to the PowerSync + * database, updating flows and triggering CRUD uploads. + */ + public suspend fun transferRoomUpdatesToPowerSync() { + write { + // The end of the write callback invokes powersync_update_hooks('get') for this + } + } + + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = + 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")