From 3bc7cf5040447bcd13652db6732141f1990e7956 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Oct 2025 14:10:49 +0100 Subject: [PATCH 1/4] Implement sync streams --- Package.swift | 2 +- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 5 + .../Kotlin/sync/KotlinSyncStatusData.swift | 21 +++ .../Kotlin/sync/KotlinSyncStreams.swift | 125 ++++++++++++++++++ .../Protocol/PowerSyncDatabaseProtocol.swift | 5 + Sources/PowerSync/Protocol/db/JsonParam.swift | 21 +++ .../Protocol/sync/SyncStatusData.swift | 17 +++ .../PowerSync/Protocol/sync/SyncStream.swift | 84 ++++++++++++ .../KotlinPowerSyncDatabaseImplTests.swift | 16 +++ 9 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift create mode 100644 Sources/PowerSync/Protocol/sync/SyncStream.swift diff --git a/Package.swift b/Package.swift index 86b91d6..286ff8f 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let packageName = "PowerSync" // Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin // build. Also see docs/LocalBuild.md for details -let localKotlinSdkOverride: String? = nil +let localKotlinSdkOverride: String? = "/Users/simon/src/powersync-kotlin" // Set this to the absolute path of your powersync-sqlite-core checkout if you want to use a // local build of the core extension. diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 2e61cef..d41f3d2 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -329,6 +329,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, func close() async throws { try await kotlinDatabase.close() } + + func syncStream(name: String, params: JsonParam?) -> any SyncStream { + let rawStream = kotlinDatabase.syncStream(name: name, parameters: params?.mapValues { $0.toKotlinMap() }); + return KotlinSyncStream(kotlinStream: rawStream) + } /// Tries to convert Kotlin PowerSyncExceptions to Swift Exceptions private func wrapPowerSyncException( diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift index a7fcf47..d836655 100644 --- a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift @@ -72,6 +72,27 @@ extension KotlinSyncStatusDataProtocol { ) ) } + + var syncStreams: [SyncStreamStatus]? { + return base.syncStreams?.map(mapSyncStreamStatus) + } + + func forStream(stream: SyncStreamDescription) -> SyncStreamStatus? { + let name = stream.name + // To match parameters, first check if we already have access to a Kotlin map for parameters. + let parameters = if let kotlinStream = stream as? any HasKotlinStreamDescription { + // Fast path: Reuse Kotlin map + kotlinStream.kotlinParameters + } else { + // We don't? Ok, map to Kotlin. + stream.parameters?.mapValues { $0.toValue() } + } + + guard let kotlinStatus = syncStatusForStream(status: base, name: stream.name, parameters: parameters) else { + return nil + } + return mapSyncStreamStatus(kotlinStatus) + } private func mapPriorityStatus(_ status: PowerSyncKotlin.PriorityStatusEntry) -> PriorityStatusEntry { var lastSyncedAt: Date? diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift new file mode 100644 index 0000000..6f2a502 --- /dev/null +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift @@ -0,0 +1,125 @@ +import Foundation +import PowerSyncKotlin + +class KotlinStreamDescription { + let inner: T + let name: String + let parameters: JsonParam? + let kotlinParameters: [String: Any?]? + + init(inner: T) { + self.inner = inner + self.name = inner.name + self.kotlinParameters = inner.parameters + self.parameters = inner.parameters?.mapValues { JsonValue.fromValue(raw: $0) } + } +} + +protocol HasKotlinStreamDescription { + associatedtype Description: PowerSyncKotlin.SyncStreamDescription + + var stream: KotlinStreamDescription { get } +} + +extension HasKotlinStreamDescription { + var kotlinParameters: [String: Any?]? { + self.stream.kotlinParameters + } +} + +class KotlinSyncStream: SyncStream, HasKotlinStreamDescription, +// `PowerSyncKotlin.SyncStream` cannot be marked as Sendable, but is thread-safe. +@unchecked Sendable +{ + let stream: KotlinStreamDescription + + init(kotlinStream: PowerSyncKotlin.SyncStream) { + self.stream = KotlinStreamDescription(inner: kotlinStream); + } + + var name: String { + stream.name + } + + var parameters: JsonParam? { + stream.parameters + } + + func subscribe(ttl: TimeInterval?, priority: BucketPriority?) async throws -> any SyncStreamSubscription { + let kotlinTtl: Optional = if let ttl { + KotlinDouble(value: ttl) + } else { + nil + } + let kotlinPriority: Optional = if let priority { + KotlinInt(value: priority.priorityCode) + } else { + nil + } + + let kotlinSubscription = try await syncStreamSubscribeSwift( + stream: stream.inner, + ttl: kotlinTtl, + priority: kotlinPriority, + ); + return KotlinSyncStreamSubscription(kotlinStream: kotlinSubscription) + } + + func unsubscribeAll() async throws { + try await stream.inner.unsubscribeAll() + } +} + +class KotlinSyncStreamSubscription: SyncStreamSubscription, HasKotlinStreamDescription, +// `PowerSyncKotlin.SyncStreamSubscription` cannot be marked as Sendable, but is thread-safe. +@unchecked Sendable +{ + let stream: KotlinStreamDescription + + init(kotlinStream: PowerSyncKotlin.SyncStreamSubscription) { + self.stream = KotlinStreamDescription(inner: kotlinStream) + } + + var name: String { + stream.name + } + var parameters: JsonParam? { + stream.parameters + } + + func waitForFirstSync() async throws { + try await stream.inner.waitForFirstSync() + } + + func unsubscribe() async throws { + try await stream.inner.unsubscribe() + } +} + +func mapSyncStreamStatus(_ status: PowerSyncKotlin.SyncStreamStatus) -> SyncStreamStatus { + let progress = status.progress.map { ProgressNumbers(source: $0) } + let subscription = status.subscription + + return SyncStreamStatus( + progress: progress, + subscription: SyncSubscriptionDescription( + name: subscription.name, + parameters: subscription.parameters?.mapValues { JsonValue.fromValue(raw: $0) }, + active: subscription.active, + isDefault: subscription.isDefault, + hasExplicitSubscription: subscription.hasExplicitSubscription, + expiresAt: subscription.expiresAt.map { Double($0.epochSeconds) }, + lastSyncedAt: subscription.lastSyncedAt.map { Double($0.epochSeconds) } + ) + ) +} + +struct ProgressNumbers: ProgressWithOperations { + let totalOperations: Int32 + let downloadedOperations: Int32 + + init(source: PowerSyncKotlin.ProgressWithOperations) { + self.totalOperations = source.totalOperations + self.downloadedOperations = source.downloadedOperations + } +} diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index d0a7161..2896c15 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -230,6 +230,11 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable { /// Using soft clears is recommended where it's not a security issue that old data could be reconstructed from /// the database. func disconnectAndClear(clearLocal: Bool, soft: Bool) async throws + + /// Create a ``SyncStream`` instance for the given name and parameters. + /// + /// Use ``SyncStream/subscribe`` on the returned instance to subscribe to the stream. + func syncStream(name: String, params: JsonParam?) -> any SyncStream /// Close the database, releasing resources. /// Also disconnects any active connection. diff --git a/Sources/PowerSync/Protocol/db/JsonParam.swift b/Sources/PowerSync/Protocol/db/JsonParam.swift index 4b0b105..09be4b3 100644 --- a/Sources/PowerSync/Protocol/db/JsonParam.swift +++ b/Sources/PowerSync/Protocol/db/JsonParam.swift @@ -50,6 +50,27 @@ public enum JsonValue: Codable, Sendable { return anyDict } } + + /// Converts a raw Swift value into a ``JsonValue``. + /// + /// The value must be one of the types returned by ``JsonValue/toValue()``. + static func fromValue(raw: Any?) -> Self { + if let string = raw as? String { + return Self.string(string) + } else if let int = raw as? Int { + return Self.int(int) + } else if let double = raw as? Double { + return Self.double(double) + } else if let bool = raw as? Bool { + return Self.bool(bool) + } else if let array = raw as? [Any?] { + return Self.array(array.map(fromValue)) + } else if let object = raw as? [String: Any?] { + return Self.object(object.mapValues(fromValue)) + } else { + return Self.null + } + } } /// A typealias representing a top-level JSON object with string keys and `JSONValue` values. diff --git a/Sources/PowerSync/Protocol/sync/SyncStatusData.swift b/Sources/PowerSync/Protocol/sync/SyncStatusData.swift index f4b5a4d..6d32da2 100644 --- a/Sources/PowerSync/Protocol/sync/SyncStatusData.swift +++ b/Sources/PowerSync/Protocol/sync/SyncStatusData.swift @@ -47,6 +47,15 @@ public protocol SyncStatusData: Sendable { /// - Parameter priority: The priority for which the status is requested. /// - Returns: A `PriorityStatusEntry` representing the synchronization status for the given priority. func statusForPriority(_ priority: BucketPriority) -> PriorityStatusEntry + + /// All sync streams currently being tracked in the database. + /// + /// This returns null when the database is currently being opened and we don't have reliable information about + /// included streams yet. + var syncStreams: [SyncStreamStatus]? { get } + + /// Status information for the given stream, if it's a stream that is currently tracked by the sync client. + func forStream(stream: SyncStreamDescription) -> SyncStreamStatus? } /// A protocol extending `SyncStatusData` to include flow-based updates for synchronization status. @@ -55,3 +64,11 @@ public protocol SyncStatus: SyncStatusData, Sendable { /// - Returns: An `AsyncStream` that emits updates whenever the synchronization status changes. func asFlow() -> AsyncStream } + +/// Current information about a ``SyncStreamSubscription``. +public struct SyncStreamStatus { + /// If the sync status is currently downloading, information about download progress related to this stream. + let progress: ProgressWithOperations? + /// The ``SyncSubscriptionDescription`` providing information about the subscription. + let subscription: SyncSubscriptionDescription +} diff --git a/Sources/PowerSync/Protocol/sync/SyncStream.swift b/Sources/PowerSync/Protocol/sync/SyncStream.swift new file mode 100644 index 0000000..23761ef --- /dev/null +++ b/Sources/PowerSync/Protocol/sync/SyncStream.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Information uniquely identifying a sync stream that can be subscribed to. +public protocol SyncStreamDescription: Sendable { + /// The name of the sync stream as it appeaers in the stream definition for the PowerSync service. + var name: String { get } + /// The parameters used to subscribe to the stream, if any. + /// + /// The same stream can be subscribed to multiple times with different parameters. + var parameters: JsonParam? { get } +} + +/// A handle to a ``SyncStreamDescription`` that allows subscribing to the stream. +/// +/// To obtain an instance of ``SyncStream``, call ``PowerSyncDatabase/syncStream``. +public protocol SyncStream: SyncStreamDescription { + /// Creates a new subscription on this stream. + /// + /// As long as a subscription is active on the stream, the sync client will request it from the sync service. + /// + /// This call is generally quite cheap and can be issued frequently, e.g. when a view needing data from the stream is activated. + func subscribe(ttl: TimeInterval?, priority: BucketPriority?) async throws -> any SyncStreamSubscription + + /// Unsubscribes all existing subscriptions on this stream. + /// + /// This is a potentially unsafe method since it interferes with other subscriptions. A better option is to call + /// ``SyncStreamSubscription/unsubscribe``. + func unsubscribeAll() async throws +} + +extension SyncStream { + + public func subscribe() async throws -> any SyncStreamSubscription { + return try await subscribe(ttl: nil, priority: nil) + } +} + +/// A ``SyncStream`` that has an active subscription. +public protocol SyncStreamSubscription: SyncStreamDescription { + /// An asynchronous function that completes once data on this stream has been synced. + func waitForFirstSync() async throws + /// Removes this subscription. + /// + /// Once all ``SyncStreamSubscription``s for a ``SyncStream`` have been unsubscribed, the `ttl` + /// for that stream thats running. When it expires without subscribing again, the stream will be evicted. + func unsubscribe() async throws +} + +/// Information about a subscribed sync stream. +/// +/// This includes the ``SyncStreamDescription`` along with information about the current sync status. +public struct SyncSubscriptionDescription: SyncStreamDescription { + public let name: String + public let parameters: JsonParam? + /// Whether this stream is active, meaning that the subscription has been acknowledged by the sync service. + public let active: Bool + /// Whether this stream subscription is included by default, regardless of whether the stream has explicitly + /// been subscribed to or not. + /// + /// Default streams are created by applying `auto_subscribe: true` in their definition on the sync service. + /// + /// It's possible for both ``SyncSubscriptionDescription/isDefault`` and + /// ``SyncSubscriptionDescription/hasExplicitSubscription`` to be true at the same time. This + /// happens when a default stream was subscribed to explicitly. + public let isDefault: Bool + /// Whether this stream has been subscribed to explicitly. + /// + /// It's possible for both ``SyncSubscriptionDescription/isDefault`` and + /// ``SyncSubscriptionDescription/hasExplicitSubscription`` to be true at the same time. This + /// happens when a default stream was subscribed to explicitly. + public let hasExplicitSubscription: Bool + /// For sync streams that have a time-to-live, the current time at which the stream would expire if not subscribed to + /// again. + public let expiresAt: TimeInterval? + /// If ``SyncSubscriptionDescription/hasSynced`` is true, the last time data from this stream has been synced. + public let lastSyncedAt: TimeInterval? + + /// Whether this stream has been synced at least once. + public var hasSynced: Bool { + get { + return self.expiresAt != nil + } + } +} diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 0c3b48e..66ccfb1 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -624,4 +624,20 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTAssertEqual(result[0], JoinOutput(name: "Test User", description: "task 1", comment: "comment 1")) XCTAssertEqual(result[1], JoinOutput(name: "Test User", description: "task 2", comment: "comment 2")) } + + func testSubscriptionsUpdateStateWhileOffline() async throws { + var streams = database.currentStatus.asFlow().makeAsyncIterator() + let initialStatus = await streams.next(); // Ignore initial + XCTAssertEqual(initialStatus?.syncStreams?.count, 0) + + // Subscribing while offline should add the stream to the subscriptions reported in the status. + let subscription = try await database.syncStream(name: "foo", params: ["foo": JsonValue.string("bar")]).subscribe() + let updatedStatus = await streams.next(); + + XCTAssertEqual(updatedStatus?.syncStreams?.count, 1) + let status = updatedStatus?.forStream(stream: subscription) + XCTAssertNotNil(status) + + XCTAssertNil(status?.progress) + } } From 7ae45dba2d1e08c4060899278fedf7443158ddc5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Oct 2025 15:53:52 +0100 Subject: [PATCH 2/4] Adopt in demo --- .../project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 13 +++++++++-- .../Components/TodoListView.swift | 22 +++++++++++++++++++ .../PowerSync/SystemManager.swift | 5 +++-- .../PowerSyncExample/Screens/HomeScreen.swift | 1 - Demo/PowerSyncExample/Secrets.template.swift | 20 +++++++++++++++++ Demo/PowerSyncExample/_Secrets.swift | 1 + .../Protocol/PowerSyncDatabaseProtocol.swift | 19 +++------------- 8 files changed, 61 insertions(+), 22 deletions(-) diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index a21dbc3..18c4b10 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.pbxproj +++ b/Demo/PowerSyncExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2aa1e82..6edc6b1 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -10,13 +10,22 @@ "version" : "0.6.7" } }, + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sbooth/CSQLite.git", + "state" : { + "revision" : "b1161e6c73fa68c25292f6bb697293d6c679f919", + "version" : "3.50.4" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", - "version" : "0.4.6" + "revision" : "9801f4aa0923c7f33fa479a01e643d00e7764f0b", + "version" : "0.4.8" } }, { diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index 973bc6a..070ec12 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -1,5 +1,6 @@ import AVFoundation import IdentifiedCollections +import PowerSync import SwiftUI import SwiftUINavigation @@ -11,6 +12,7 @@ struct TodoListView: View { @State private var error: Error? @State private var newTodo: NewTodo? @State private var editing: Bool = false + @State private var loadingListItems: Bool = false #if os(iOS) // Called when a photo has been captured. Individual widgets should register the listener @@ -33,6 +35,10 @@ struct TodoListView: View { } } } + + if (loadingListItems) { + ProgressView() + } ForEach(todos) { todo in #if os(iOS) @@ -142,6 +148,22 @@ struct TodoListView: View { } } } + .task { + if (Secrets.previewSyncStreams) { + // With sync streams, todo items are not loaded by default. We have to request them while we need them. + // Thanks to builtin caching, navingating to the same list multiple times does not have to fetch items again. + loadingListItems = true + do { + // This will make the sync client request items from this list as long as we keep a reference to the stream subscription, + // and a default TTL of one day afterwards. + let streamSubscription = try await system.db.syncStream(name: "todos", params: ["list": JsonValue.string(listId)]).subscribe() + try await streamSubscription.waitForFirstSync() + } catch { + print("Error subscribing to list stream \(error)") + } + loadingListItems = false + } + } } func toggleCompletion(of todo: Todo) async { diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index f250997..5d8c04a 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -78,11 +78,12 @@ final class SystemManager { options: ConnectOptions( clientConfiguration: SyncClientConfiguration( requestLogger: SyncRequestLoggerConfiguration( - requestLevel: .headers + requestLevel: .all ) { message in self.db.logger.debug(message, tag: "SyncRequest") } - ) + ), + newClientImplementation: true, ) ) try await attachments?.startSync() diff --git a/Demo/PowerSyncExample/Screens/HomeScreen.swift b/Demo/PowerSyncExample/Screens/HomeScreen.swift index 608e046..73c5e0d 100644 --- a/Demo/PowerSyncExample/Screens/HomeScreen.swift +++ b/Demo/PowerSyncExample/Screens/HomeScreen.swift @@ -8,7 +8,6 @@ struct HomeScreen: View { var body: some View { - ListView() .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/Demo/PowerSyncExample/Secrets.template.swift b/Demo/PowerSyncExample/Secrets.template.swift index d7b3677..6ff49ab 100644 --- a/Demo/PowerSyncExample/Secrets.template.swift +++ b/Demo/PowerSyncExample/Secrets.template.swift @@ -17,4 +17,24 @@ extension Secrets { static var supabaseStorageBucket: String? { return nil } + + static var previewSyncStreams: Bool { + /* + Set to true to preview https://docs.powersync.com/usage/sync-streams. + When enabling this, also set your sync rules to the following: + + streams: + lists: + query: SELECT * FROM lists WHERE owner_id = auth.user_id() + auto_subscribe: true + todos: + query: SELECT * FROM todos WHERE list_id = subscription.parameter('list') AND list_id IN (SELECT id FROM lists WHERE owner_id = auth.user_id()) + + config: + edition: 2 + + */ + + false + } } \ No newline at end of file diff --git a/Demo/PowerSyncExample/_Secrets.swift b/Demo/PowerSyncExample/_Secrets.swift index 871ddf2..1e34650 100644 --- a/Demo/PowerSyncExample/_Secrets.swift +++ b/Demo/PowerSyncExample/_Secrets.swift @@ -6,6 +6,7 @@ protocol SecretsProvider { static var supabaseURL: URL { get } static var supabaseAnonKey: String { get } static var supabaseStorageBucket: String? { get } + static var previewSyncStreams: Bool { get } } // Default conforming type diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index 2896c15..71208a7 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -81,27 +81,14 @@ public struct ConnectOptions: Sendable { /// - retryDelay: Delay TimeInterval between retry attempts in milliseconds. Defaults to `5` seconds. /// - params: Custom sync parameters to send to the server. Defaults to an empty dictionary. /// - clientConfiguration: Configuration for the HTTP client used to connect to PowerSync. + /// - newClientImplementation: Whether to use a new sync client implemented in Rust. Currently defaults to + /// `false`, but we encourage users to try it out. public init( crudThrottle: TimeInterval = 1, retryDelay: TimeInterval = 5, params: JsonParam = [:], - clientConfiguration: SyncClientConfiguration? = nil - ) { - self.crudThrottle = crudThrottle - self.retryDelay = retryDelay - self.params = params - newClientImplementation = false - self.clientConfiguration = clientConfiguration - } - - /// Initializes a ``ConnectOptions`` instance with optional values, including experimental options. - @_spi(PowerSyncExperimental) - public init( - crudThrottle: TimeInterval = 1, - retryDelay: TimeInterval = 5, - params: JsonParam = [:], + clientConfiguration: SyncClientConfiguration? = nil, newClientImplementation: Bool = false, - clientConfiguration: SyncClientConfiguration? = nil ) { self.crudThrottle = crudThrottle self.retryDelay = retryDelay From ffd8db3e0362e1b37cc11a8ed920a922250a5306 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Oct 2025 15:54:32 +0100 Subject: [PATCH 3/4] Changelog entry --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a8370..8456be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog -## 1.6.1 (unreleased) +## 1.7.0 (unreleased) -* Update Kotlin SDK to 1.7.0. +* Update Kotlin SDK to 1.8.0. +* Add experimental support for [sync streams](https://docs.powersync.com/usage/sync-streams). ## 1.6.0 From b88ac04eb5ef173501d341f704768438119b7d43 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Oct 2025 16:03:34 +0100 Subject: [PATCH 4/4] Simplify forStream implementation --- .../Kotlin/sync/KotlinSyncStatusData.swift | 19 ++++++++----------- .../Kotlin/sync/KotlinSyncStreams.swift | 4 ++-- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift index d836655..9740694 100644 --- a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift @@ -78,20 +78,17 @@ extension KotlinSyncStatusDataProtocol { } func forStream(stream: SyncStreamDescription) -> SyncStreamStatus? { - let name = stream.name - // To match parameters, first check if we already have access to a Kotlin map for parameters. - let parameters = if let kotlinStream = stream as? any HasKotlinStreamDescription { - // Fast path: Reuse Kotlin map - kotlinStream.kotlinParameters + var rawStatus: Optional + if let kotlinStream = stream as? any HasKotlinStreamDescription { + // Fast path: Reuse Kotlin stream object for lookup. + rawStatus = base.forStream(stream: kotlinStream.kotlinDescription) } else { - // We don't? Ok, map to Kotlin. - stream.parameters?.mapValues { $0.toValue() } + // Custom stream description, we have to convert parameters to a Kotlin map. + let parameters = stream.parameters?.mapValues { $0.toValue() } + rawStatus = syncStatusForStream(status: base, name: stream.name, parameters: parameters) } - guard let kotlinStatus = syncStatusForStream(status: base, name: stream.name, parameters: parameters) else { - return nil - } - return mapSyncStreamStatus(kotlinStatus) + return rawStatus.map(mapSyncStreamStatus) } private func mapPriorityStatus(_ status: PowerSyncKotlin.PriorityStatusEntry) -> PriorityStatusEntry { diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift index 6f2a502..13f5fcc 100644 --- a/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStreams.swift @@ -22,8 +22,8 @@ protocol HasKotlinStreamDescription { } extension HasKotlinStreamDescription { - var kotlinParameters: [String: Any?]? { - self.stream.kotlinParameters + var kotlinDescription: any PowerSyncKotlin.SyncStreamDescription { + self.stream.inner } }