diff --git a/CHANGELOG.md b/CHANGELOG.md index 3963c40a..a9039618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.4.0 (unreleased) + +* Added the ability to log PowerSync service HTTP request information via specifying a + `SyncClientConfiguration` in the `SyncOptions.clientConfiguration` parameter used in + `PowerSyncDatabase.connect()` calls. + ## 1.3.1 * Update SQLite to 3.50.3. @@ -12,7 +18,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 +30,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 +40,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 +70,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/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/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/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/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index 5c95935b..6cc414b3 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(), ) @@ -109,13 +116,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 +147,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 +167,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/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/libs.versions.toml b/gradle/libs.versions.toml index 3dfdc7e3..3104e356 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }