From fdf8dc2edc9b6b20900ce106f5a5907bce34d5a0 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 23 Jul 2025 09:31:35 +0200 Subject: [PATCH 01/11] wip: add logging --- .../kotlin/com/powersync/sync/SyncOptions.kt | 64 +++++++++++++------ .../kotlin/com/powersync/sync/SyncStream.kt | 60 +++++++++++++---- .../composeApp/build.gradle.kts | 1 + .../composeApp/composeApp.podspec | 2 +- .../kotlin/com/powersync/demos/PowerSync.kt | 21 +++++- demos/hello-powersync/iosApp/Podfile.lock | 8 +-- .../iosApp/iosApp.xcodeproj/project.pbxproj | 35 ---------- gradle/libs.versions.toml | 1 + 8 files changed, 120 insertions(+), 72 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index cf14d673..f897be9a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -2,10 +2,31 @@ package com.powersync.sync import com.powersync.ExperimentalPowerSyncAPI import com.powersync.PowerSyncDatabase +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig import io.rsocket.kotlin.keepalive.KeepAlive import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * 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. + */ + 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. + */ + public class ExistingClient(public val client: HttpClient) : + SyncClientConfiguration() +} + /** * Experimental options that can be passed to [PowerSyncDatabase.connect] to specify an experimental * connection mechanism. @@ -15,28 +36,33 @@ import kotlin.time.Duration.Companion.seconds * for the rest of the SDK though. */ public class SyncOptions - @ExperimentalPowerSyncAPI - constructor( - @property:ExperimentalPowerSyncAPI - public val newClientImplementation: Boolean = false, - @property:ExperimentalPowerSyncAPI - public val method: ConnectionMethod = ConnectionMethod.Http, +@ExperimentalPowerSyncAPI +constructor( + @property:ExperimentalPowerSyncAPI + public val newClientImplementation: Boolean = false, + @property:ExperimentalPowerSyncAPI + public val method: ConnectionMethod = ConnectionMethod.Http, + /** + * 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 { /** - * The user agent to use for requests made to the PowerSync service. + * The default sync options, which are safe and stable to use. + * + * Constructing non-standard sync options requires an opt-in to experimental PowerSync + * APIs, and those might change in the future. */ - public val userAgent: String = userAgent(), - ) { - public companion object { - /** - * The default sync options, which are safe and stable to use. - * - * Constructing non-standard sync options requires an opt-in to experimental PowerSync - * APIs, and those might change in the future. - */ - @OptIn(ExperimentalPowerSyncAPI::class) - public val defaults: SyncOptions = SyncOptions() - } + @OptIn(ExperimentalPowerSyncAPI::class) + public val defaults: SyncOptions = SyncOptions() } +} /** * The connection method to use when the SDK connects to the sync service. diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 826cdd24..f98cc51d 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -78,18 +78,37 @@ internal class SyncStream( private var clientId: String? = null - private val httpClient: HttpClient = - createClient { - install(HttpTimeout) - install(ContentNegotiation) - install(WebSockets) + private val httpClient: HttpClient = when (val config = options.clientConfiguration) { + is SyncClientConfiguration.ExtendedConfig -> { + createClient { + configureHttpClient() + // Apply additional configuration + config.block(this) + } + } - install(DefaultRequest) { - headers { - append("User-Agent", options.userAgent) - } + is SyncClientConfiguration.ExistingClient -> config.client + + null -> { + // Default client configuration + createClient { + configureHttpClient() + } + } + } + + private fun HttpClientConfig<*>.configureHttpClient() { + install(HttpTimeout) + install(ContentNegotiation) + install(WebSockets) + + install(DefaultRequest) { + headers { + append("User-Agent", options.userAgent) } } + } + fun invalidateCredentials() { connector.invalidateCredentials() @@ -366,15 +385,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 = @@ -388,11 +410,13 @@ internal class SyncStream( throwable = null, ) } + is Instruction.UpdateSyncStatus -> { status.update { applyCoreChanges(instruction.status) } } + is Instruction.FetchCredentials -> { if (instruction.didExpire) { connector.invalidateCredentials() @@ -414,9 +438,11 @@ internal class SyncStream( } } } + Instruction.DidCompleteSync -> { status.update { copy(downloadError = null) } } + is Instruction.UnknownInstruction -> { logger.w { "Unknown instruction received from core extension: ${instruction.raw}" } } @@ -429,6 +455,7 @@ internal class SyncStream( connectViaHttp(start.request).collect { controlInvocations.send(PowerSyncControlArguments.TextLine(it)) } + is ConnectionMethod.WebSocket -> connectViaWebSocket(start.request, method).collect { controlInvocations.send(PowerSyncControlArguments.BinaryLine(it)) @@ -463,7 +490,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, ) @@ -664,7 +697,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 } 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..1894a2ea 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,14 @@ 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.Logging +import io.ktor.client.plugins.logging.LogLevel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking @@ -62,15 +67,27 @@ 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) + println("connecting to PowerSync...") + database.connect( + connector, + options = + SyncOptions( + clientConfiguration = SyncClientConfiguration.ExtendedConfig { + install(Logging) { + level = LogLevel.ALL + } + } + ), + ) } 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/libs.versions.toml b/gradle/libs.versions.toml index f0551878..9cc97b76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,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" } From 3cf7686b8f55288f1d217e6d9ba52e78a4314112 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 23 Jul 2025 14:24:11 +0200 Subject: [PATCH 02/11] Remove `createClient` param from `SyncStream` and `PowerSyncDatabaseImpl` --- .../com/powersync/sync/AbstractSyncTest.kt | 9 ++- .../com/powersync/sync/SyncIntegrationTest.kt | 70 +++++++++++-------- .../com/powersync/sync/SyncProgressTest.kt | 21 +++--- .../com/powersync/testutils/TestUtils.kt | 10 +-- .../com/powersync/PowerSyncDatabaseFactory.kt | 5 -- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 4 -- .../kotlin/com/powersync/sync/SyncOptions.kt | 58 +++++++-------- .../kotlin/com/powersync/sync/SyncStream.kt | 61 +++++++--------- .../com/powersync/sync/SyncStreamTest.kt | 38 ++++++++-- .../kotlin/com/powersync/demos/PowerSync.kt | 16 ++--- 10 files changed, 161 insertions(+), 131 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/AbstractSyncTest.kt index 6e54618a..1860909c 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 @@ -10,7 +11,9 @@ abstract class AbstractSyncTest( private val useNewSyncImplementation: Boolean, ) { @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 84743ac0..bc53b14b 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -59,7 +59,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) @@ -71,7 +71,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 } @@ -92,7 +96,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) @@ -111,7 +115,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) @@ -133,7 +137,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) @@ -151,7 +155,7 @@ abstract class BaseSyncIntegrationTest( @Test fun testPartialSync() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) val checksums = buildList { @@ -242,7 +246,7 @@ abstract class BaseSyncIntegrationTest( @Test fun testRemembersLastPartialSync() = databaseTest { - database.connect(connector, options = options) + database.connect(connector, options = getOptions()) syncLines.send( SyncLine.FullCheckpoint( @@ -278,7 +282,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) @@ -312,7 +316,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() @@ -325,7 +329,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) @@ -371,8 +375,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( @@ -397,10 +401,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 @@ -424,13 +428,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 } @@ -445,10 +449,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() @@ -461,7 +465,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)!! } @@ -499,7 +503,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() @@ -590,14 +597,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)) @@ -651,7 +662,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)) @@ -691,7 +702,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" @@ -735,7 +746,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)) @@ -770,7 +781,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( @@ -791,7 +805,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( 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 6faea462..121a550c 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -17,9 +17,9 @@ import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.LegacySyncImplementation import com.powersync.sync.SyncLine +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 kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.TestScope @@ -111,7 +111,6 @@ internal class ActiveDatabaseTest( dbDirectory = testDirectory, logger = logger, scope = scope, - createClient = ::createClient, ) doOnCleanup { db.close() } return db @@ -119,19 +118,20 @@ internal class ActiveDatabaseTest( suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl = openDatabase().also { it.readLock { } } - private fun createClient(config: HttpClientConfig<*>.() -> Unit): HttpClient { + fun createSyncClient(): HttpClient { val engine = MockSyncService( lines = syncLines, generateCheckpoint = { checkpointResponse() }, 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 f897be9a..5b988b72 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -16,15 +16,17 @@ public sealed class SyncClientConfiguration { /** * Extends the default Ktor [HttpClient] configuration with the provided block. */ - public class ExtendedConfig(public val block: HttpClientConfig<*>.() -> Unit) : - SyncClientConfiguration() + 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. */ - public class ExistingClient(public val client: HttpClient) : - SyncClientConfiguration() + public class ExistingClient( + public val client: HttpClient, + ) : SyncClientConfiguration() } /** @@ -36,33 +38,33 @@ public sealed class SyncClientConfiguration { * for the rest of the SDK though. */ public class SyncOptions -@ExperimentalPowerSyncAPI -constructor( - @property:ExperimentalPowerSyncAPI - public val newClientImplementation: Boolean = false, - @property:ExperimentalPowerSyncAPI - public val method: ConnectionMethod = ConnectionMethod.Http, - /** - * 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 { + @ExperimentalPowerSyncAPI + constructor( + @property:ExperimentalPowerSyncAPI + public val newClientImplementation: Boolean = false, + @property:ExperimentalPowerSyncAPI + public val method: ConnectionMethod = ConnectionMethod.Http, /** - * The default sync options, which are safe and stable to use. - * - * Constructing non-standard sync options requires an opt-in to experimental PowerSync - * APIs, and those might change in the future. + * The user agent to use for requests made to the PowerSync service. */ - @OptIn(ExperimentalPowerSyncAPI::class) - public val defaults: SyncOptions = SyncOptions() + 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 { + /** + * The default sync options, which are safe and stable to use. + * + * Constructing non-standard sync options requires an opt-in to experimental PowerSync + * APIs, and those might change in the future. + */ + @OptIn(ExperimentalPowerSyncAPI::class) + public val defaults: SyncOptions = SyncOptions() + } } -} /** * The connection method to use when the SDK connects to the sync service. diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index f98cc51d..19b353e8 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -55,6 +55,21 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement +/** + * Configures a [HttpClient] for PowerSync sync operations. + */ +public fun HttpClientConfig<*>.configureSyncHttpClient(userAgent: String = userAgent()) { + install(HttpTimeout) + install(ContentNegotiation) + install(WebSockets) + + install(DefaultRequest) { + headers { + append("User-Agent", userAgent) + } + } +} + @OptIn(ExperimentalPowerSyncAPI::class) internal class SyncStream( private val bucketStorage: BucketStorage, @@ -66,7 +81,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) @@ -78,38 +92,24 @@ internal class SyncStream( private var clientId: String? = null - private val httpClient: HttpClient = when (val config = options.clientConfiguration) { - is SyncClientConfiguration.ExtendedConfig -> { - createClient { - configureHttpClient() - // Apply additional configuration - config.block(this) - } - } + private val httpClient: HttpClient = + when (val config = options.clientConfiguration) { + is SyncClientConfiguration.ExtendedConfig -> + createClient(options.userAgent, config.block) - is SyncClientConfiguration.ExistingClient -> config.client + is SyncClientConfiguration.ExistingClient -> config.client - null -> { - // Default client configuration - createClient { - configureHttpClient() - } + null -> createClient(options.userAgent) } - } - private fun HttpClientConfig<*>.configureHttpClient() { - install(HttpTimeout) - install(ContentNegotiation) - install(WebSockets) - - install(DefaultRequest) { - headers { - append("User-Agent", options.userAgent) - } - } + private fun createClient( + userAgent: String, + additionalConfig: HttpClientConfig<*>.() -> Unit = {}, + ) = HttpClient { + configureSyncHttpClient(userAgent) + additionalConfig() } - fun invalidateCredentials() { connector.invalidateCredentials() } @@ -724,13 +724,6 @@ internal class SyncStream( return state } } - - internal companion object { - fun defaultHttpClient(config: HttpClientConfig<*>.() -> Unit) = - HttpClient { - config(this) - } - } } @LegacySyncImplementation diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index 1e903f8b..d403e3bf 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -65,12 +65,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(), ) @@ -104,13 +111,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(), ) @@ -128,7 +142,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) } } @@ -145,13 +162,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/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt b/demos/hello-powersync/composeApp/src/commonMain/kotlin/com/powersync/demos/PowerSync.kt index 1894a2ea..1e22d9d3 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 @@ -7,8 +7,8 @@ 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.Logging import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking @@ -67,7 +67,7 @@ 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)) @@ -76,16 +76,16 @@ class PowerSync( @OptIn(ExperimentalPowerSyncAPI::class) suspend fun connect() { - println("connecting to PowerSync...") database.connect( connector, options = SyncOptions( - clientConfiguration = SyncClientConfiguration.ExtendedConfig { - install(Logging) { - level = LogLevel.ALL - } - } + clientConfiguration = + SyncClientConfiguration.ExtendedConfig { + install(Logging) { + level = LogLevel.ALL + } + }, ), ) } From b2c3e2e2097c94dea92f1ea3e3830cbb6674343d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 31 Jul 2025 09:09:31 +0200 Subject: [PATCH 03/11] Hide From Objective C. Add Swift helper. --- PowerSyncKotlin/build.gradle.kts | 13 ++++- .../kotlin/com/powersync/HttpPlugins.kt | 58 +++++++++++++++++++ .../kotlin/com/powersync/sync/SyncOptions.kt | 9 +++ .../kotlin/com/powersync/sync/SyncStream.kt | 14 +++++ .../kotlin/com/powersync/demos/PowerSync.kt | 5 +- 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt 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/HttpPlugins.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt new file mode 100644 index 00000000..61657481 --- /dev/null +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt @@ -0,0 +1,58 @@ +package com.powersync + +import com.powersync.sync.SyncClientConfiguration +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.Logger as KtorLogger + +/** + * 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 output: (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.ExtendedConfig = + 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.output(message) + } + } + } + } + } diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 5b988b72..ce42de35 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -5,6 +5,8 @@ import com.powersync.PowerSyncDatabase import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.rsocket.kotlin.keepalive.KeepAlive +import kotlin.experimental.ExperimentalObjCRefinement +import kotlin.native.HiddenFromObjC import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -16,6 +18,8 @@ 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() @@ -23,7 +27,12 @@ public sealed class 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() diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 19b353e8..aad0cac6 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -54,10 +54,24 @@ import kotlinx.datetime.Clock 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) install(ContentNegotiation) 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 1e22d9d3..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 @@ -8,7 +8,9 @@ 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 @@ -67,7 +69,7 @@ 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)) @@ -84,6 +86,7 @@ class PowerSync( SyncClientConfiguration.ExtendedConfig { install(Logging) { level = LogLevel.ALL + logger = Logger.SIMPLE } }, ), From f1e6df4b24c80d12f3ca94c2f07c89c3650775ce Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 31 Jul 2025 09:52:47 +0200 Subject: [PATCH 04/11] rename logger --- .../src/appleMain/kotlin/com/powersync/HttpPlugins.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt index 61657481..150b3d6a 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt @@ -34,14 +34,14 @@ internal fun SwiftNetworkLogLevel.toKtorLogLevel(): LogLevel = */ public data class SwiftNetworkLoggerConfig( public val logLevel: SwiftNetworkLogLevel, - public val output: (message: String) -> Unit, + 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.ExtendedConfig = +public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftNetworkLoggerConfig? = null): SyncClientConfiguration = SyncClientConfiguration.ExtendedConfig { if (loggingConfig != null) { install(Logging) { @@ -50,7 +50,7 @@ public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftNetworkLogg logger = object : KtorLogger { override fun log(message: String) { - loggingConfig.output(message) + loggingConfig.log(message) } } } From 4ae7c954923c3ab43fc8d034a13134685f26001b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 31 Jul 2025 11:02:02 +0200 Subject: [PATCH 05/11] Updates from merge conflicts --- .../com/powersync/sync/SyncIntegrationTest.kt | 2 +- .../com/powersync/testutils/TestUtils.kt | 12 +-- .../kotlin/com/powersync/sync/SyncOptions.kt | 4 + .../kotlin/com/powersync/sync/SyncStream.kt | 86 +++++++++++++++---- gradle.properties | 2 +- 5 files changed, 83 insertions(+), 23 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt index 604d32ce..25bc868c 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt @@ -891,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/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/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 92f52c01..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,10 @@ 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 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/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 From d357109995342bd55f1ce154aa2f77ff965ddaca Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 31 Jul 2025 17:49:51 +0200 Subject: [PATCH 06/11] cleanup --- .../kotlin/com/powersync/HttpPlugins.kt | 58 ------------------ .../src/appleMain/kotlin/com/powersync/SDK.kt | 60 ++++++++++++++++++- 2 files changed, 59 insertions(+), 59 deletions(-) delete mode 100644 PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt deleted file mode 100644 index 150b3d6a..00000000 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/HttpPlugins.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.powersync - -import com.powersync.sync.SyncClientConfiguration -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.logging.Logger as KtorLogger - -/** - * 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) - } - } - } - } - } 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), ) From 8f276c9c3071a6be432af63631238ba4b9f96b59 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 5 Aug 2025 18:15:15 +0200 Subject: [PATCH 07/11] Apply network logger to Supabase todolist app --- .../kotlin/com/powersync/sync/SyncStream.kt | 3 +- .../iosApp/iosApp.xcodeproj/project.pbxproj | 18 +++++++++++ .../supabase-todolist/shared/build.gradle.kts | 1 + .../kotlin/com/powersync/demos/Auth.kt | 31 ++++++++++++++++--- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 90528a0c..2b22561a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -106,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) @@ -755,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") diff --git a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj index 58c8b8f8..0b5842d5 100644 --- a/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/iosApp/iosApp.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ 7555FF79242A565900829871 /* Resources */, F85CB1118929364A9C6EFABC /* Frameworks */, 3C5ACF3A4AAFF294B2A5839B /* [CP] Embed Pods Frameworks */, + AA799A6E8997A58F1EF8CBFF /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -230,6 +231,23 @@ 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; }; + AA799A6E8997A58F1EF8CBFF /* [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/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) From 9be66c96658736f25576d9570e677eaa9e475f2f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 6 Aug 2025 09:06:31 +0200 Subject: [PATCH 08/11] Add changelog --- CHANGELOG.md | 23 ++++++++++++++----- .../kotlin/com/powersync/sync/SyncOptions.kt | 3 +-- 2 files changed, 18 insertions(+), 8 deletions(-) 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/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index bc4a1066..94e827f4 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -11,11 +11,11 @@ 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. */ - @OptIn(ExperimentalObjCRefinement::class) @HiddenFromObjC public class ExtendedConfig( public val block: HttpClientConfig<*>.() -> Unit, @@ -27,7 +27,6 @@ public sealed class SyncClientConfiguration { * 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( From 13f9268afe80d90017fc398fdfcae236e4210bd6 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 6 Aug 2025 09:44:55 +0200 Subject: [PATCH 09/11] Update Swift Sync Request type naming --- .../src/appleMain/kotlin/com/powersync/SDK.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt index b851b75f..6204de80 100644 --- a/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt +++ b/PowerSyncKotlin/src/appleMain/kotlin/com/powersync/SDK.kt @@ -24,7 +24,7 @@ public fun throwPowerSyncException(exception: PowerSyncException): Unit = throw * 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 { +public enum class SwiftSyncRequestLogLevel { ALL, HEADERS, BODY, @@ -35,28 +35,28 @@ public enum class SwiftNetworkLogLevel { /** * Mapper function to Ktor LogLevel */ -internal fun SwiftNetworkLogLevel.toKtorLogLevel(): LogLevel = +internal fun SwiftSyncRequestLogLevel.toKtorLogLevel(): LogLevel = when (this) { - SwiftNetworkLogLevel.ALL -> LogLevel.ALL - SwiftNetworkLogLevel.HEADERS -> LogLevel.HEADERS - SwiftNetworkLogLevel.BODY -> LogLevel.BODY - SwiftNetworkLogLevel.INFO -> LogLevel.INFO - SwiftNetworkLogLevel.NONE -> LogLevel.NONE + 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 SwiftNetworkLoggerConfig( - public val logLevel: SwiftNetworkLogLevel, +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 [SwiftNetworkLoggerConfig] will install the Ktor logging plugin with the specified configuration. + * Specifying a [SwiftRequestLoggerConfig] will install the Ktor logging plugin with the specified configuration. */ -public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftNetworkLoggerConfig? = null): SyncClientConfiguration = +public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftRequestLoggerConfig? = null): SyncClientConfiguration = SyncClientConfiguration.ExtendedConfig { if (loggingConfig != null) { install(Logging) { @@ -81,7 +81,7 @@ public fun createExtendedSyncClientConfiguration(loggingConfig: SwiftNetworkLogg public fun createSyncOptions( newClient: Boolean, userAgent: String, - loggingConfig: SwiftNetworkLoggerConfig? = null, + loggingConfig: SwiftRequestLoggerConfig? = null, ): SyncOptions = SyncOptions( newClientImplementation = newClient, From 65570d0bccdc060d672a08e0fb5304213ae86c2b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 6 Aug 2025 10:06:45 +0200 Subject: [PATCH 10/11] Restore api dependency --- core/build.gradle.kts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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) } } From fa96882f47dbeb5afa64a942d1c3cef15f3030eb Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 6 Aug 2025 10:43:15 +0200 Subject: [PATCH 11/11] Docs updates --- .../kotlin/com/powersync/sync/SyncOptions.kt | 3 ++- .../commonMain/kotlin/com/powersync/sync/SyncStream.kt | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt index 94e827f4..c8c89f5b 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncOptions.kt @@ -25,7 +25,8 @@ public sealed class 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. + * 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 diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 2b22561a..3bacd18a 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -67,16 +67,22 @@ import kotlin.native.HiddenFromObjC import kotlin.time.Clock /** - * 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. * + * 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