From 72c2bbb2553db5d7a2548a7ac281ff540af7981e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 29 Sep 2025 12:58:48 +0200 Subject: [PATCH 01/14] Add in-memory database for tests # Conflicts: # core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt # integrations/sqldelight/build.gradle.kts --- .../kotlin/com/powersync/db/InMemoryTest.kt | 76 +++++++++++++++++++ .../integrations/sqldelight/SqlDelightTest.kt | 0 2 files changed, 76 insertions(+) create mode 100644 core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt rename integrations/sqldelight/src/{commonIntegrationTest => commonTest}/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt (100%) diff --git a/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt b/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt new file mode 100644 index 00000000..8cc323db --- /dev/null +++ b/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt @@ -0,0 +1,76 @@ +package com.powersync.db + +import app.cash.turbine.turbineScope +import co.touchlab.kermit.ExperimentalKermitApi +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.kermit.TestConfig +import co.touchlab.kermit.TestLogWriter +import com.powersync.PowerSyncDatabase +import com.powersync.db.schema.Column +import com.powersync.db.schema.Schema +import com.powersync.db.schema.Table +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +@OptIn(ExperimentalKermitApi::class) +class InMemoryTest { + private val logWriter = + TestLogWriter( + loggable = Severity.Debug, + ) + + private val logger = + Logger( + TestConfig( + minSeverity = Severity.Debug, + logWriterList = listOf(logWriter), + ), + ) + + @Test + fun createsSchema() = + runTest { + val db = PowerSyncDatabase.inMemory(this, schema, logger) + try { + db.getAll("SELECT * FROM users") { } shouldHaveSize 0 + } finally { + db.close() + } + } + + @Test + fun watch() = + runTest { + val db = PowerSyncDatabase.inMemory(this, schema, logger) + try { + turbineScope { + val turbine = + db.watch("SELECT name FROM users", mapper = { it.getString(0)!! }).testIn(this) + + turbine.awaitItem() shouldBe listOf() + + db.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("test user")) + turbine.awaitItem() shouldBe listOf("test user") + turbine.cancelAndIgnoreRemainingEvents() + } + } finally { + db.close() + } + } + + companion object { + private val schema = + Schema( + Table( + name = "users", + columns = + listOf( + Column.text("name"), + ), + ), + ) + } +} diff --git a/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt b/integrations/sqldelight/src/commonTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt similarity index 100% rename from integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt rename to integrations/sqldelight/src/commonTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt From 95506308e4efb1fb204fed947ce57fb63712dc90 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 29 Sep 2025 13:28:09 +0200 Subject: [PATCH 02/14] Fix in-memory tests --- core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt b/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt index 8cc323db..ef06c4d9 100644 --- a/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt @@ -33,7 +33,7 @@ class InMemoryTest { @Test fun createsSchema() = runTest { - val db = PowerSyncDatabase.inMemory(this, schema, logger) + val db = PowerSyncDatabase.inMemory(schema, this, logger) try { db.getAll("SELECT * FROM users") { } shouldHaveSize 0 } finally { @@ -44,7 +44,7 @@ class InMemoryTest { @Test fun watch() = runTest { - val db = PowerSyncDatabase.inMemory(this, schema, logger) + val db = PowerSyncDatabase.inMemory(schema, this, logger) try { turbineScope { val turbine = From 321e95eed430424204279d16a41383aae86728e7 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 29 Sep 2025 12:41:16 +0200 Subject: [PATCH 03/14] Allow customizing error handling --- .../connector/supabase/SupabaseConnector.kt | 123 +++++++++++------- 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt index 12913cda..13870435 100644 --- a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt +++ b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt @@ -5,6 +5,7 @@ import com.powersync.PowerSyncDatabase import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.connectors.PowerSyncCredentials import com.powersync.db.crud.CrudEntry +import com.powersync.db.crud.CrudTransaction import com.powersync.db.crud.UpdateType import com.powersync.db.runWrapped import io.github.jan.supabase.SupabaseClient @@ -27,12 +28,13 @@ import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive +import kotlin.toString /** * Get a Supabase token to authenticate against the PowerSync instance. */ @OptIn(SupabaseInternal::class, InternalAPI::class) -public class SupabaseConnector( +public open class SupabaseConnector( public val supabaseClient: SupabaseClient, public val powerSyncEndpoint: String, private val storageBucket: String? = null, @@ -40,7 +42,7 @@ public class SupabaseConnector( private val json = Json { coerceInputValues = true } private var errorCode: String? = null - private object PostgresFatalCodes { + public companion object PostgresFatalCodes { // Using Regex patterns for Postgres error codes private val FATAL_RESPONSE_CODES = listOf( @@ -52,7 +54,7 @@ public class SupabaseConnector( "^42501$".toRegex(), ) - fun isFatalError(code: String): Boolean = + public fun isFatalError(code: String): Boolean = FATAL_RESPONSE_CODES.any { pattern -> pattern.matches(code) } @@ -172,6 +174,73 @@ public class SupabaseConnector( ) } + /** + * Uses the PostgREST APIs to upload a given [entry] to the backend database. + * + * This method should report errors during the upload as an exception that would be caught by [uploadData]. + */ + public open suspend fun uploadCrudEntry(entry: CrudEntry) { + val table = supabaseClient.from(entry.table) + + when (entry.op) { + UpdateType.PUT -> { + val data = + buildMap { + put("id", JsonPrimitive(entry.id)) + entry.opData?.jsonValues?.let { putAll(it) } + } + table.upsert(data) + } + UpdateType.PATCH -> { + table.update(entry.opData!!.jsonValues) { + filter { + eq("id", entry.id) + } + } + } + UpdateType.DELETE -> { + table.delete { + filter { + eq("id", entry.id) + } + } + } + } + } + + /** + * Handles an error during the upload. This method can be overridden to log errors or customize error handling. + * + * By default, it throws the rest of a transaction away when the error code indicates that this is a fatal postgres + * error that can't be retried. Otherwise, it rethrows the exception so that the PowerSync SDK will retry. + * + * @param tx The full [CrudTransaction] we're in the process of uploading. + * @param entry The [CrudEntry] for which an upload has failed. + * @param exception The [Exception] thrown by the Supabase client. + * @param [errorCode] The postgres error code, if any. + * @throws Exception If the upload should be retried. If this method doesn't throw, it should mark [tx] as complete + * by invoking [CrudTransaction.complete]. In that case, the local write would be lost. + */ + public open suspend fun handleError(tx: CrudTransaction, entry: CrudEntry, exception: Exception, errorCode: String?) { + if (errorCode != null && isFatalError(errorCode)) { + /** + * Instead of blocking the queue with these errors, + * discard the (rest of the) transaction. + * + * Note that these errors typically indicate a bug in the application. + * If protecting against data loss is important, save the failing records + * elsewhere instead of discarding, and/or notify the user. + */ + Logger.e("Data upload error: ${exception.message}") + Logger.e("Discarding entry: $entry") + tx.complete(null) + return + } + + Logger.e("Data upload error - retrying last entry: $entry, $exception") + throw exception + } + /** * Upload local changes to the app backend (in this case Supabase). * @@ -186,54 +255,16 @@ public class SupabaseConnector( try { for (entry in transaction.crud) { lastEntry = entry - - val table = supabaseClient.from(entry.table) - - when (entry.op) { - UpdateType.PUT -> { - val data = - buildMap { - put("id", JsonPrimitive(entry.id)) - entry.opData?.jsonValues?.let { putAll(it) } - } - table.upsert(data) - } - UpdateType.PATCH -> { - table.update(entry.opData!!.jsonValues) { - filter { - eq("id", entry.id) - } - } - } - UpdateType.DELETE -> { - table.delete { - filter { - eq("id", entry.id) - } - } - } - } + uploadCrudEntry(entry) } transaction.complete(null) } catch (e: Exception) { - if (errorCode != null && PostgresFatalCodes.isFatalError(errorCode.toString())) { - /** - * Instead of blocking the queue with these errors, - * discard the (rest of the) transaction. - * - * Note that these errors typically indicate a bug in the application. - * If protecting against data loss is important, save the failing records - * elsewhere instead of discarding, and/or notify the user. - */ - Logger.e("Data upload error: ${e.message}") - Logger.e("Discarding entry: $lastEntry") - transaction.complete(null) - return@runWrapped + if (lastEntry != null) { + handleError(transaction, lastEntry, e, errorCode) + } else { + throw e } - - Logger.e("Data upload error - retrying last entry: $lastEntry, $e") - throw e } } } From 3eece506d30765342947c6e02040b412283b32c6 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 29 Sep 2025 13:15:46 +0200 Subject: [PATCH 04/14] Supabase connector: Allow error handling --- CHANGELOG.md | 1 + connectors/supabase/build.gradle.kts | 6 ++ .../supabase/SupabaseConnectorTest.kt | 57 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 connectors/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 207196f9..47cfc7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add `PowerSyncDatabase.inMemory` to create an in-memory SQLite database with PowerSync. This may be useful for testing. +- The Supabase connector can now be subclassed to customize how rows are uploaded and how errors are handled. ## 1.6.1 diff --git a/connectors/supabase/build.gradle.kts b/connectors/supabase/build.gradle.kts index 0410af6a..77350781 100644 --- a/connectors/supabase/build.gradle.kts +++ b/connectors/supabase/build.gradle.kts @@ -31,6 +31,12 @@ kotlin { api(libs.supabase.auth) api(libs.supabase.storage) } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.test.coroutines) + implementation(libs.test.kotest.assertions) + } } } diff --git a/connectors/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt b/connectors/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt new file mode 100644 index 00000000..eceb5be6 --- /dev/null +++ b/connectors/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt @@ -0,0 +1,57 @@ +package com.powersync.connector.supabase + +import com.powersync.PowerSyncDatabase +import com.powersync.db.crud.CrudEntry +import com.powersync.db.crud.CrudTransaction +import com.powersync.db.schema.Column +import com.powersync.db.schema.Schema +import com.powersync.db.schema.Table +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class SupabaseConnectorTest { + + @Test + fun errorHandling() = runTest { + val db = PowerSyncDatabase.inMemory(scope=this, schema= Schema(Table("users", listOf( + Column.text("name") + )))) + + try { + db.writeTransaction { tx -> + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("a")) + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("b")) + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("c")) + } + + var calledErrorHandler = false + val connector = object : SupabaseConnector("", "", "") { + override suspend fun uploadCrudEntry(entry: CrudEntry) { + throw Exception("Expected exception, failing in uploadCrudEntry") + } + + override suspend fun handleError( + tx: CrudTransaction, + entry: CrudEntry, + exception: Exception, + errorCode: String? + ) { + calledErrorHandler = true + + tx.crud shouldHaveSize 3 + entry shouldBeEqual tx.crud[0] + exception.message shouldBe "Expected exception, failing in uploadCrudEntry" + tx.complete(null) + } + } + + connector.uploadData(db) + calledErrorHandler shouldBe true + } finally { + db.close() + } + } +} From 75a3a35fb309c8f5702cce4999254a6a54f78893 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 29 Sep 2025 13:26:22 +0200 Subject: [PATCH 05/14] Move supabase connector to integrations folder --- README.md | 12 ++++-------- build.gradle.kts | 2 +- demos/android-supabase-todolist/build.gradle.kts | 2 +- .../androidBackgroundSync/build.gradle.kts | 2 +- demos/supabase-todolist/shared/build.gradle.kts | 2 +- {connectors => integrations/supabase}/README.md | 14 +++++--------- .../supabase/build.gradle.kts | 0 .../supabase/gradle.properties | 0 .../connector/supabase/SupabaseConnector.kt | 0 .../connector/supabase/SupabaseRemoteStorage.kt | 0 .../connector/supabase/SupabaseConnectorTest.kt | 0 settings.gradle.kts | 2 +- 12 files changed, 14 insertions(+), 22 deletions(-) rename {connectors => integrations/supabase}/README.md (57%) rename {connectors => integrations}/supabase/build.gradle.kts (100%) rename {connectors => integrations}/supabase/gradle.properties (100%) rename {connectors => integrations}/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt (100%) rename {connectors => integrations}/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseRemoteStorage.kt (100%) rename {connectors => integrations}/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt (100%) diff --git a/README.md b/README.md index aa5bdeca..ad0d92ed 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,14 @@ and API documentation [here](https://powersync-ja.github.io/powersync-kotlin/). - This is the Kotlin Multiplatform SDK implementation. -- [connectors](./connectors/) - - - [SupabaseConnector.kt](./connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt) An example connector implementation for Supabase (Postgres). The backend - connector provides the connection between your application backend and the PowerSync managed database. It is used to: - 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. - [sqldelight](./integrations/sqldelight/README.md): Allows using [SQLDelight](https://sqldelight.github.io/sqldelight) with PowerSync, also enabling typed statements on the database. - + - [SupabaseConnector.kt](./integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt) An example connector implementation for Supabase (Postgres). The backend + connector provides the connection between your application backend and the PowerSync managed database. It is used to: + 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). ## Demo Apps / Example Projects diff --git a/build.gradle.kts b/build.gradle.kts index 967269ec..e5d1df9c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,10 +68,10 @@ tasks.getByName("clean") { // Merges individual module docs into a single HTML output dependencies { dokka(project(":core:")) - dokka(project(":connectors:supabase")) dokka(project(":compose:")) dokka(project(":integrations:room")) dokka(project(":integrations:sqldelight")) + dokka(project(":integrations:supabase")) } dokka { diff --git a/demos/android-supabase-todolist/build.gradle.kts b/demos/android-supabase-todolist/build.gradle.kts index 391e8711..737ffd68 100644 --- a/demos/android-supabase-todolist/build.gradle.kts +++ b/demos/android-supabase-todolist/build.gradle.kts @@ -127,7 +127,7 @@ dependencies { // When adopting the PowerSync dependencies into your project, use the latest version available at // https://central.sonatype.com/artifact/com.powersync/core implementation(projects.core) // "com.powersync:core:latest.release" - implementation(projects.connectors.supabase) // "com.powersync:connector-supabase:latest.release" + implementation(projects.integrations.supabase) // "com.powersync:connector-supabase:latest.release" implementation(projects.compose) // "com.powersync:compose:latest.release" implementation(libs.uuid) implementation(libs.kermit) diff --git a/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts b/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts index d8144eed..70fad978 100644 --- a/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts +++ b/demos/supabase-todolist/androidBackgroundSync/build.gradle.kts @@ -44,7 +44,7 @@ android { dependencies { // When copying this example, use the the current version available // at: https://central.sonatype.com/artifact/com.powersync/connector-supabase - implementation(projects.connectors.supabase) // "com.powersync:connector-supabase" + implementation(projects.integrations.supabase) // "com.powersync:connector-supabase" implementation(projects.demos.supabaseTodolist.shared) diff --git a/demos/supabase-todolist/shared/build.gradle.kts b/demos/supabase-todolist/shared/build.gradle.kts index 43b4a7a7..3a0fe227 100644 --- a/demos/supabase-todolist/shared/build.gradle.kts +++ b/demos/supabase-todolist/shared/build.gradle.kts @@ -42,7 +42,7 @@ kotlin { // When copying this example, use the current version available // at: https://central.sonatype.com/artifact/com.powersync/core api(projects.core) // "com.powersync:core" - implementation(projects.connectors.supabase) // "com.powersync:connector-supabase" + implementation(projects.integrations.supabase) // "com.powersync:connector-supabase" implementation(projects.compose) // "com.powersync:compose" implementation(libs.uuid) implementation(compose.runtime) diff --git a/connectors/README.md b/integrations/supabase/README.md similarity index 57% rename from connectors/README.md rename to integrations/supabase/README.md index 0c9a09c4..28e58130 100644 --- a/connectors/README.md +++ b/integrations/supabase/README.md @@ -1,16 +1,12 @@ -# PowerSync Backend Connectors +# PowerSync Supabase connector -Convenience implementations of backend connectors that provide the connection between your application backend and the PowerSync managed database. +Convenience implementation of a backend connector that provide the connection between your application backend and the PowerSync managed database +by delegating to Supabase. It is used to: 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). +The connector is fairly basic, and also serves as an example for getting started. -## Provided Connectors - -### Supabase (Postgres) - -A basic implementation of a PowerSync Backend Connector for Supabase, that serves as getting started example. - -See a step-by-step tutorial for connecting to Supabase, [here](https://docs.powersync.com/integration-guides/supabase-+-powersync). \ No newline at end of file +See a step-by-step tutorial for connecting to Supabase, [here](https://docs.powersync.com/integration-guides/supabase-+-powersync). diff --git a/connectors/supabase/build.gradle.kts b/integrations/supabase/build.gradle.kts similarity index 100% rename from connectors/supabase/build.gradle.kts rename to integrations/supabase/build.gradle.kts diff --git a/connectors/supabase/gradle.properties b/integrations/supabase/gradle.properties similarity index 100% rename from connectors/supabase/gradle.properties rename to integrations/supabase/gradle.properties diff --git a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt b/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt similarity index 100% rename from connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt rename to integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt diff --git a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseRemoteStorage.kt b/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseRemoteStorage.kt similarity index 100% rename from connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseRemoteStorage.kt rename to integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseRemoteStorage.kt diff --git a/connectors/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt b/integrations/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt similarity index 100% rename from connectors/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt rename to integrations/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 77204629..3a0d5b12 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,12 +32,12 @@ include(":internal:PowerSyncKotlin") include(":core") include(":core-tests-android") -include(":connectors:supabase") include(":integrations:room") include(":static-sqlite-driver") include(":integrations:sqldelight") include(":integrations:sqldelight-test-database") +include(":integrations:supabase") include(":compose") From eab8a9daff6b00746e7269c9c0b8edcd4df0bc3e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 30 Sep 2025 13:18:50 +0200 Subject: [PATCH 06/14] Move to integration test --- .../kotlin/com/powersync/DatabaseTest.kt | 2 - integrations/supabase/build.gradle.kts | 26 +++++++-- .../supabase/SupabaseConnectorTest.kt | 57 ------------------- 3 files changed, 22 insertions(+), 63 deletions(-) delete mode 100644 integrations/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 810d25d9..97eee155 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -1,7 +1,5 @@ package com.powersync -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.execSQL import app.cash.turbine.test import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi diff --git a/integrations/supabase/build.gradle.kts b/integrations/supabase/build.gradle.kts index 77350781..fef9fb0d 100644 --- a/integrations/supabase/build.gradle.kts +++ b/integrations/supabase/build.gradle.kts @@ -32,11 +32,29 @@ kotlin { api(libs.supabase.storage) } - commonTest.dependencies { - implementation(libs.kotlin.test) - implementation(libs.test.coroutines) - implementation(libs.test.kotest.assertions) + val commonIntegrationTest by creating { + dependsOn(commonTest.get()) + + dependencies { + // Separate project because SQLDelight can't generate code in test source sets. + implementation(projects.integrations.sqldelightTestDatabase) + + implementation(libs.kotlin.test) + implementation(libs.kotlinx.io) + implementation(libs.test.turbine) + implementation(libs.test.coroutines) + implementation(libs.test.kotest.assertions) + + implementation(libs.sqldelight.coroutines) + } } + + // The PowerSync SDK links the core extension, so we can just run tests as-is. + 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) } } diff --git a/integrations/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt b/integrations/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt deleted file mode 100644 index eceb5be6..00000000 --- a/integrations/supabase/src/commonTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.powersync.connector.supabase - -import com.powersync.PowerSyncDatabase -import com.powersync.db.crud.CrudEntry -import com.powersync.db.crud.CrudTransaction -import com.powersync.db.schema.Column -import com.powersync.db.schema.Schema -import com.powersync.db.schema.Table -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.equals.shouldBeEqual -import io.kotest.matchers.shouldBe -import kotlinx.coroutines.test.runTest -import kotlin.test.Test - -class SupabaseConnectorTest { - - @Test - fun errorHandling() = runTest { - val db = PowerSyncDatabase.inMemory(scope=this, schema= Schema(Table("users", listOf( - Column.text("name") - )))) - - try { - db.writeTransaction { tx -> - tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("a")) - tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("b")) - tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("c")) - } - - var calledErrorHandler = false - val connector = object : SupabaseConnector("", "", "") { - override suspend fun uploadCrudEntry(entry: CrudEntry) { - throw Exception("Expected exception, failing in uploadCrudEntry") - } - - override suspend fun handleError( - tx: CrudTransaction, - entry: CrudEntry, - exception: Exception, - errorCode: String? - ) { - calledErrorHandler = true - - tx.crud shouldHaveSize 3 - entry shouldBeEqual tx.crud[0] - exception.message shouldBe "Expected exception, failing in uploadCrudEntry" - tx.complete(null) - } - } - - connector.uploadData(db) - calledErrorHandler shouldBe true - } finally { - db.close() - } - } -} From 99d5f1af8c22efcc2202584525d505d26be34a50 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 30 Sep 2025 13:19:24 +0200 Subject: [PATCH 07/14] Make core extension available --- .../integrations/sqldelight/SqlDelightTest.kt | 0 integrations/supabase/build.gradle.kts | 4 +- .../supabase/SupabaseConnectorTest.kt | 57 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) rename integrations/sqldelight/src/{commonTest => commonIntegrationTest}/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt (100%) create mode 100644 integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt diff --git a/integrations/sqldelight/src/commonTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt b/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt similarity index 100% rename from integrations/sqldelight/src/commonTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt rename to integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt diff --git a/integrations/supabase/build.gradle.kts b/integrations/supabase/build.gradle.kts index fef9fb0d..23550407 100644 --- a/integrations/supabase/build.gradle.kts +++ b/integrations/supabase/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlinter) id("com.powersync.plugins.sonatype") + id("com.powersync.plugins.sharedbuild") id("dokka-convention") } @@ -22,10 +23,11 @@ kotlin { } explicitApi() + applyDefaultHierarchyTemplate() sourceSets { commonMain.dependencies { - api(project(":core")) + api(projects.core) implementation(libs.kotlinx.coroutines.core) implementation(libs.supabase.client) api(libs.supabase.auth) diff --git a/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt b/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt new file mode 100644 index 00000000..eceb5be6 --- /dev/null +++ b/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt @@ -0,0 +1,57 @@ +package com.powersync.connector.supabase + +import com.powersync.PowerSyncDatabase +import com.powersync.db.crud.CrudEntry +import com.powersync.db.crud.CrudTransaction +import com.powersync.db.schema.Column +import com.powersync.db.schema.Schema +import com.powersync.db.schema.Table +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class SupabaseConnectorTest { + + @Test + fun errorHandling() = runTest { + val db = PowerSyncDatabase.inMemory(scope=this, schema= Schema(Table("users", listOf( + Column.text("name") + )))) + + try { + db.writeTransaction { tx -> + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("a")) + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("b")) + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("c")) + } + + var calledErrorHandler = false + val connector = object : SupabaseConnector("", "", "") { + override suspend fun uploadCrudEntry(entry: CrudEntry) { + throw Exception("Expected exception, failing in uploadCrudEntry") + } + + override suspend fun handleError( + tx: CrudTransaction, + entry: CrudEntry, + exception: Exception, + errorCode: String? + ) { + calledErrorHandler = true + + tx.crud shouldHaveSize 3 + entry shouldBeEqual tx.crud[0] + exception.message shouldBe "Expected exception, failing in uploadCrudEntry" + tx.complete(null) + } + } + + connector.uploadData(db) + calledErrorHandler shouldBe true + } finally { + db.close() + } + } +} From 025c79f498871784e9602f2b9f833a6c1c8980ec Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 30 Sep 2025 13:23:18 +0200 Subject: [PATCH 08/14] Bad merge --- .../kotlin/com/powersync/db/InMemoryTest.kt | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt diff --git a/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt b/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt deleted file mode 100644 index ef06c4d9..00000000 --- a/core/src/commonTest/kotlin/com/powersync/db/InMemoryTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.powersync.db - -import app.cash.turbine.turbineScope -import co.touchlab.kermit.ExperimentalKermitApi -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import co.touchlab.kermit.TestConfig -import co.touchlab.kermit.TestLogWriter -import com.powersync.PowerSyncDatabase -import com.powersync.db.schema.Column -import com.powersync.db.schema.Schema -import com.powersync.db.schema.Table -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import kotlinx.coroutines.test.runTest -import kotlin.test.Test - -@OptIn(ExperimentalKermitApi::class) -class InMemoryTest { - private val logWriter = - TestLogWriter( - loggable = Severity.Debug, - ) - - private val logger = - Logger( - TestConfig( - minSeverity = Severity.Debug, - logWriterList = listOf(logWriter), - ), - ) - - @Test - fun createsSchema() = - runTest { - val db = PowerSyncDatabase.inMemory(schema, this, logger) - try { - db.getAll("SELECT * FROM users") { } shouldHaveSize 0 - } finally { - db.close() - } - } - - @Test - fun watch() = - runTest { - val db = PowerSyncDatabase.inMemory(schema, this, logger) - try { - turbineScope { - val turbine = - db.watch("SELECT name FROM users", mapper = { it.getString(0)!! }).testIn(this) - - turbine.awaitItem() shouldBe listOf() - - db.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("test user")) - turbine.awaitItem() shouldBe listOf("test user") - turbine.cancelAndIgnoreRemainingEvents() - } - } finally { - db.close() - } - } - - companion object { - private val schema = - Schema( - Table( - name = "users", - columns = - listOf( - Column.text("name"), - ), - ), - ) - } -} From 2104fd9a65b4300c106a3c9d327ae8ef6dfc7237 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 30 Sep 2025 13:27:43 +0200 Subject: [PATCH 09/14] Fix formatting --- .../supabase/SupabaseConnectorTest.kt | 78 +++++++++++-------- .../connector/supabase/SupabaseConnector.kt | 7 +- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt b/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt index eceb5be6..14728a6b 100644 --- a/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt +++ b/integrations/supabase/src/commonIntegrationTest/kotlin/com/powersync/connector/supabase/SupabaseConnectorTest.kt @@ -13,45 +13,55 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test class SupabaseConnectorTest { - @Test - fun errorHandling() = runTest { - val db = PowerSyncDatabase.inMemory(scope=this, schema= Schema(Table("users", listOf( - Column.text("name") - )))) - - try { - db.writeTransaction { tx -> - tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("a")) - tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("b")) - tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("c")) - } + fun errorHandling() = + runTest { + val db = + PowerSyncDatabase.inMemory( + scope = this, + schema = + Schema( + Table( + "users", + listOf( + Column.text("name"), + ), + ), + ), + ) - var calledErrorHandler = false - val connector = object : SupabaseConnector("", "", "") { - override suspend fun uploadCrudEntry(entry: CrudEntry) { - throw Exception("Expected exception, failing in uploadCrudEntry") + try { + db.writeTransaction { tx -> + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("a")) + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("b")) + tx.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("c")) } - override suspend fun handleError( - tx: CrudTransaction, - entry: CrudEntry, - exception: Exception, - errorCode: String? - ) { - calledErrorHandler = true + var calledErrorHandler = false + val connector = + object : SupabaseConnector("", "", "") { + override suspend fun uploadCrudEntry(entry: CrudEntry): Unit = + throw Exception("Expected exception, failing in uploadCrudEntry") - tx.crud shouldHaveSize 3 - entry shouldBeEqual tx.crud[0] - exception.message shouldBe "Expected exception, failing in uploadCrudEntry" - tx.complete(null) - } - } + override suspend fun handleError( + tx: CrudTransaction, + entry: CrudEntry, + exception: Exception, + errorCode: String?, + ) { + calledErrorHandler = true + + tx.crud shouldHaveSize 3 + entry shouldBeEqual tx.crud[0] + exception.message shouldBe "Expected exception, failing in uploadCrudEntry" + tx.complete(null) + } + } - connector.uploadData(db) - calledErrorHandler shouldBe true - } finally { - db.close() + connector.uploadData(db) + calledErrorHandler shouldBe true + } finally { + db.close() + } } - } } diff --git a/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt b/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt index 13870435..d386da28 100644 --- a/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt +++ b/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt @@ -221,7 +221,12 @@ public open class SupabaseConnector( * @throws Exception If the upload should be retried. If this method doesn't throw, it should mark [tx] as complete * by invoking [CrudTransaction.complete]. In that case, the local write would be lost. */ - public open suspend fun handleError(tx: CrudTransaction, entry: CrudEntry, exception: Exception, errorCode: String?) { + public open suspend fun handleError( + tx: CrudTransaction, + entry: CrudEntry, + exception: Exception, + errorCode: String?, + ) { if (errorCode != null && isFatalError(errorCode)) { /** * Instead of blocking the queue with these errors, From 884361293fa726be8cfa5f96c6594d6cbec723a7 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 1 Oct 2025 09:46:41 +0200 Subject: [PATCH 10/14] Review suggestions --- integrations/supabase/README.md | 2 +- integrations/supabase/build.gradle.kts | 3 --- .../com/powersync/connector/supabase/SupabaseConnector.kt | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/integrations/supabase/README.md b/integrations/supabase/README.md index 28e58130..76218687 100644 --- a/integrations/supabase/README.md +++ b/integrations/supabase/README.md @@ -1,4 +1,4 @@ -# PowerSync Supabase connector +# PowerSync Supabase Connector Convenience implementation of a backend connector that provide the connection between your application backend and the PowerSync managed database by delegating to Supabase. diff --git a/integrations/supabase/build.gradle.kts b/integrations/supabase/build.gradle.kts index 23550407..9004bbcb 100644 --- a/integrations/supabase/build.gradle.kts +++ b/integrations/supabase/build.gradle.kts @@ -38,9 +38,6 @@ kotlin { dependsOn(commonTest.get()) dependencies { - // Separate project because SQLDelight can't generate code in test source sets. - implementation(projects.integrations.sqldelightTestDatabase) - implementation(libs.kotlin.test) implementation(libs.kotlinx.io) implementation(libs.test.turbine) diff --git a/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt b/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt index d386da28..15ebbc3e 100644 --- a/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt +++ b/integrations/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt @@ -211,7 +211,7 @@ public open class SupabaseConnector( /** * Handles an error during the upload. This method can be overridden to log errors or customize error handling. * - * By default, it throws the rest of a transaction away when the error code indicates that this is a fatal postgres + * By default, it discards the rest of a transaction when the error code indicates that this is a fatal postgres * error that can't be retried. Otherwise, it rethrows the exception so that the PowerSync SDK will retry. * * @param tx The full [CrudTransaction] we're in the process of uploading. From 0b7ae208bded7a3ba3cdc994cb53963320fd6146 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 2 Oct 2025 09:57:42 +0200 Subject: [PATCH 11/14] Fix reconnect race in test --- CHANGELOG.md | 1 + .../com/powersync/sync/SyncStreamTest.kt | 3 ++- .../com/powersync/testutils/TestUtils.kt | 21 ++++++++++++++++++- .../powersync/testutils/MockSyncService.kt | 7 +++---- gradle.properties | 2 +- .../integrations/room/TestDatabase.kt | 2 +- 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47cfc7af..f3123235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add `PowerSyncDatabase.inMemory` to create an in-memory SQLite database with PowerSync. This may be useful for testing. - The Supabase connector can now be subclassed to customize how rows are uploaded and how errors are handled. +- Experimental support for sync streams. ## 1.6.1 diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt index 1a8ed4f4..9f23f9b4 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -163,9 +163,10 @@ class SyncStreamTest : AbstractSyncTest(true) { requestedSyncStreams.clear() val subscription = database.syncStream("a").subscribe() + waitForSyncLinesChannelClosed() // Adding the subscription should reconnect - turbine.waitFor { it.connected && !it.downloading } + turbine.waitFor { it.connected } requestedSyncStreams shouldHaveSingleElement { val streams = it.jsonObject["streams"]!!.jsonObject val subscriptions = streams["subscriptions"]!!.jsonArray diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index 0b533cfd..fed689ff 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -23,10 +23,12 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.mock.toByteArray import io.ktor.http.ContentType import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path import kotlinx.serialization.json.JsonElement +import kotlin.coroutines.resume expect val factory: DatabaseDriverFactory @@ -102,6 +104,23 @@ internal class ActiveDatabaseTest( var connector = TestConnector() + suspend fun waitForSyncLinesChannelClosed() { + suspendCancellableCoroutine { continuation -> + var cancelled = false + continuation.invokeOnCancellation { + cancelled = true + } + + syncLines.invokeOnClose { + if (!cancelled) { + continuation.resume(Unit) + } + + syncLines = Channel() + } + } + } + fun openDatabase(schema: Schema = Schema(UserRow.table)): PowerSyncDatabaseImpl { logger.d { "Opening database $databaseName in directory $testDirectory" } val db = @@ -123,7 +142,7 @@ internal class ActiveDatabaseTest( fun createSyncClient(): HttpClient { val engine = MockSyncService( - lines = syncLines, + lines = { syncLines }, generateCheckpoint = { checkpointResponse() }, syncLinesContentType = { syncLinesContentType }, trackSyncRequest = { diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt b/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt index ea3cbf77..20c0dc12 100644 --- a/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt +++ b/core/src/commonTest/kotlin/com/powersync/testutils/MockSyncService.kt @@ -38,7 +38,7 @@ import kotlinx.serialization.json.JsonElement */ @OptIn(LegacySyncImplementation::class) internal class MockSyncService( - private val lines: ReceiveChannel, + private val lines: () -> ReceiveChannel, private val syncLinesContentType: () -> ContentType, private val generateCheckpoint: () -> WriteCheckpointResponse, private val trackSyncRequest: suspend (HttpRequestData) -> Unit, @@ -60,12 +60,11 @@ internal class MockSyncService( trackSyncRequest(data) val job = scope.writer { - lines.consume { + lines().consume { while (true) { // Wait for a downstream listener being ready before requesting a sync line channel.awaitFreeSpace() - val line = receive() - when (line) { + when (val line = receive()) { is SyncLine -> { val serializedLine = JsonUtil.json.encodeToString(line) channel.writeStringUtf8("$serializedLine\n") diff --git a/gradle.properties b/gradle.properties index 72b5be9e..a2776781 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ development=true RELEASE_SIGNING_ENABLED=true # Library config GROUP=com.powersync -LIBRARY_VERSION=1.6.1 +LIBRARY_VERSION=1.7.0 GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git # POM POM_URL=https://github.com/powersync-ja/powersync-kotlin/ diff --git a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt index 17bc4f6a..1dffff4a 100644 --- a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/TestDatabase.kt @@ -37,7 +37,7 @@ interface UserDao { suspend fun delete(user: User) } -@Database(entities = [User::class], version = 1) +@Database(entities = [User::class], version = 1, exportSchema = false) @ConstructedBy(TestDatabaseConstructor::class) abstract class TestDatabase : RoomDatabase() { abstract fun userDao(): UserDao From a55c9c90cd7baf91b199b81047c91013cccbedce Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 2 Oct 2025 10:00:47 +0200 Subject: [PATCH 12/14] Improve room test reliability --- .../com/powersync/integrations/room/PowerSyncRoomTest.kt | 5 +++++ .../com/powersync/integrations/room/RoomConnectionPool.kt | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt index e9267193..143a26c1 100644 --- a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -88,6 +88,8 @@ class PowerSyncRoomTest { turbine.awaitItem() shouldHaveSize 1 turbine.cancel() } + + powersync.close() } @Test @@ -108,6 +110,7 @@ class PowerSyncRoomTest { database.userDao().getAll() shouldHaveSize 0 powersync.execute("insert into user values (uuid(), ?)", listOf("PowerSync user")) database.userDao().getAll() shouldHaveSize 1 + powersync.close() } @Test @@ -133,5 +136,7 @@ class PowerSyncRoomTest { turbine.awaitItem() shouldHaveSize 1 turbine.cancel() } + + powersync.close() } } diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt index 3f8aad30..ce4ec0ce 100644 --- a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -91,7 +91,7 @@ public class RoomConnectionPool( val changed = it.usePrepared("SELECT powersync_update_hooks('get')") { stmt -> check(stmt.step()) - json.decodeFromString>(stmt.getText(0)) + Json.decodeFromString>(stmt.getText(0)) } val userTables = @@ -114,10 +114,6 @@ public class RoomConnectionPool( override suspend fun close() { // Noop, Room database managed independently } - - private companion object { - val json = Json {} - } } private class RoomTransactionLease( From f1dfa253aebf14f4651a35bcb2e8ecf45a8de936 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 2 Oct 2025 10:17:32 +0200 Subject: [PATCH 13/14] Try catching closed pool message --- .../integrations/room/PowerSyncRoomTest.kt | 11 +++++++---- .../integrations/room/RoomConnectionPool.kt | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt index 143a26c1..49b44ec6 100644 --- a/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt +++ b/integrations/room/src/commonIntegrationTest/kotlin/com/powersync/integrations/room/PowerSyncRoomTest.kt @@ -2,7 +2,9 @@ package com.powersync.integrations.room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import app.cash.turbine.turbineScope +import co.touchlab.kermit.CommonWriter import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import co.touchlab.kermit.loggerConfigInit import com.powersync.PowerSyncDatabase import com.powersync.db.getString @@ -28,6 +30,7 @@ class PowerSyncRoomTest { @AfterTest fun tearDown() { + logger.i { "Closing Room database" } database.close() } @@ -35,7 +38,6 @@ class PowerSyncRoomTest { fun roomWritePowerSyncRead() = runTest { database.userDao().create(User(id = "test", name = "Test user")) - val logger = Logger(loggerConfigInit()) val powersync = PowerSyncDatabase.opened( @@ -61,7 +63,6 @@ class PowerSyncRoomTest { @Test fun roomWritePowerSyncWatch() = runTest { - val logger = Logger(loggerConfigInit()) val pool = RoomConnectionPool(database, TestDatabase.schema) val powersync = @@ -95,7 +96,6 @@ class PowerSyncRoomTest { @Test fun powersyncWriteRoomRead() = runTest { - val logger = Logger(loggerConfigInit()) val pool = RoomConnectionPool(database, TestDatabase.schema) val powersync = @@ -116,7 +116,6 @@ class PowerSyncRoomTest { @Test fun powersyncWriteRoomWatch() = runTest { - val logger = Logger(loggerConfigInit()) val pool = RoomConnectionPool(database, TestDatabase.schema) val powersync = @@ -139,4 +138,8 @@ class PowerSyncRoomTest { powersync.close() } + + companion object { + private val logger = Logger(loggerConfigInit(CommonWriter())) + } } diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt index ce4ec0ce..da82a9bc 100644 --- a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -5,6 +5,7 @@ import androidx.room.Transactor import androidx.room.execSQL import androidx.room.useReaderConnection import androidx.room.useWriterConnection +import androidx.sqlite.SQLiteException import androidx.sqlite.SQLiteStatement import com.powersync.db.driver.SQLiteConnectionLease import com.powersync.db.driver.SQLiteConnectionPool @@ -73,7 +74,19 @@ public class RoomConnectionPool( db.getCoroutineScope().launch { val tables = schema.rawTables.map { it.name }.toTypedArray() db.invalidationTracker.createFlow(*tables, emitInitialState = false).collect { - transferPendingRoomUpdatesToPowerSync() + try { + transferPendingRoomUpdatesToPowerSync() + } catch (e: SQLiteException) { + // It can happen that we get an update shortly before the database is closed. Since this is + // asynchronous, we'd then be using the database in a closed state, which fails. We handle that by + // stopping the flow collection. + if (e.message == "Connection pool is closed") { + return@collect + } + + throw e + } + } } } From 9c44d0b894f0c30125ea451bd3988bc1a0b66c08 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 2 Oct 2025 10:21:45 +0200 Subject: [PATCH 14/14] Format --- .../kotlin/com/powersync/integrations/room/RoomConnectionPool.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt index da82a9bc..6da84ca3 100644 --- a/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt +++ b/integrations/room/src/commonMain/kotlin/com/powersync/integrations/room/RoomConnectionPool.kt @@ -86,7 +86,6 @@ public class RoomConnectionPool( throw e } - } } }