diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38d623bb..a618511c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,13 +16,13 @@ jobs: matrix: include: - os: macos-latest - targets: iosSimulatorArm64Test macosArm64Test watchosSimulatorArm64Test jvmTest + targets: iosSimulatorArm64Test macosArm64Test watchosSimulatorArm64Test tvosSimulatorArm64Test jvmTest - os: ubuntu-latest targets: testDebugUnitTest testReleaseUnitTest jvmTest lintKotlin - os: windows-latest targets: jvmTest runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3963c40a..9f8c327d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.4.0 + +* Added the ability to log PowerSync service HTTP request information via specifying a + `SyncClientConfiguration` in the `SyncOptions.clientConfiguration` parameter used in + `PowerSyncDatabase.connect()` calls. +* `CrudEntry`: Introduce `SqliteRow` interface for `opData` and `previousValues`, providing typed + access to the underlying values. +* Update core extension to 0.4.2, fixing a bug where `hasSynced` would turn `false` when losing + connectivity. + ## 1.3.1 * Update SQLite to 3.50.3. @@ -12,7 +22,8 @@ ## 1.3.0 * Support tables created outside of PowerSync with the `RawTable` API. - For more information, see [the documentation](https://docs.powersync.com/usage/use-case-examples/raw-tables). + For more information, + see [the documentation](https://docs.powersync.com/usage/use-case-examples/raw-tables). * Fix `runWrapped` catching cancellation exceptions. * Fix errors in `PowerSyncBackendConnector.fetchCredentials()` crashing Android apps. @@ -23,7 +34,8 @@ ## 1.2.1 -* [Supabase Connector] Fixed issue where only `400` HTTP status code errors where reported as connection errors. The connector now reports errors for codes `>=400`. +* [Supabase Connector] Fixed issue where only `400` HTTP status code errors where reported as + connection errors. The connector now reports errors for codes `>=400`. * Update PowerSync core extension to `0.4.1`, fixing an issue with the new Rust client. * Rust sync client: Fix writes made while offline not being uploaded reliably. * Add watchOS support. @@ -32,7 +44,7 @@ * Add a new sync client implementation written in Rust instead of Kotlin. While this client is still experimental, we intend to make it the default in the future. The main benefit of this client is - faster sync performance, but upcoming features will also require this client. We encourage + faster sync performance, but upcoming features will also require this client. We encourage interested users to try it out by opting in to `ExperimentalPowerSyncAPI` and passing options when connecting: ```Kotlin @@ -62,10 +74,13 @@ ## 1.1.0 -* Add `trackPreviousValues` option on `Table` which sets `CrudEntry.previousValues` to previous values on updates. -* Add `trackMetadata` option on `Table` which adds a `_metadata` column that can be used for updates. +* Add `trackPreviousValues` option on `Table` which sets `CrudEntry.previousValues` to previous + values on updates. +* Add `trackMetadata` option on `Table` which adds a `_metadata` column that can be used for + updates. The configured metadata is available through `CrudEntry.metadata`. -* Add `ignoreEmptyUpdates` option which skips creating CRUD entries for updates that don't change any values. +* Add `ignoreEmptyUpdates` option which skips creating CRUD entries for updates that don't change + any values. ## 1.0.1 diff --git a/PowerSyncKotlin/build.gradle.kts b/PowerSyncKotlin/build.gradle.kts index 9b5c0f68..505bf65f 100644 --- a/PowerSyncKotlin/build.gradle.kts +++ b/PowerSyncKotlin/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":core")) + implementation(libs.ktor.client.logging) } } } @@ -58,8 +59,16 @@ listOf("Debug", "Release").forEach { buildType -> val originalFramework = tasks.getByName("assemblePowerSyncKotlin${buildType}XCFramework") dependsOn(originalFramework) - val source = project.layout.buildDirectory.map { it.dir("XCFrameworks/${buildType.lowercase()}") }.get().asFile - val archiveFile = project.layout.buildDirectory.map { it.file("FrameworkArchives/PowersyncKotlin$buildType.zip") }.get().asFile + val source = + project.layout.buildDirectory + .map { it.dir("XCFrameworks/${buildType.lowercase()}") } + .get() + .asFile + val archiveFile = + project.layout.buildDirectory + .map { it.file("FrameworkArchives/PowersyncKotlin$buildType.zip") } + .get() + .asFile archiveFile.parentFile.mkdirs() archiveFile.delete() diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt index 350c593f..6204de80 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt @@ -2,7 +2,11 @@ package com.powersync +import com.powersync.sync.SyncClientConfiguration import com.powersync.sync.SyncOptions +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.Logger as KtorLogger /** * Helper class designed to bridge SKIEE methods and allow them to throw @@ -17,7 +21,59 @@ import com.powersync.sync.SyncOptions public fun throwPowerSyncException(exception: PowerSyncException): Unit = throw exception /** - * Creates a [ConnectionMethod] based on simple booleans, because creating the actual instance with + * A small wrapper around the Ktor LogLevel enum to allow + * specifying the log level from Swift without exposing the Ktor plugin types. + */ +public enum class SwiftSyncRequestLogLevel { + ALL, + HEADERS, + BODY, + INFO, + NONE, +} + +/** + * Mapper function to Ktor LogLevel + */ +internal fun SwiftSyncRequestLogLevel.toKtorLogLevel(): LogLevel = + when (this) { + SwiftSyncRequestLogLevel.ALL -> LogLevel.ALL + SwiftSyncRequestLogLevel.HEADERS -> LogLevel.HEADERS + SwiftSyncRequestLogLevel.BODY -> LogLevel.BODY + SwiftSyncRequestLogLevel.INFO -> LogLevel.INFO + SwiftSyncRequestLogLevel.NONE -> LogLevel.NONE + } + +/** + * Configuration which is used to configure the Ktor logging plugin + */ +public data class SwiftRequestLoggerConfig( + public val logLevel: SwiftSyncRequestLogLevel, + public val log: (message: String) -> Unit, +) + +/** + * Creates a Ktor [SyncClientConfiguration.ExtendedConfig] that extends the default Ktor client. + * Specifying a [SwiftRequestLoggerConfig] will install the Ktor logging plugin with the specified configuration. + */ +public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftRequestLoggerConfig? = null): SyncClientConfiguration = + SyncClientConfiguration.ExtendedConfig { + if (loggingConfig != null) { + install(Logging) { + // Pass everything to the provided logger. The logger controls the active level + level = loggingConfig.logLevel.toKtorLogLevel() + logger = + object : KtorLogger { + override fun log(message: String) { + loggingConfig.log(message) + } + } + } + } + } + +/** + * Creates a [SyncOptions] based on simple parameters, because creating the actual instance with * the default constructor is not possible from Swift due to an optional argument with an internal * default value. */ @@ -25,8 +81,10 @@ public fun throwPowerSyncException(exception: PowerSyncException): Unit = throw public fun createSyncOptions( newClient: Boolean, userAgent: String, + loggingConfig: SwiftRequestLoggerConfig? = null, ): SyncOptions = SyncOptions( newClientImplementation = newClient, userAgent = userAgent, + clientConfiguration = createExtendedSyncClientConfiguration(loggingConfig), ) 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 43895927..12913cda 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 @@ -26,6 +26,7 @@ import io.ktor.client.statement.bodyAsText import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive /** * Get a Supabase token to authenticate against the PowerSync instance. @@ -190,19 +191,20 @@ public class SupabaseConnector( when (entry.op) { UpdateType.PUT -> { - val data = entry.opData?.toMutableMap() ?: mutableMapOf() - data["id"] = entry.id + val data = + buildMap { + put("id", JsonPrimitive(entry.id)) + entry.opData?.jsonValues?.let { putAll(it) } + } table.upsert(data) } - UpdateType.PATCH -> { - table.update(entry.opData!!) { + table.update(entry.opData!!.jsonValues) { filter { eq("id", entry.id) } } } - UpdateType.DELETE -> { table.delete { filter { diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b1a71240..520ab811 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -6,10 +6,10 @@ import org.gradle.internal.os.OperatingSystem import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest import org.jetbrains.kotlin.gradle.tasks.KotlinTest +import org.jetbrains.kotlin.konan.target.Family import java.nio.file.Path import kotlin.io.path.createDirectories import kotlin.io.path.writeText -import org.jetbrains.kotlin.konan.target.Family plugins { alias(libs.plugins.kotlinMultiplatform) @@ -140,12 +140,13 @@ val generateVersionConstant by tasks.registering { dir.mkdir() val rootPath = dir.toPath() - val source = """ + val source = + """ package $packageName internal const val LIBRARY_VERSION: String = "$currentVersion" - """.trimIndent() + """.trimIndent() val packageRoot = packageName.split('.').fold(rootPath, Path::resolve) packageRoot.createDirectories() @@ -204,7 +205,6 @@ kotlin { dependencies { implementation(libs.uuid) implementation(libs.kotlin.stdlib) - implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.serialization.json) implementation(libs.kotlinx.io) @@ -213,6 +213,7 @@ kotlin { implementation(libs.stately.concurrency) implementation(libs.configuration.annotations) api(projects.persistence) + api(libs.ktor.client.core) api(libs.kermit) } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt index b3c930f9..4ba15d9b 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt @@ -90,4 +90,71 @@ class CrudTest { val batch = database.getNextCrudTransaction() batch shouldBe null } + + @Test + fun typedUpdates() = + databaseTest { + database.updateSchema( + Schema( + Table( + "foo", + listOf( + Column.text("a"), + Column.integer("b"), + Column.integer("c"), + ), + trackPreviousValues = TrackPreviousValuesOptions(onlyWhenChanged = true), + ), + ), + ) + + database.writeTransaction { tx -> + tx.execute( + "INSERT INTO foo (id,a,b,c) VALUES (uuid(), ?, ?, ?)", + listOf( + "text", + 42, + 13.37, + ), + ) + tx.execute( + "UPDATE foo SET a = ?, b = NULL", + listOf( + "te\"xt", + ), + ) + } + + var batch = database.getNextCrudTransaction()!! + batch.crud[0].opData?.typed shouldBe + mapOf( + "a" to "text", + "b" to 42, + "c" to 13.37, + ) + batch.crud[0].previousValues shouldBe null + + batch.crud[1].opData?.typed shouldBe + mapOf( + "a" to "te\"xt", + "b" to null, + ) + batch.crud[1].previousValues?.typed shouldBe + mapOf( + "a" to "text", + "b" to 42, + ) + + database.execute("DELETE FROM ps_crud") + database.execute( + "UPDATE foo SET a = ?", + listOf("42"), + ) + + batch = database.getNextCrudTransaction()!! + batch.crud[0].opData?.typed shouldBe + mapOf( + "a" to "42", // Not an integer! + ) + } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt index f4f108d7..1bc2cadb 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt @@ -1,6 +1,7 @@ package com.powersync.sync import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.testutils.ActiveDatabaseTest /** * Small utility to run tests both with the legacy Kotlin sync implementation and the new @@ -11,7 +12,9 @@ abstract class AbstractSyncTest( protected val useBson: Boolean = false, ) { @OptIn(ExperimentalPowerSyncAPI::class) - val options: SyncOptions get() { - return SyncOptions(useNewSyncImplementation) - } + internal fun ActiveDatabaseTest.getOptions(): SyncOptions = + SyncOptions( + useNewSyncImplementation, + clientConfiguration = SyncClientConfiguration.ExistingClient(createSyncClient()), + ) } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index 1807bd22..25bc868c 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -60,7 +60,7 @@ abstract class BaseSyncIntegrationTest( databaseTest(createInitialDatabase = false) { // Regression test for https://github.com/powersync-ja/powersync-kotlin/issues/169 val database = openDatabase() - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -72,7 +72,11 @@ abstract class BaseSyncIntegrationTest( @Test fun useParameters() = databaseTest { - database.connect(connector, options = options, params = mapOf("foo" to JsonParam.String("bar"))) + database.connect( + connector, + options = getOptions(), + params = mapOf("foo" to JsonParam.String("bar")), + ) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) turbine.waitFor { it.connected } @@ -93,7 +97,7 @@ abstract class BaseSyncIntegrationTest( @OptIn(DelicateCoroutinesApi::class) fun closesResponseStreamOnDatabaseClose() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -112,7 +116,7 @@ abstract class BaseSyncIntegrationTest( @OptIn(DelicateCoroutinesApi::class) fun cleansResourcesOnDisconnect() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -134,7 +138,7 @@ abstract class BaseSyncIntegrationTest( @Test fun cannotUpdateSchemaWhileConnected() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -152,7 +156,7 @@ abstract class BaseSyncIntegrationTest( @Test fun testPartialSync() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) val checksums = buildList { @@ -243,7 +247,7 @@ abstract class BaseSyncIntegrationTest( @Test fun testRemembersLastPartialSync() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) syncLines.send( SyncLine.FullCheckpoint( @@ -279,7 +283,7 @@ abstract class BaseSyncIntegrationTest( @Test fun setsDownloadingState() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -313,7 +317,7 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbine.waitFor { it.connecting } database.disconnect() @@ -326,7 +330,7 @@ abstract class BaseSyncIntegrationTest( @Test fun testMultipleSyncsDoNotCreateMultipleStatusEntries() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -372,8 +376,8 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { // Connect the first database - database.connect(connector, options = options) - db2.connect(connector, options = options) + database.connect(connector, options = getOptions()) + db2.connect(connector, options = getOptions()) waitFor { assertNotNull( @@ -398,10 +402,10 @@ abstract class BaseSyncIntegrationTest( val turbine2 = db2.currentStatus.asFlow().testIn(this) // Connect the first database - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbine1.waitFor { it.connecting } - db2.connect(connector, options = options) + db2.connect(connector, options = getOptions()) // Should not be connecting yet db2.currentStatus.connecting shouldBe false @@ -425,13 +429,13 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, options = options) + database.connect(connector, 1000L, options = getOptions()) turbine.waitFor { it.connecting } database.disconnect() turbine.waitFor { !it.connecting } - database.connect(connector, 1000L, options = options) + database.connect(connector, 1000L, options = getOptions()) turbine.waitFor { it.connecting } database.disconnect() turbine.waitFor { !it.connecting } @@ -446,10 +450,10 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000, options = options) + database.connect(connector, 1000L, retryDelayMs = 5000, options = getOptions()) turbine.waitFor { it.connecting } - database.connect(connector, 1000L, retryDelayMs = 5000, options = options) + database.connect(connector, 1000L, retryDelayMs = 5000, options = getOptions()) turbine.waitFor { it.connecting } turbine.cancelAndIgnoreRemainingEvents() @@ -462,7 +466,7 @@ abstract class BaseSyncIntegrationTest( databaseTest { val testConnector = TestConnector() connector = testConnector - database.connect(testConnector, options = options) + database.connect(testConnector, options = getOptions()) suspend fun expectUserRows(amount: Int) { val row = database.get("SELECT COUNT(*) FROM users") { it.getLong(0)!! } @@ -500,7 +504,10 @@ abstract class BaseSyncIntegrationTest( } } - database.execute("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf("local", "local@example.org")) + database.execute( + "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", + listOf("local", "local@example.org"), + ) expectUserRows(1) uploadStarted.await() @@ -591,14 +598,18 @@ abstract class BaseSyncIntegrationTest( WriteCheckpointResponse(WriteCheckpointData("1")) } - database.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("local write")) - database.connect(connector, options = options) + database.execute( + "INSERT INTO users (id, name) VALUES (uuid(), ?)", + listOf("local write"), + ) + database.connect(connector, options = getOptions()) turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(scope) turbine.waitFor { it.connected } - val query = database.watch("SELECT name FROM users") { it.getString(0)!! }.testIn(scope) + val query = + database.watch("SELECT name FROM users") { it.getString(0)!! }.testIn(scope) query.awaitItem() shouldBe listOf("local write") syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 1234)) @@ -652,7 +663,7 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000, options = options) + database.connect(connector, 1000L, retryDelayMs = 5000, options = getOptions()) turbine.waitFor { it.connecting } syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 4000)) @@ -692,7 +703,7 @@ abstract class BaseSyncIntegrationTest( turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000, options = options) + database.connect(connector, 1000L, retryDelayMs = 5000, options = getOptions()) turbine.waitFor { it.downloadError != null } database.currentStatus.downloadError?.toString() shouldContain "Expected exception from fetchCredentials" @@ -736,7 +747,7 @@ class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) - database.connect(connector, 1000L, retryDelayMs = 5000, options = options) + database.connect(connector, 1000L, retryDelayMs = 5000, options = getOptions()) turbine.waitFor { it.connecting } syncLines.send(SyncLine.KeepAlive(tokenExpiresIn = 4000)) @@ -771,7 +782,10 @@ class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) { put = PendingStatement( "INSERT OR REPLACE INTO lists (id, name) VALUES (?, ?)", - listOf(PendingStatementParameter.Id, PendingStatementParameter.Column("name")), + listOf( + PendingStatementParameter.Id, + PendingStatementParameter.Column("name"), + ), ), delete = PendingStatement( @@ -792,7 +806,7 @@ class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) { }.testIn(this) query.awaitItem() shouldBe emptyList() - db.connect(connector, options = options) + db.connect(connector, options = getOptions()) syncLines.send( SyncLine.FullCheckpoint( Checkpoint( @@ -877,7 +891,7 @@ class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) { }.testIn(this) query.awaitItem() shouldBe emptyList() - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) // {checkpoint: {last_op_id: 1, write_checkpoint: null, buckets: [{bucket: a, checksum: 0, priority: 3, count: null}]}} syncLines.send( diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt index 87e15e27..c2b3cb9e 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncProgressTest.kt @@ -118,7 +118,7 @@ abstract class BaseSyncProgressTest( @Test fun withoutPriorities() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -167,7 +167,7 @@ abstract class BaseSyncProgressTest( @Test fun interruptedSync() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -197,7 +197,7 @@ abstract class BaseSyncProgressTest( // And reconnecting database = openDatabase() syncLines = Channel() - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -231,7 +231,7 @@ abstract class BaseSyncProgressTest( @Test fun interruptedSyncWithNewCheckpoint() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -257,7 +257,7 @@ abstract class BaseSyncProgressTest( syncLines.close() database = openDatabase() syncLines = Channel() - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -290,7 +290,7 @@ abstract class BaseSyncProgressTest( @Test fun interruptedWithDefrag() = databaseTest { - database.connect(connector) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -316,7 +316,7 @@ abstract class BaseSyncProgressTest( syncLines.close() database = openDatabase() syncLines = Channel() - database.connect(connector) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -345,7 +345,7 @@ abstract class BaseSyncProgressTest( @Test fun differentPriorities() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) turbineScope { val turbine = database.currentStatus.asFlow().testIn(this) @@ -355,7 +355,10 @@ abstract class BaseSyncProgressTest( prio0: Pair, prio2: Pair, ) { - turbine.expectProgress(prio2, mapOf(BucketPriority(0) to prio0, BucketPriority(2) to prio2)) + turbine.expectProgress( + prio2, + mapOf(BucketPriority(0) to prio0, BucketPriority(2) to prio2), + ) } syncLines.send( diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index fd2807ea..0b533cfd 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -8,6 +8,7 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncTestLogWriter import com.powersync.TestConnector import com.powersync.bucket.WriteCheckpointData @@ -16,9 +17,9 @@ import com.powersync.createPowerSyncDatabaseImpl import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation +import com.powersync.sync.configureSyncHttpClient import com.powersync.utils.JsonUtil import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import io.ktor.client.engine.mock.toByteArray import io.ktor.http.ContentType import kotlinx.coroutines.channels.Channel @@ -111,7 +112,6 @@ internal class ActiveDatabaseTest( dbDirectory = testDirectory, logger = logger, scope = scope, - createClient = ::createClient, ) doOnCleanup { db.close() } return db @@ -119,20 +119,22 @@ internal class ActiveDatabaseTest( suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl = openDatabase().also { it.readLock { } } - private fun createClient(config: HttpClientConfig<*>.() -> Unit): HttpClient { + @OptIn(ExperimentalPowerSyncAPI::class) + fun createSyncClient(): HttpClient { val engine = MockSyncService( lines = syncLines, generateCheckpoint = { checkpointResponse() }, syncLinesContentType = { syncLinesContentType }, trackSyncRequest = { - val parsed = JsonUtil.json.parseToJsonElement(it.body.toByteArray().decodeToString()) + val parsed = + JsonUtil.json.parseToJsonElement(it.body.toByteArray().decodeToString()) requestedSyncStreams.add(parsed) }, ) return HttpClient(engine) { - config() + configureSyncHttpClient() } } diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt index 9ba2ca60..bd6fc453 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabaseFactory.kt @@ -4,10 +4,7 @@ import co.touchlab.kermit.Logger import co.touchlab.skie.configuration.annotations.DefaultArgumentInterop import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema -import com.powersync.sync.SyncStream import com.powersync.utils.generateLogger -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -50,7 +47,6 @@ internal fun createPowerSyncDatabaseImpl( scope: CoroutineScope, logger: Logger, dbDirectory: String?, - createClient: (HttpClientConfig<*>.() -> Unit) -> HttpClient = SyncStream::defaultHttpClient, ): PowerSyncDatabaseImpl = PowerSyncDatabaseImpl( schema = schema, @@ -59,5 +55,4 @@ internal fun createPowerSyncDatabaseImpl( scope = scope, logger = logger, dbDirectory = dbDirectory, - createClient = createClient, ) diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 704cada8..7e44ada0 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -26,8 +26,6 @@ import com.powersync.utils.JsonParam import com.powersync.utils.JsonUtil import com.powersync.utils.throttle import com.powersync.utils.toJsonObject -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -64,7 +62,6 @@ internal class PowerSyncDatabaseImpl( private val dbFilename: String, private val dbDirectory: String? = null, val logger: Logger = Logger, - private val createClient: (HttpClientConfig<*>.() -> Unit) -> HttpClient, ) : PowerSyncDatabase { companion object { internal val streamConflictMessage = @@ -167,7 +164,6 @@ internal class PowerSyncDatabaseImpl( logger = logger, params = params.toJsonObject(), uploadScope = scope, - createClient = createClient, options = options, schema = schema, ) diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt index 1b3366a4..293b5ea3 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt @@ -3,14 +3,14 @@ package com.powersync.db.crud import com.powersync.PowerSyncDatabase import com.powersync.db.schema.Table import com.powersync.utils.JsonUtil -import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** * A single client-side change. */ -public data class CrudEntry( +@ConsistentCopyVisibility +public data class CrudEntry internal constructor( /** * ID of the changed row. */ @@ -57,14 +57,14 @@ public data class CrudEntry( * * For DELETE, this is null. */ - val opData: Map?, + val opData: SqliteRow?, /** * Previous values before this change. * * These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is * enabled. */ - val previousValues: Map? = null, + val previousValues: SqliteRow? = null, ) { public companion object { public fun fromRow(row: CrudRow): CrudEntry { @@ -73,17 +73,11 @@ public data class CrudEntry( id = data["id"]!!.jsonPrimitive.content, clientId = row.id.toInt(), op = UpdateType.fromJsonChecked(data["op"]!!.jsonPrimitive.content), - opData = - data["data"]?.jsonObject?.mapValues { (_, value) -> - value.jsonPrimitive.contentOrNull - }, + opData = data["data"]?.let { SerializedRow(it.jsonObject) }, table = data["type"]!!.jsonPrimitive.content, transactionId = row.txId, metadata = data["metadata"]?.jsonPrimitive?.content, - previousValues = - data["old"]?.jsonObject?.mapValues { (_, value) -> - value.jsonPrimitive.contentOrNull - }, + previousValues = data["old"]?.let { SerializedRow(it.jsonObject) }, ) } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt new file mode 100644 index 00000000..38499c8e --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt @@ -0,0 +1,93 @@ +package com.powersync.db.crud + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlin.experimental.ExperimentalObjCRefinement +import kotlin.native.HiddenFromObjC + +/** + * A named collection of values as they appear in a SQLite row. + * + * We represent values as a `Map` to ensure compatible with earlier versions of the + * SDK, but the [typed] getter can be used to obtain a `Map` where values are either + * [String]s, [Int]s or [Double]s. + */ +@OptIn(ExperimentalObjCRefinement::class) +public interface SqliteRow : Map { + /** + * A typed view of the SQLite row. + */ + public val typed: Map + + /** + * A [JsonObject] of all values in this row that can be represented as JSON. + */ + @HiddenFromObjC + public val jsonValues: JsonObject +} + +/** + * A [SqliteRow] implemented over a [JsonObject] view. + */ +internal class SerializedRow( + override val jsonValues: JsonObject, +) : AbstractMap(), + SqliteRow { + override val entries: Set> = + jsonValues.entries.mapTo( + mutableSetOf(), + ::ToStringEntry, + ) + + override val typed: Map = TypedRow(jsonValues) +} + +private data class ToStringEntry( + val inner: Map.Entry, +) : Map.Entry { + override val key: String + get() = inner.key + override val value: String + get() = inner.value.jsonPrimitive.content +} + +private class TypedRow( + inner: JsonObject, +) : AbstractMap() { + override val entries: Set> = + inner.entries.mapTo( + mutableSetOf(), + ::ToTypedEntry, + ) +} + +private data class ToTypedEntry( + val inner: Map.Entry, +) : Map.Entry { + override val key: String + get() = inner.key + override val value: Any? + get() = inner.value.jsonPrimitive.asData() + + companion object { + private fun JsonPrimitive.asData(): Any? = + if (this === JsonNull) { + null + } else if (isString) { + content + } else { + content.jsonNumberOrBoolean() + } + + private fun String.jsonNumberOrBoolean(): Any = + when { + this == "true" -> true + this == "false" -> false + this.any { char -> char == '.' || char == 'e' || char == 'E' } -> this.toDouble() + else -> this.toInt() + } + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 035482a6..c8c89f5b 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -2,6 +2,38 @@ package com.powersync.sync import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import kotlin.experimental.ExperimentalObjCRefinement +import kotlin.native.HiddenFromObjC + +/** + * Configuration options for the [PowerSyncDatabase.connect] method, allowing customization of + * the HTTP client used to connect to the PowerSync service. + */ +@OptIn(ExperimentalObjCRefinement::class) +public sealed class SyncClientConfiguration { + /** + * Extends the default Ktor [HttpClient] configuration with the provided block. + */ + @HiddenFromObjC + public class ExtendedConfig( + public val block: HttpClientConfig<*>.() -> Unit, + ) : SyncClientConfiguration() + + /** + * Provides an existing [HttpClient] instance to use for connecting to the PowerSync service. + * This client should be configured with the necessary plugins and settings to function correctly. + * The HTTP client requirements are delicate and subject to change throughout the SDK's development. + * The [configureSyncHttpClient] function can be used to configure the client for PowerSync, call + * this method when instantiating the client. The PowerSync SDK does not modify the provided client. + */ + @HiddenFromObjC + @ExperimentalPowerSyncAPI + public class ExistingClient( + public val client: HttpClient, + ) : SyncClientConfiguration() +} /** * Experimental options that can be passed to [PowerSyncDatabase.connect] to specify an experimental @@ -20,6 +52,11 @@ public class SyncOptions * The user agent to use for requests made to the PowerSync service. */ public val userAgent: String = userAgent(), + @property:ExperimentalPowerSyncAPI + /** + * Allows configuring the [HttpClient] used for connecting to the PowerSync service. + */ + public val clientConfiguration: SyncClientConfiguration? = null, ) { public companion object { /** diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index c1548d3f..3bacd18a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -15,6 +15,7 @@ import com.powersync.connectors.PowerSyncBackendConnector import com.powersync.db.crud.CrudEntry import com.powersync.db.schema.Schema import com.powersync.db.schema.toSerializable +import com.powersync.sync.SyncStream.Companion.SOCKET_TIMEOUT import com.powersync.utils.JsonUtil import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -61,8 +62,45 @@ import kotlinx.io.readIntLe import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement +import kotlin.experimental.ExperimentalObjCRefinement +import kotlin.native.HiddenFromObjC import kotlin.time.Clock +/** + * This API is experimental and may change in future releases. + * + * Configures a [HttpClient] for PowerSync sync operations. + * Configures required plugins and default request headers. + * + * This is currently only necessary when using a [SyncClientConfiguration.ExistingClient] for PowerSync + * network requests. + * + * Example usage: + * + * ```kotlin + * val client = HttpClient() { + * configureSyncHttpClient() + * // Your own config here + * } + * ``` + */ +@OptIn(ExperimentalObjCRefinement::class) +@HiddenFromObjC +@ExperimentalPowerSyncAPI +public fun HttpClientConfig<*>.configureSyncHttpClient(userAgent: String = userAgent()) { + install(HttpTimeout) { + socketTimeoutMillis = SOCKET_TIMEOUT + } + install(ContentNegotiation) + install(WebSockets) + + install(DefaultRequest) { + headers { + append("User-Agent", userAgent) + } + } +} + @OptIn(ExperimentalPowerSyncAPI::class) internal class SyncStream( private val bucketStorage: BucketStorage, @@ -74,7 +112,6 @@ internal class SyncStream( private val uploadScope: CoroutineScope, private val options: SyncOptions, private val schema: Schema, - createClient: (HttpClientConfig<*>.() -> Unit) -> HttpClient, ) { private var isUploadingCrud = AtomicReference(null) private var completedCrudUploads = Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -87,21 +124,23 @@ internal class SyncStream( private var clientId: String? = null private val httpClient: HttpClient = - createClient { - install(HttpTimeout) { - socketTimeoutMillis = SOCKET_TIMEOUT - } + when (val config = options.clientConfiguration) { + is SyncClientConfiguration.ExtendedConfig -> + createClient(options.userAgent, config.block) - install(ContentNegotiation) - install(WebSockets) + is SyncClientConfiguration.ExistingClient -> config.client - install(DefaultRequest) { - headers { - append("User-Agent", options.userAgent) - } - } + null -> createClient(options.userAgent) } + private fun createClient( + userAgent: String, + additionalConfig: HttpClientConfig<*>.() -> Unit = {}, + ) = HttpClient { + configureSyncHttpClient(userAgent) + additionalConfig() + } + fun invalidateCredentials() { connector.invalidateCredentials() } @@ -386,15 +425,18 @@ internal class SyncStream( } } } + Instruction.CloseSyncStream -> { logger.v { "Closing sync stream connection" } fetchLinesJob!!.cancelAndJoin() fetchLinesJob = null logger.v { "Sync stream connection shut down" } } + Instruction.FlushSileSystem -> { // We have durable file systems, so flushing is not necessary } + is Instruction.LogLine -> { logger.log( severity = @@ -408,11 +450,13 @@ internal class SyncStream( throwable = null, ) } + is Instruction.UpdateSyncStatus -> { status.update { applyCoreChanges(instruction.status) } } + is Instruction.FetchCredentials -> { if (instruction.didExpire) { connector.invalidateCredentials() @@ -434,9 +478,11 @@ internal class SyncStream( } } } + Instruction.DidCompleteSync -> { status.update { copy(downloadError = null) } } + is Instruction.UnknownInstruction -> { logger.w { "Unknown instruction received from core extension: ${instruction.raw}" } } @@ -476,7 +522,13 @@ internal class SyncStream( val req = StreamingSyncRequest( - buckets = initialBuckets.map { (bucket, after) -> BucketRequest(bucket, after) }, + buckets = + initialBuckets.map { (bucket, after) -> + BucketRequest( + bucket, + after, + ) + }, clientId = clientId!!, parameters = params, ) @@ -677,7 +729,12 @@ internal class SyncStream( ): SyncStreamState { val batch = SyncDataBatch(listOf(data)) bucketStorage.saveSyncData(batch) - status.update { copy(downloading = true, downloadProgress = downloadProgress?.incrementDownloaded(batch)) } + status.update { + copy( + downloading = true, + downloadProgress = downloadProgress?.incrementDownloaded(batch), + ) + } return state } @@ -703,7 +760,7 @@ internal class SyncStream( internal companion object { // The sync service sends a token keepalive message roughly every 20 seconds. So if we don't receive a message // in twice that time, assume the connection is broken. - private const val SOCKET_TIMEOUT: Long = 40_000 + internal const val SOCKET_TIMEOUT: Long = 40_000 private val ndjson = ContentType("application", "x-ndjson") private val bsonStream = ContentType("application", "vnd.powersync.bson-stream") @@ -755,7 +812,10 @@ internal class SyncStream( if (bytesRead == -1) { // No bytes available, wait for more if (isClosedForRead || !awaitContent(1)) { - throw PowerSyncException("Unexpected end of response in middle of BSON sync line", null) + throw PowerSyncException( + "Unexpected end of response in middle of BSON sync line", + null, + ) } } else { remaining -= bytesRead diff --git a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt index b169f88f..512a90e1 100644 --- a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt @@ -75,10 +75,7 @@ class BucketStorageTest { op = UpdateType.PUT, table = "table1", transactionId = 1, - opData = - mapOf( - "key" to "value", - ), + opData = null, ) mockDb = mock { diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index 5c95935b..3872da0a 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -70,12 +70,19 @@ class SyncStreamTest { SyncStream( bucketStorage = bucketStorage, connector = connector, - createClient = { config -> HttpClient(assertNoHttpEngine, config) }, uploadCrud = {}, logger = logger, params = JsonObject(emptyMap()), uploadScope = this, - options = SyncOptions(), + options = + SyncOptions( + clientConfiguration = + SyncClientConfiguration.ExistingClient( + HttpClient(assertNoHttpEngine) { + configureSyncHttpClient() + }, + ), + ), schema = Schema(), ) @@ -95,10 +102,7 @@ class SyncStreamTest { op = UpdateType.PUT, table = "table1", transactionId = 1, - opData = - mapOf( - "key" to "value", - ), + opData = null, ) bucketStorage = mock { @@ -109,13 +113,20 @@ class SyncStreamTest { SyncStream( bucketStorage = bucketStorage, connector = connector, - createClient = { config -> HttpClient(assertNoHttpEngine, config) }, uploadCrud = { }, retryDelayMs = 10, logger = logger, params = JsonObject(emptyMap()), uploadScope = this, - options = SyncOptions(), + options = + SyncOptions( + clientConfiguration = + SyncClientConfiguration.ExistingClient( + HttpClient(assertNoHttpEngine) { + configureSyncHttpClient() + }, + ), + ), schema = Schema(), ) @@ -133,7 +144,10 @@ class SyncStreamTest { } with(testLogWriter.logs[1]) { - assertEquals(message, "Error uploading crud: Delaying due to previously encountered CRUD item.") + assertEquals( + message, + "Error uploading crud: Delaying due to previously encountered CRUD item.", + ) assertEquals(Severity.Error, severity) } } @@ -150,13 +164,20 @@ class SyncStreamTest { SyncStream( bucketStorage = bucketStorage, connector = connector, - createClient = { config -> HttpClient(assertNoHttpEngine, config) }, uploadCrud = { }, retryDelayMs = 10, logger = logger, params = JsonObject(emptyMap()), uploadScope = this, - options = SyncOptions(), + options = + SyncOptions( + clientConfiguration = + SyncClientConfiguration.ExistingClient( + HttpClient(assertNoHttpEngine) { + configureSyncHttpClient() + }, + ), + ), schema = Schema(), ) diff --git a/core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt b/core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt new file mode 100644 index 00000000..2f2c759c --- /dev/null +++ b/core/src/tvosMain/kotlin/com/powersync/DatabaseDriverFactory.tvos.kt @@ -0,0 +1,7 @@ +package com.powersync + +import co.touchlab.sqliter.DatabaseConnection + +internal actual fun DatabaseConnection.loadPowerSyncSqliteCoreExtension() { + loadPowerSyncSqliteCoreExtensionDynamically() +} diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj index 57e2c8e9..0b5842d5 100644 --- a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj @@ -118,7 +118,7 @@ 7555FF79242A565900829871 /* Resources */, F85CB1118929364A9C6EFABC /* Frameworks */, 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */, - 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */, + AA799A6E8997A58F1EF8CBFF /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -192,23 +192,6 @@ runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; }; - 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -248,7 +231,7 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - F72245E8E98E97BEF8C32493 /* [CP] Copy Pods Resources */ = { + AA799A6E8997A58F1EF8CBFF /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( diff --git a/demos/supabase-todolist/shared/build.gradle.kts b/demos/supabase-todolist/shared/build.gradle.kts index a3833520..43b4a7a7 100644 --- a/demos/supabase-todolist/shared/build.gradle.kts +++ b/demos/supabase-todolist/shared/build.gradle.kts @@ -54,6 +54,7 @@ kotlin { implementation(libs.supabase.client) api(libs.koin.core) implementation(libs.koin.compose.viewmodel) + implementation(libs.ktor.client.logging) } androidMain.dependencies { api(libs.androidx.activity.compose) diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt index 4eaa4e06..465cec3b 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt @@ -6,9 +6,12 @@ import co.touchlab.kermit.Logger import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase import com.powersync.connector.supabase.SupabaseConnector +import com.powersync.sync.SyncClientConfiguration import com.powersync.sync.SyncOptions import io.github.jan.supabase.auth.status.RefreshFailureCause import io.github.jan.supabase.auth.status.SessionStatus +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -20,7 +23,7 @@ data class AuthOptions( * androidBackgroundSync app, this is false because we're connecting from a * foreground service. */ - val connectFromViewModel: Boolean + val connectFromViewModel: Boolean, ) sealed class AuthState { @@ -47,13 +50,32 @@ internal class AuthViewModel( supabase.sessionStatus.collect { when (it) { is SessionStatus.Authenticated -> { - db.connect(supabase, options = SyncOptions( - newClientImplementation = true, - )) + db.connect( + supabase, + options = + SyncOptions( + newClientImplementation = true, + clientConfiguration = + SyncClientConfiguration.ExtendedConfig { + install(Logging) { + level = LogLevel.ALL + logger = + object : + io.ktor.client.plugins.logging.Logger { + override fun log(message: String) { + Logger.d { message } + } + } + } + }, + ), + ) } + is SessionStatus.NotAuthenticated -> { db.disconnectAndClear() } + else -> { // Ignore } @@ -78,6 +100,7 @@ internal class AuthViewModel( is RefreshFailureCause.InternalServerError -> Logger.e("Internal server error occurred") } } + is SessionStatus.NotAuthenticated -> { _authState.value = AuthState.SignedOut navController.navigate(Screen.SignIn) diff --git a/gradle.properties b/gradle.properties index a2b78388..6380f8e4 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.3.1 +LIBRARY_VERSION=1.4.0 GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git # POM POM_URL=https://github.com/powersync-ja/powersync-kotlin/ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3dfdc7e3..c91b3aae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ kotlinx-datetime = "0.7.1" kotlinx-io = "0.8.0" ktor = "3.2.3" uuid = "0.8.4" -powersync-core = "0.4.2" +powersync-core = "0.4.4" sqlite-jdbc = "3.50.3.0" sqliter = "1.3.3" turbine = "1.2.1" @@ -87,6 +87,7 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } diff --git a/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt b/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt index 7cf32739..7905d11f 100644 --- a/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt +++ b/plugins/build-plugin/src/main/kotlin/SharedBuildPlugin.kt @@ -62,6 +62,7 @@ class SharedBuildPlugin : Plugin { // We're testing on simulators Family.IOS -> "ios-arm64_x86_64-simulator" Family.WATCHOS -> "watchos-arm64_x86_64-simulator" + Family.TVOS -> "tvos-arm64_x86_64-simulator" else -> return@configureEach } diff --git a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt index 749def36..e51df1bc 100644 --- a/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/compile/ClangCompile.kt @@ -68,6 +68,9 @@ abstract class ClangCompile: DefaultTask() { KonanTarget.WATCHOS_ARM64 -> "arm64_32-apple-watchos" to WATCHOS_SDK KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "aarch64-apple-watchos-simulator" to WATCHOS_SIMULATOR_SDK KonanTarget.WATCHOS_X64 -> "x86_64-apple-watchos-simulator" to WATCHOS_SIMULATOR_SDK + KonanTarget.TVOS_ARM64 -> "aarch64-apple-tvos" to TVOS_SDK + KonanTarget.TVOS_X64 -> "x86_64-apple-tvos-simulator" to TVOS_SIMULATOR_SDK + KonanTarget.TVOS_SIMULATOR_ARM64 -> "aarch64-apple-tvos-simulator" to TVOS_SIMULATOR_SDK else -> error("Unexpected target $target") } @@ -104,6 +107,8 @@ abstract class ClangCompile: DefaultTask() { const val WATCHOS_SIMULATOR_SDK = "Platforms/WatchSimulator.platform/Developer/SDKs/WatchSimulator.sdk/" const val IOS_SDK = "Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" const val IOS_SIMULATOR_SDK = "Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" + const val TVOS_SDK = "Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS.sdk" + const val TVOS_SIMULATOR_SDK = "Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk" const val MACOS_SDK = "Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/" } } diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt index 416b79f5..2e7f145e 100644 --- a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt +++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt @@ -45,6 +45,10 @@ public fun KotlinTargetContainerWithPresetFunctions.powersyncTargets( macosX64() macosArm64() + tvosSimulatorArm64() + tvosX64() + tvosArm64() + if (watchOS) { watchosDeviceArm64() // aarch64-apple-watchos watchosArm64() // arm64_32-apple-watchos