From ec9e222c6132027f712e8f65eb7c9f1d46f98d1d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 25 Nov 2025 13:38:11 +0200 Subject: [PATCH 1/3] feat: add appMetadata for service logs --- CHANGELOG.md | 11 ++++ .../kotlin/com/powersync/PowerSyncDatabase.kt | 13 +++- .../com/powersync/bucket/BucketStorage.kt | 5 +- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 1 + .../com/powersync/sync/StreamingSync.kt | 60 ++++++++++++++++++- .../powersync/sync/StreamingSyncRequest.kt | 1 + 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a97931b1..8c727bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ - Sync options: `newClientImplementation` is now the default. - Make `androidx.sqlite:sqlite-bundled` an API dependency of `:core` to avoid toolchain warnings. +- Add `appMetadata` parameter to `PowerSyncDatabase.connect()` to include application metadata in sync requests. This metadata is merged into sync requests and displayed in PowerSync service logs. + +```kotlin +database.connect( + connector = connector, + appMetadata = mapOf( + "appVersion" to "1.0.0", + "deviceId" to "device456" + ) +) +``` ## 1.8.1 diff --git a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index 42271ebe..bc2de2a8 100644 --- a/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/common/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -80,6 +80,7 @@ public interface PowerSyncDatabase : Queries { * Use @param [crudThrottleMs] to specify the time between CRUD operations. Defaults to 1000ms. * Use @param [retryDelayMs] to specify the delay between retries after failure. Defaults to 5000ms. * Use @param [params] to specify sync parameters from the client. + * Use @param [appMetadata] to specify application metadata that will be displayed in PowerSync service logs. * * Example usage: * ``` @@ -91,11 +92,17 @@ public interface PowerSyncDatabase : Queries { * ) * ) * + * val appMetadata = mapOf( + * "appVersion" to "1.0.0", + * "deviceId" to "device456" + * ) + * * connect( * connector = connector, * crudThrottleMs = 2000L, * retryDelayMs = 10000L, - * params = params + * params = params, + * appMetadata = appMetadata * ) * ``` */ @@ -106,6 +113,7 @@ public interface PowerSyncDatabase : Queries { retryDelayMs: Long = 5000L, params: Map = emptyMap(), options: SyncOptions = SyncOptions(), + appMetadata: Map = emptyMap(), ) /** @@ -272,7 +280,8 @@ public interface PowerSyncDatabase : Queries { val logger = generateLogger(logger) // Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the // same database being opened multiple times. - val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") + val collection = + ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") return openedWithGroup( SingleConnectionPool(factory.openInMemoryConnection()), diff --git a/common/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt b/common/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt index 8ea60a38..abca8525 100644 --- a/common/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt +++ b/common/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt @@ -71,6 +71,8 @@ internal sealed interface PowerSyncControlArguments { val includeDefaults: Boolean, @SerialName("active_streams") val activeStreams: List, + @SerialName("app_metadata") + val appMetadata: Map, ) : PowerSyncControlArguments { override val sqlArguments: Pair get() = "start" to JsonUtil.json.encodeToString(this) @@ -109,7 +111,8 @@ internal sealed interface PowerSyncControlArguments { class UpdateSubscriptions( activeStreams: List, ) : PowerSyncControlArguments { - override val sqlArguments: Pair = "update_subscriptions" to JsonUtil.json.encodeToString(activeStreams) + override val sqlArguments: Pair = + "update_subscriptions" to JsonUtil.json.encodeToString(activeStreams) } } diff --git a/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index bf23a5af..c1366153 100644 --- a/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -142,6 +142,7 @@ internal class PowerSyncDatabaseImpl( retryDelayMs: Long, params: Map, options: SyncOptions, + appMetadata: Map, ) { waitReady() mutex.withLock { diff --git a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt index 10997a34..8d9b2371 100644 --- a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt +++ b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt @@ -63,6 +63,7 @@ import kotlinx.io.readByteArray import kotlinx.io.readIntLe import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement import kotlin.experimental.ExperimentalObjCRefinement import kotlin.native.HiddenFromObjC @@ -115,6 +116,7 @@ internal class StreamingSyncClient( private val options: SyncOptions, private val schema: Schema, private val activeSubscriptions: StateFlow>, + private val appMetadata: Map, ) { private var isUploadingCrud = AtomicReference(null) private var completedCrudUploads = Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -176,7 +178,13 @@ internal class StreamingSyncClient( status.update { copy(downloadError = e) } } finally { if (!result.hideDisconnectStateAndReconnectImmediately) { - status.update { copy(connected = false, connecting = true, downloading = false) } + status.update { + copy( + connected = false, + connecting = true, + downloading = false, + ) + } delay(retryDelayMs) } } @@ -397,6 +405,7 @@ internal class StreamingSyncClient( schema = schema.toSerializable(), includeDefaults = options.includeDefaultStreams, activeStreams = subscriptions.map { it.key }, + appMetadata = appMetadata, ), ) @@ -405,7 +414,9 @@ internal class StreamingSyncClient( activeSubscriptions.collect { if (subscriptions !== it) { subscriptions = it - controlInvocations.send(PowerSyncControlArguments.UpdateSubscriptions(activeSubscriptions.value.map { it.key })) + controlInvocations.send( + PowerSyncControlArguments.UpdateSubscriptions(activeSubscriptions.value.map { it.key }), + ) } } } @@ -525,10 +536,52 @@ internal class StreamingSyncClient( } private suspend fun connect(start: Instruction.EstablishSyncStream) { - receiveTextOrBinaryLines(start.request).collect { + // Merge local appMetadata from StreamingSyncClient into the request before sending + val mergedRequest = mergeAppMetadata(start.request) + receiveTextOrBinaryLines(mergedRequest).collect { controlInvocations.send(it) } } + + /** + * FIXME, the Rust implementation does not yet pass app_metadata to the sync instruction + * + * Merges local appMetadata into the request JsonObject. + * If the request already has app_metadata, the local appMetadata will be merged into it + * (with local values taking precedence for duplicate keys). + */ + private fun mergeAppMetadata(request: JsonObject): JsonObject { + if (appMetadata.isEmpty()) { + return request + } + + // Convert local appMetadata to JsonObject + val localAppMetadataJson = + JsonObject( + appMetadata.mapValues { (_, value) -> JsonPrimitive(value) }, + ) + + // Get existing app_metadata from request, if any + val existingAppMetadata = + request["app_metadata"] as? JsonObject ?: JsonObject(emptyMap()) + + // Merge: existing first, then local (local takes precedence) + val mergedAppMetadata = + JsonObject( + buildMap { + putAll(existingAppMetadata) + putAll(localAppMetadataJson) + }, + ) + + // Create new request with merged app_metadata + return JsonObject( + buildMap { + putAll(request) + put("app_metadata", mergedAppMetadata) + }, + ) + } } @LegacySyncImplementation @@ -566,6 +619,7 @@ internal class StreamingSyncClient( }, clientId = clientId!!, parameters = params, + appMetadata = appMetadata, ) lateinit var receiveLines: Job diff --git a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt index a09c15f4..063334d6 100644 --- a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt +++ b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt @@ -12,6 +12,7 @@ internal data class StreamingSyncRequest( @SerialName("include_checksum") val includeChecksum: Boolean = true, @SerialName("client_id") val clientId: String, val parameters: JsonObject = JsonObject(mapOf()), + @SerialName("app_metadata") val appMetadata: Map, ) { @SerialName("raw_data") private val rawData: Boolean = true From df60d89d5be773e6372b03ee077961aa6ab23a30 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 25 Nov 2025 13:58:16 +0200 Subject: [PATCH 2/3] Add example to demo --- .../kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt | 1 + demos/supabase-todolist/shared/build.gradle.kts | 3 +++ .../shared/src/commonMain/kotlin/com/powersync/demos/Auth.kt | 3 +++ 3 files changed, 7 insertions(+) diff --git a/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index c1366153..2f2746e1 100644 --- a/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/common/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -160,6 +160,7 @@ internal class PowerSyncDatabaseImpl( options = options, schema = schema, activeSubscriptions = streams.currentlyReferencedStreams, + appMetadata = appMetadata, ) } } diff --git a/demos/supabase-todolist/shared/build.gradle.kts b/demos/supabase-todolist/shared/build.gradle.kts index 3a0fe227..52001749 100644 --- a/demos/supabase-todolist/shared/build.gradle.kts +++ b/demos/supabase-todolist/shared/build.gradle.kts @@ -123,5 +123,8 @@ buildkonfig { stringConfigField("POWERSYNC_URL") stringConfigField("SUPABASE_URL") stringConfigField("SUPABASE_ANON_KEY") + + // App version from Gradle project version + buildConfigField(STRING, "APP_VERSION", "\"${project.version}\"") } } 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 465cec3b..c1894ce7 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 @@ -69,6 +69,9 @@ internal class AuthViewModel( } }, ), + appMetadata = mapOf( + "appVersion" to Config.APP_VERSION + ), ) } From a48955583bb75de90f04159434760be025d020d0 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 25 Nov 2025 14:34:12 +0200 Subject: [PATCH 3/3] Fix tests --- .../src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt | 2 +- .../kotlin/com/powersync/sync/StreamingSyncRequest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt index 8d9b2371..cf9e1b10 100644 --- a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt +++ b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSync.kt @@ -116,7 +116,7 @@ internal class StreamingSyncClient( private val options: SyncOptions, private val schema: Schema, private val activeSubscriptions: StateFlow>, - private val appMetadata: Map, + private val appMetadata: Map = emptyMap(), ) { private var isUploadingCrud = AtomicReference(null) private var completedCrudUploads = Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) diff --git a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt index 063334d6..9ad273c0 100644 --- a/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt +++ b/common/src/commonMain/kotlin/com/powersync/sync/StreamingSyncRequest.kt @@ -12,7 +12,7 @@ internal data class StreamingSyncRequest( @SerialName("include_checksum") val includeChecksum: Boolean = true, @SerialName("client_id") val clientId: String, val parameters: JsonObject = JsonObject(mapOf()), - @SerialName("app_metadata") val appMetadata: Map, + @SerialName("app_metadata") val appMetadata: Map = emptyMap(), ) { @SerialName("raw_data") private val rawData: Boolean = true