From 82880a4b29d5f2f6464aa3d960ab617615bc41cf Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 23 Oct 2025 17:27:08 +0200 Subject: [PATCH 01/11] Prepare for Kotlin common module --- Package.resolved | 9 +++++++++ Package.swift | 15 +++++++++++---- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Package.resolved b/Package.resolved index a62a2d5..8bba077 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "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", diff --git a/Package.swift b/Package.swift index cece14d..438c8d9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -18,14 +18,20 @@ let localCoreExtension: String? = nil // a binary target. // With a local SDK, we point to a `Package.swift` within the Kotlin SDK containing a target pointing // towards a local framework build -var conditionalDependencies: [Package.Dependency] = [] +var conditionalDependencies: [Package.Dependency] = [ + .package( + url: "https://github.com/sbooth/CSQLite.git", + from: "3.50.4", + traits: ["ENABLE_SESSION"] + ) +] var conditionalTargets: [Target] = [] var kotlinTargetDependency = Target.Dependency.target(name: "PowerSyncKotlin") if let kotlinSdkPath = localKotlinSdkOverride { // We can't depend on local XCFrameworks outside of this project's root, so there's a Package.swift // in the PowerSyncKotlin project pointing towards a local build. - conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/PowerSyncKotlin")) + conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/internal/PowerSyncKotlin")) kotlinTargetDependency = .product(name: "PowerSyncKotlin", package: "PowerSyncKotlin") } else { @@ -81,7 +87,8 @@ let package = Package( name: packageName, dependencies: [ kotlinTargetDependency, - .product(name: "PowerSyncSQLiteCore", package: corePackageName) + .product(name: "PowerSyncSQLiteCore", package: corePackageName), + .product(name: "CSQLite", package: "CSQLite") ] ), .testTarget( diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 10bbd97..efd6590 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -15,7 +15,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, dbFilename: String, logger: DatabaseLogger ) { - let factory = PowerSyncKotlin.DatabaseDriverFactory() + let factory = sqlite3DatabaseFactory(initialStatements: []) kotlinDatabase = PowerSyncDatabase( factory: factory, schema: KotlinAdapter.Schema.toKotlin(schema), From ebacd302b30c71b38eb1d4436930147ae589e501 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 27 Oct 2025 11:58:02 +0100 Subject: [PATCH 02/11] Update PowerSync Kotlin and core extension --- Package.resolved | 7 +++--- Package.swift | 6 ++--- Sources/PowerSync/Kotlin/KotlinAdapter.swift | 3 ++- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 3 ++- .../Protocol/PowerSyncDatabaseProtocol.swift | 22 +++++++++++++------ .../PowerSync/Protocol/Schema/RawTable.swift | 6 ++++- Tests/PowerSyncTests/CrudTests.swift | 15 +++++++++++++ 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/Package.resolved b/Package.resolved index 8bba077..d0dc9d8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "82539474c4bcedf04818c668b504533c49309a0dd3669f4ec2fb8e77b64439b3", "pins" : [ { "identity" : "csqlite", @@ -14,10 +15,10 @@ "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" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 438c8d9..ce28be9 100644 --- a/Package.swift +++ b/Package.swift @@ -38,8 +38,8 @@ if let kotlinSdkPath = localKotlinSdkOverride { // Not using a local build, so download from releases conditionalTargets.append(.binaryTarget( name: "PowerSyncKotlin", - url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.7.0/PowersyncKotlinRelease.zip", - checksum: "836ac106c26a184c10373c862745d9af195737ad01505bb965f197797aa88535" + url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.8.0/PowersyncKotlinRelease.zip", + checksum: "31ac7c5e11d747e11bceb0b34f30438d37033e700c621b0a468aa308d887587f" )) } @@ -51,7 +51,7 @@ if let corePath = localCoreExtension { // Not using a local build, so download from releases conditionalDependencies.append(.package( url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", - exact: "0.4.6" + exact: "0.4.8" )) } diff --git a/Sources/PowerSync/Kotlin/KotlinAdapter.swift b/Sources/PowerSync/Kotlin/KotlinAdapter.swift index 8e85220..2d73ab3 100644 --- a/Sources/PowerSync/Kotlin/KotlinAdapter.swift +++ b/Sources/PowerSync/Kotlin/KotlinAdapter.swift @@ -49,7 +49,8 @@ enum KotlinAdapter { return PowerSyncKotlin.RawTable( name: table.name, put: translateStatement(table.put), - delete: translateStatement(table.delete) + delete: translateStatement(table.delete), + clear: table.clear, ); } diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index efd6590..ba91fd7 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -89,7 +89,8 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, func disconnectAndClear(clearLocal: Bool = true) async throws { try await kotlinDatabase.disconnectAndClear( - clearLocal: clearLocal + clearLocal: clearLocal, + soft: false ) } diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index cd112a5..74fd57a 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -216,12 +216,20 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable { func disconnect() async throws /// Disconnect and clear the database. - /// Use this when logging out. - /// The database can still be queried after this is called, but the tables - /// would be empty. /// - /// - Parameter clearLocal: Set to false to preserve data in local-only tables. Defaults to `true`. - func disconnectAndClear(clearLocal: Bool) async throws + /// Clearing the database is useful when a user logs out, to ensure another user logging in later would not see + /// previous data. + /// + /// The database can still be queried after this is called, but the tables would be empty. + /// + /// To perserve data in local-only tables, set `clearLocal` to `false`. + /// + /// A `soft` clear deletes publicly visible data, but keeps internal copies of data synced in the database. This + /// usually means that if the same user logs out and back in again, the first sync is very fast because all internal + /// data is still available. When a different user logs in, no old data would be visible at any point. + /// 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 /// Close the database, releasing resources. /// Also disconnects any active connection. @@ -289,8 +297,8 @@ public extension PowerSyncDatabaseProtocol { ) } - func disconnectAndClear() async throws { - try await disconnectAndClear(clearLocal: true) + func disconnectAndClear(clearLocal: Bool = true, soft: Bool = false) async throws { + try await disconnectAndClear(clearLocal: clearLocal, soft: soft) } func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? { diff --git a/Sources/PowerSync/Protocol/Schema/RawTable.swift b/Sources/PowerSync/Protocol/Schema/RawTable.swift index b209583..f8fbcd0 100644 --- a/Sources/PowerSync/Protocol/Schema/RawTable.swift +++ b/Sources/PowerSync/Protocol/Schema/RawTable.swift @@ -24,11 +24,15 @@ public struct RawTable: BaseTableProtocol { /// The statement to run when the sync client has to delete a row. public let delete: PendingStatement + + /// An optional statement to run when the database is cleared. + public let clear: String? - public init(name: String, put: PendingStatement, delete: PendingStatement) { + public init(name: String, put: PendingStatement, delete: PendingStatement, clear: String? = nil) { self.name = name self.put = put self.delete = delete + self.clear = clear } } diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index c315b38..d3a508f 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -238,4 +238,19 @@ final class CrudTests: XCTestCase { let finalTx = try await database.getNextCrudTransaction() XCTAssertEqual(finalTx!.crud.count, 15) } + + func testSoftClear() async throws { + try await database.execute(sql: "INSERT INTO users (id, name) VALUES (uuid(), ?)", parameters: ["test"]); + try await database.execute(sql: "INSERT INTO ps_buckets (name, last_applied_op) VALUES (?, ?)", parameters: ["bkt", 10]) + + // Doing a soft-clear should delete data but keep the bucket around. + try await database.disconnectAndClear(soft: true) + let entries = try await database.getAll("SELECT name FROM ps_buckets", mapper: { cursor in try cursor.getString(index: 0) }) + XCTAssertEqual(entries.count, 1) + + // Doing a default clear also deletes buckets. + try await database.disconnectAndClear(); + let newEntries = try await database.getAll("SELECT name FROM ps_buckets", mapper: { cursor in try cursor.getString(index: 0) }) + XCTAssertEqual(newEntries.count, 0) + } } From 858640fb93313c61d59f38d9fd7f6dd38c0cbbe4 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 27 Oct 2025 12:19:59 +0100 Subject: [PATCH 03/11] Fix tests --- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 4 ++-- .../Protocol/PowerSyncDatabaseProtocol.swift | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index ba91fd7..142fc78 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -87,10 +87,10 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, try await kotlinDatabase.disconnect() } - func disconnectAndClear(clearLocal: Bool = true) async throws { + func disconnectAndClear(clearLocal: Bool, soft: Bool) async throws { try await kotlinDatabase.disconnectAndClear( clearLocal: clearLocal, - soft: false + soft: soft ) } diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index 74fd57a..d0a7161 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -297,8 +297,16 @@ public extension PowerSyncDatabaseProtocol { ) } - func disconnectAndClear(clearLocal: Bool = true, soft: Bool = false) async throws { - try await disconnectAndClear(clearLocal: clearLocal, soft: soft) + func disconnectAndClear() async throws { + try await disconnectAndClear(clearLocal: true, soft: false) + } + + func disconnectAndClear(clearLocal: Bool) async throws { + try await disconnectAndClear(clearLocal: clearLocal, soft: false) + } + + func disconnectAndClear(soft: Bool) async throws { + try await disconnectAndClear(clearLocal: true, soft: soft) } func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? { From 8300d80e36a356cdf49d1c37b14b57258f31cd6b Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 27 Oct 2025 12:24:25 +0100 Subject: [PATCH 04/11] Remove explicit Swift 6 test --- .github/workflows/build_and_test.yaml | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build_and_test.yaml b/.github/workflows/build_and_test.yaml index f13117d..9a8ff59 100644 --- a/.github/workflows/build_and_test.yaml +++ b/.github/workflows/build_and_test.yaml @@ -14,25 +14,13 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - - name: Build and Test + + - name: Test on iOS simulator run: | xcodebuild test -scheme PowerSync-Package -destination "platform=iOS Simulator,name=iPhone 16" - xcodebuild test -scheme PowerSync-Package -destination "platform=macOS,arch=arm64,name=My Mac" - xcodebuild test -scheme PowerSync-Package -destination "platform=watchOS Simulator,arch=arm64,name=Apple Watch Ultra 2 (49mm)" - - buildSwift6: - name: Build and test with Swift 6 - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - name: Set up XCode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - name: Use Swift 6 + - name: Test on macOS run: | - sed -i '' 's|^// swift-tools-version:.*$|// swift-tools-version:6.1|' Package.swift - - name: Build and Test + xcodebuild test -scheme PowerSync-Package -destination "platform=macOS,arch=arm64,name=My Mac" + - name: Test on watchOS simulator run: | - swift build -Xswiftc -strict-concurrency=complete - swift test -Xswiftc -strict-concurrency=complete + xcodebuild test -scheme PowerSync-Package -destination "platform=watchOS Simulator,arch=arm64,name=Apple Watch Ultra 2 (49mm)" From 3a4fb66f83f4070579371ea830de6cd08707d30f Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 27 Oct 2025 13:52:48 +0100 Subject: [PATCH 05/11] Try initializing SQLite --- Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 142fc78..2e61cef 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -1,5 +1,6 @@ import Foundation import PowerSyncKotlin +import CSQLite final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, // `PowerSyncKotlin.PowerSyncDatabase` cannot be marked as Sendable @@ -15,6 +16,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, dbFilename: String, logger: DatabaseLogger ) { + let rc = sqlite3_initialize(); + if (rc != 0) { + fatalError("Call to sqlite3_initialize() failed with \(rc)") + } + let factory = sqlite3DatabaseFactory(initialStatements: []) kotlinDatabase = PowerSyncDatabase( factory: factory, From e1aca1a2cfbcf31a2ff6a479f4c74e94b79fe275 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Oct 2025 15:03:20 +0100 Subject: [PATCH 06/11] Explicitly pass threadsafe 2 --- Package.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index ce28be9..f502a95 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,22 @@ var conditionalDependencies: [Package.Dependency] = [ .package( url: "https://github.com/sbooth/CSQLite.git", from: "3.50.4", - traits: ["ENABLE_SESSION"] + traits: [ + .defaults, + // CSQLite uses THREADSAFE=0 by default, which breaks PowerSync because we're using SQLite on + // multiple threads (it can lead to race conditions when closing connecting sharing resources + // like shared memory, causing crashes). + // THREADSAFE=2 overrides the default, and is safe to use as long as a single SQLite connection + // is not shared between threads. + // TODO: Technically, we should not use .defaults because there's a logical conflict between + // the threadsafe options. Instead, we should spell out all defaults again and remove that + // thread-safety option. + // However, despite the docs explicitly saying something else, it looks like there's no way to + // disable default traits anyway (XCode compiles sqlite3.c with the default option even without + // .defaults being included here). + "THREADSAFE_2", + "ENABLE_SESSION" + ] ) ] var conditionalTargets: [Target] = [] From 691395859cd877c551f03d161dab6360a5ee4360 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 28 Oct 2025 15:18:19 +0100 Subject: [PATCH 07/11] typo --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f502a95..86b91d6 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ var conditionalDependencies: [Package.Dependency] = [ traits: [ .defaults, // CSQLite uses THREADSAFE=0 by default, which breaks PowerSync because we're using SQLite on - // multiple threads (it can lead to race conditions when closing connecting sharing resources + // multiple threads (it can lead to race conditions when closing connections sharing resources // like shared memory, causing crashes). // THREADSAFE=2 overrides the default, and is safe to use as long as a single SQLite connection // is not shared between threads. From 3bc7cf5040447bcd13652db6732141f1990e7956 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 29 Oct 2025 14:10:49 +0100 Subject: [PATCH 08/11] 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 09/11] 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 10/11] 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 11/11] 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 } }