diff --git a/PowerSyncKotlin/build.gradle.kts b/PowerSyncKotlin/build.gradle.kts index 850c46ba..ba73ae21 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) } } } @@ -78,8 +79,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..b851b75f 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 SwiftNetworkLogLevel { + ALL, + HEADERS, + BODY, + INFO, + NONE, +} + +/** + * Mapper function to Ktor LogLevel + */ +internal fun SwiftNetworkLogLevel.toKtorLogLevel(): LogLevel = + when (this) { + SwiftNetworkLogLevel.ALL -> LogLevel.ALL + SwiftNetworkLogLevel.HEADERS -> LogLevel.HEADERS + SwiftNetworkLogLevel.BODY -> LogLevel.BODY + SwiftNetworkLogLevel.INFO -> LogLevel.INFO + SwiftNetworkLogLevel.NONE -> LogLevel.NONE + } + +/** + * Configuration which is used to configure the Ktor logging plugin + */ +public data class SwiftNetworkLoggerConfig( + public val logLevel: SwiftNetworkLogLevel, + public val log: (message: String) -> Unit, +) + +/** + * Creates a Ktor [SyncClientConfiguration.ExtendedConfig] that extends the default Ktor client. + * Specifying a [SwiftNetworkLoggerConfig] will install the Ktor logging plugin with the specified configuration. + */ +public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftNetworkLoggerConfig? = 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: SwiftNetworkLoggerConfig? = null, ): SyncOptions = SyncOptions( newClientImplementation = newClient, userAgent = userAgent, + clientConfiguration = createExtendedSyncClientConfiguration(loggingConfig), ) 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 51880ee1..d127da8a 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..bc4a1066 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. + */ +public sealed class SyncClientConfiguration { + /** + * Extends the default Ktor [HttpClient] configuration with the provided block. + */ + @OptIn(ExperimentalObjCRefinement::class) + @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. + */ + @OptIn(ExperimentalObjCRefinement::class) + @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 581b147f..3425c3e4 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 @@ -62,6 +63,37 @@ 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 + +/** + * Configures a [HttpClient] for PowerSync sync operations. + * Sets up required plugins and default request headers. + * This API is experimental and may change in future releases. + * + * Example usage: + * + * 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( @@ -74,7 +106,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 +118,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 +419,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 +444,13 @@ internal class SyncStream( throwable = null, ) } + is Instruction.UpdateSyncStatus -> { status.update { applyCoreChanges(instruction.status) } } + is Instruction.FetchCredentials -> { if (instruction.didExpire) { connector.invalidateCredentials() @@ -434,9 +472,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 +516,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 +723,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 +754,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 +806,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/hello-powersync/composeApp/build.gradle.kts b/demos/hello-powersync/composeApp/build.gradle.kts index b358eae5..6c546d8e 100644 --- a/demos/hello-powersync/composeApp/build.gradle.kts +++ b/demos/hello-powersync/composeApp/build.gradle.kts @@ -52,6 +52,7 @@ kotlin { implementation(compose.ui) @OptIn(ExperimentalComposeLibrary::class) implementation(compose.components.resources) + implementation(projectLibs.ktor.client.logging) } androidMain.dependencies { diff --git a/demos/hello-powersync/composeApp/composeApp.podspec b/demos/hello-powersync/composeApp/composeApp.podspec index 716d7345..43d55ace 100644 --- a/demos/hello-powersync/composeApp/composeApp.podspec +++ b/demos/hello-powersync/composeApp/composeApp.podspec @@ -9,7 +9,7 @@ Pod::Spec.new do |spec| spec.vendored_frameworks = 'build/cocoapods/framework/composeApp.framework' spec.libraries = 'c++' spec.ios.deployment_target = '15.2' - spec.dependency 'powersync-sqlite-core', '0.3.12' + spec.dependency 'powersync-sqlite-core', '0.4.0' if !Dir.exist?('build/cocoapods/framework/composeApp.framework') || Dir.empty?('build/cocoapods/framework/composeApp.framework') raise " diff --git a/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt index 383a3a6b..1791a038 100644 --- a/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt +++ b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt @@ -1,9 +1,16 @@ package com.powersync.demos import com.powersync.DatabaseDriverFactory +import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase import com.powersync.connector.supabase.SupabaseConnector import com.powersync.db.getString +import com.powersync.sync.SyncClientConfiguration +import com.powersync.sync.SyncOptions +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.SIMPLE import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking @@ -62,15 +69,28 @@ class PowerSync( id ?: database.getOptional("SELECT id FROM customers LIMIT 1", mapper = { cursor -> cursor.getString(0)!! }) - ?: return + ?: return database.writeTransaction { tx -> tx.execute("DELETE FROM customers WHERE id = ?", listOf(targetId)) } } + @OptIn(ExperimentalPowerSyncAPI::class) suspend fun connect() { - database.connect(connector) + database.connect( + connector, + options = + SyncOptions( + clientConfiguration = + SyncClientConfiguration.ExtendedConfig { + install(Logging) { + level = LogLevel.ALL + logger = Logger.SIMPLE + } + }, + ), + ) } suspend fun disconnect() { diff --git a/demos/hello-powersync/iosApp/Podfile.lock b/demos/hello-powersync/iosApp/Podfile.lock index 0f707dfb..19b0bc62 100644 --- a/demos/hello-powersync/iosApp/Podfile.lock +++ b/demos/hello-powersync/iosApp/Podfile.lock @@ -1,7 +1,7 @@ PODS: - composeApp (1.0.0): - - powersync-sqlite-core (= 0.3.12) - - powersync-sqlite-core (0.3.12) + - powersync-sqlite-core (= 0.4.0) + - powersync-sqlite-core (0.4.0) DEPENDENCIES: - composeApp (from `../composeApp`) @@ -15,8 +15,8 @@ EXTERNAL SOURCES: :path: "../composeApp" SPEC CHECKSUMS: - composeApp: 904d95008148b122d963aa082a29624b99d0f4e1 - powersync-sqlite-core: fcc32da5528fca9d50b185fcd777705c034e255b + composeApp: f3426c7c85040911848919eebf5573c0f1306733 + powersync-sqlite-core: 3bfe9a3c210e130583496871b404f18d4cfbe366 PODFILE CHECKSUM: 4680f51fbb293d1385fb2467ada435cc1f16ab3d diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj index 57e2c8e9..58c8b8f8 100644 --- a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj @@ -118,7 +118,6 @@ 7555FF79242A565900829871 /* Resources */, F85CB1118929364A9C6EFABC /* Frameworks */, 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */, - 1015E800EC39A6B62654C306 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -192,23 +191,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,23 +230,6 @@ 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 */ = { - 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; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/gradle.properties b/gradle.properties index afd254c7..638bb54e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official # Gradle -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4096M" org.gradle.caching=true org.gradle.configuration-cache=true # Compose diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7282066..b6df25f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,6 +80,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" }