diff --git a/CHANGELOG.md b/CHANGELOG.md index 062cc31..eb71457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.6.0 + +* Update core extension to 0.4.6 ([changelog](https://github.com/powersync-ja/powersync-sqlite-core/releases/tag/v0.4.6)) +* Add `getCrudTransactions()`, returning an async sequence of transactions. +* Compatibility with Swift 6.2 and XCode 26. + ## 1.5.1 * Update core extension to 0.4.5 ([changelog](https://github.com/powersync-ja/powersync-sqlite-core/releases/tag/v0.4.5)) diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 547bdd5..2aa1e82 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "33297127250b66812faa920958a24bae46bf9e9d1c38ea6b84ca413efaf16afd", + "originHash" : "2d885a1b46f17f9239b7876e3889168a6de98024718f2d7af03aede290c8a86a", "pins" : [ { "identity" : "anycodable", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "3396dd7eb9d4264b19e3d95bfe0d77347826f4c2", - "version" : "0.4.4" + "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", + "version" : "0.4.6" } }, { diff --git a/Demo/PowerSyncExample/PowerSync/FtsSetup.swift b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift index 4d23672..40c195f 100644 --- a/Demo/PowerSyncExample/PowerSync/FtsSetup.swift +++ b/Demo/PowerSyncExample/PowerSync/FtsSetup.swift @@ -159,10 +159,11 @@ func configureFts(db: PowerSyncDatabaseProtocol, schema: Schema) async throws { // --- Execute all generated SQL statements --- if !allSqlStatements.isEmpty { + let resultingStatements: [String] = allSqlStatements do { print("[FTS] Executing \(allSqlStatements.count) SQL statements in a transaction...") _ = try await db.writeTransaction { transaction in - for sql in allSqlStatements { + for sql in resultingStatements { print("[FTS] Executing SQL:\n\(sql)") _ = try transaction.execute(sql: sql, parameters: []) } diff --git a/Package.resolved b/Package.resolved index 8926049..a62a2d5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "00776db5157c8648671b00e6673603144fafbfeb", - "version" : "0.4.5" + "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", + "version" : "0.4.6" } } ], diff --git a/Package.swift b/Package.swift index aac1063..bd8db2e 100644 --- a/Package.swift +++ b/Package.swift @@ -32,8 +32,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.5.1/PowersyncKotlinRelease.zip", - checksum: "3a2de1863d2844d49cebf4428d0ab49956ba384dcab9f3cc2ddbc7836013c434" + url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.6.0/PowersyncKotlinRelease.zip", + checksum: "4f70331c11e30625eecf4ebcebe7b562e2e0165774890d2a43480ebc3a9081cc" )) } @@ -45,7 +45,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.5" + exact: "0.4.6" )) } diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 83cdf6c..10bbd97 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -48,10 +48,9 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, connector: PowerSyncBackendConnectorProtocol, options: ConnectOptions? ) async throws { - let connectorAdapter = PowerSyncBackendConnectorAdapter( - swiftBackendConnector: connector, - db: self - ) + let connectorAdapter = swiftBackendConnectorToPowerSyncConnector(connector: SwiftBackendConnectorBridge( + swiftBackendConnector: connector, db: self + )) let resolvedOptions = options ?? ConnectOptions() try await kotlinDatabase.connect( @@ -75,14 +74,9 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, batch: base ) } - - func getNextCrudTransaction() async throws -> CrudTransaction? { - guard let base = try await kotlinDatabase.getNextCrudTransaction() else { - return nil - } - return try KotlinCrudTransaction( - transaction: base - ) + + func getCrudTransactions() -> any CrudTransactions { + return KotlinCrudTransactions(db: kotlinDatabase) } func getPowerSyncVersion() async throws -> String { diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift index f85fce1..9c256a4 100644 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ b/Sources/PowerSync/Kotlin/KotlinTypes.swift @@ -1,5 +1,6 @@ import PowerSyncKotlin +typealias KotlinSwiftBackendConnector = PowerSyncKotlin.SwiftBackendConnector typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase diff --git a/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift index 6507610..02b894b 100644 --- a/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift +++ b/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift @@ -1,9 +1,7 @@ import OSLog +import PowerSyncKotlin -final class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector, - // We need to declare this since we declared KotlinPowerSyncBackendConnector as @unchecked Sendable - @unchecked Sendable -{ +final class SwiftBackendConnectorBridge: KotlinSwiftBackendConnector, Sendable { let swiftBackendConnector: PowerSyncBackendConnectorProtocol let db: any PowerSyncDatabaseProtocol let logTag = "PowerSyncBackendConnector" @@ -15,31 +13,25 @@ final class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector, self.swiftBackendConnector = swiftBackendConnector self.db = db } - - override func __fetchCredentials() async throws -> KotlinPowerSyncCredentials? { + + func __fetchCredentials() async throws -> PowerSyncResult { do { let result = try await swiftBackendConnector.fetchCredentials() - return result?.kotlinCredentials + return PowerSyncResult.Success(value: result?.kotlinCredentials) } catch { db.logger.error("Error while fetching credentials", tag: logTag) - /// We can't use throwKotlinPowerSyncError here since the Kotlin connector - /// runs this in a Job - this seems to break the SKIEE error propagation. - /// returning nil here should still cause a retry - return nil + return PowerSyncResult.Failure(exception: error.toPowerSyncError()) } } - - override func __uploadData(database _: KotlinPowerSyncDatabase) async throws { + + func __uploadData() async throws -> PowerSyncResult { do { // Pass the Swift DB protocal to the connector - return try await swiftBackendConnector.uploadData(database: db) + try await swiftBackendConnector.uploadData(database: self.db) + return PowerSyncResult.Success(value: nil) } catch { db.logger.error("Error while uploading data: \(error)", tag: logTag) - // Relay the error to the Kotlin SDK - try throwKotlinPowerSyncError( - message: "Connector errored while uploading data: \(error.localizedDescription)", - cause: error.localizedDescription - ) + return PowerSyncResult.Failure(exception: error.toPowerSyncError()) } } } diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudTransactions.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudTransactions.swift new file mode 100644 index 0000000..866e76e --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudTransactions.swift @@ -0,0 +1,39 @@ +import PowerSyncKotlin + +struct KotlinCrudTransactions: CrudTransactions { + typealias Element = KotlinCrudTransaction + + private let db: KotlinPowerSyncDatabase + + init(db: KotlinPowerSyncDatabase) { + self.db = db + } + + public func makeAsyncIterator() -> CrudTransactionIterator { + let kotlinIterator = errorHandledCrudTransactions(db: self.db).makeAsyncIterator() + return CrudTransactionIterator(inner: kotlinIterator) + } + + struct CrudTransactionIterator: CrudTransactionsIterator { + private var inner: PowerSyncKotlin.SkieSwiftFlowIterator + + internal init(inner: PowerSyncKotlin.SkieSwiftFlowIterator) { + self.inner = inner + } + + public mutating func next() async throws -> KotlinCrudTransaction? { + if let innerTx = await self.inner.next() { + if let success = innerTx as? PowerSyncResult.Success { + let tx = success.value as! PowerSyncKotlin.CrudTransaction + return try KotlinCrudTransaction(transaction: tx) + } else if let failure = innerTx as? PowerSyncResult.Failure { + try throwPowerSyncException(exception: failure.exception) + } + + fatalError("unreachable") + } else { + return nil + } + } + } +} diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index 500dc10..cd112a5 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -188,19 +188,24 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable { /// data by transaction. One batch may contain data from multiple transactions, /// and a single transaction may be split over multiple batches. func getCrudBatch(limit: Int32) async throws -> CrudBatch? - - /// Get the next recorded transaction to upload. + + /// Obtains an async iterator of completed transactions with local writes against the database. /// - /// Returns nil if there is no data to upload. + /// This is typically used from the ``PowerSyncBackendConnectorProtocol/uploadData(database:)`` callback. + /// Each entry emitted by teh returned flow is a full transaction containing all local writes made while that transaction was + /// active. /// - /// Use this from the `PowerSyncBackendConnector.uploadData` callback. + /// Unlike ``getNextCrudTransaction()``, which always returns the oldest transaction that hasn't been + /// ``CrudTransaction/complete()``d yet, this iterator can be used to upload multiple transactions. + /// Calling ``CrudTransaction/complete()`` will mark that and all prior transactions returned by this iterator as + /// completed. /// - /// Once the data have been successfully uploaded, call `CrudTransaction.complete` before - /// requesting the next transaction. + /// This can be used to upload multiple transactions in a single batch, e.g. with /// - /// Unlike `getCrudBatch`, this only returns data from a single transaction at a time. - /// All data for the transaction is loaded into memory. - func getNextCrudTransaction() async throws -> CrudTransaction? + /// ```Swift + /// + /// ``` + func getCrudTransactions() -> any CrudTransactions /// Convenience method to get the current version of PowerSync. func getPowerSyncVersion() async throws -> String @@ -226,6 +231,25 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable { } public extension PowerSyncDatabaseProtocol { + /// Get the next recorded transaction to upload. + /// + /// Returns nil if there is no data to upload. + /// + /// Use this from the `PowerSyncBackendConnector.uploadData` callback. + /// + /// Once the data have been successfully uploaded, call `CrudTransaction.complete` before + /// requesting the next transaction. + /// + /// Unlike `getCrudBatch`, this only returns data from a single transaction at a time. + /// All data for the transaction is loaded into memory. + func getNextCrudTransaction() async throws -> CrudTransaction? { + for try await transaction in self.getCrudTransactions() { + return transaction + } + + return nil + } + /// /// The connection is automatically re-opened if it fails for any reason. /// diff --git a/Sources/PowerSync/Protocol/db/CrudTransaction.swift b/Sources/PowerSync/Protocol/db/CrudTransaction.swift index ace3ea4..8a7dacf 100644 --- a/Sources/PowerSync/Protocol/db/CrudTransaction.swift +++ b/Sources/PowerSync/Protocol/db/CrudTransaction.swift @@ -24,3 +24,11 @@ public extension CrudTransaction { ) } } + +/// A sequence of crud transactions in a PowerSync database. +/// +/// For details, see ``PowerSyncDatabaseProtocol/getCrudTransactions()``. +public protocol CrudTransactions: AsyncSequence where Element: CrudTransaction, AsyncIterator: CrudTransactionsIterator {} + +/// The iterator returned by ``CrudTransactions``. +public protocol CrudTransactionsIterator: AsyncIteratorProtocol where Element: CrudTransaction {} diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index 5b303d6..c315b38 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -199,4 +199,43 @@ final class CrudTests: XCTestCase { let finalValidationBatch = try await database.getCrudBatch(limit: 100) XCTAssertNil(finalValidationBatch) } + + func testCrudTransactions() async throws { + func insertInTransaction(size: Int) async throws { + try await database.writeTransaction { tx in + for _ in 0 ..< size { + try tx.execute( + sql: "INSERT INTO users (id, name, email) VALUES (uuid(), null, null)", + parameters: [] + ) + } + } + } + + // Before inserting any data, the iterator should be empty. + for try await _ in database.getCrudTransactions() { + XCTFail("Unexpected transaction") + } + + try await insertInTransaction(size: 5) + try await insertInTransaction(size: 10) + try await insertInTransaction(size: 15) + + var batch = [CrudEntry]() + var lastTx: CrudTransaction? = nil + for try await tx in database.getCrudTransactions() { + batch.append(contentsOf: tx.crud) + lastTx = tx + + if (batch.count >= 10) { + break + } + } + + XCTAssertEqual(batch.count, 15) + try await lastTx!.complete() + + let finalTx = try await database.getNextCrudTransaction() + XCTAssertEqual(finalTx!.crud.count, 15) + } } diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index ebecd44..0c3b48e 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -42,8 +42,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) """) } } @@ -85,8 +84,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: SELECT id, name, email FROM usersfail WHERE id = ? - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? """) } } @@ -131,8 +129,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: SELECT id, name, email FROM usersfail WHERE id = ? - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? """) } } @@ -197,8 +194,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: SELECT id, name, email FROM usersfail WHERE id = ? - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? """) } } @@ -281,8 +277,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: EXPLAIN SELECT name FROM usersfail ORDER BY id - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: EXPLAIN SELECT name FROM usersfail ORDER BY id """) } } @@ -371,8 +366,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) """) } } @@ -392,8 +386,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) """) } @@ -436,8 +429,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } catch { XCTAssertEqual(error.localizedDescription, """ - error while compiling: SELECT COUNT(*) FROM usersfail - no such table: usersfail + SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT COUNT(*) FROM usersfail """) } }