diff --git a/CHANGELOG.md b/CHANGELOG.md index e632074..79d31c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,120 @@ # Changelog +# 1.0.0 + +- Improved the stability of watched queries. Watched queries were previously susceptible to runtime crashes if an exception was thrown in the update stream. Errors are now gracefully handled. + +- Deprecated `PowerSyncCredentials` `userId` field. This value is not used by the PowerSync service. + +- Added `readLock` and `writeLock` APIs. These methods allow obtaining a SQLite connection context without starting a transaction. + +- Removed references to the PowerSync Kotlin SDK from all public API protocols. Dedicated Swift protocols are now defined. These protocols align better with Swift primitives. See the `BRAKING CHANGES` section for more details. Updated protocols include: + + - `ConnectionContext` - The context provided by `readLock` and `writeLock` + - `Transaction` - The context provided by `readTransaction` and `writeTransaction` + - `CrudBatch` - Response from `getCrudBatch` + - `CrudTransaction` Response from `getNextCrudTransaction` + - `CrudEntry` - Crud entries for `CrudBatch` and `CrudTransaction` + - `UpdateType` - Operation type for `CrudEntry`s + - `SqlCursor` - Cursor used to map SQLite results to typed result sets + - `JsonParam` - JSON parameters used to declare client parameters in the `connect` method + - `JsonValue` - Individual JSON field types for `JsonParam` + +- Database and transaction/lock level query `execute` methods now have `@discardableResult` annotation. + +- Query methods' `parameters` typing has been updated to `[Any?]` from `[Any]`. This makes passing `nil` or optional values to queries easier. + +- `AttachmentContext`, `AttachmentQueue`, `AttachmentService` and `SyncingService` are are now explicitly declared as `open` classes, allowing them to be subclassed outside the defining module. + +**BREAKING CHANGES**: + +- Completing CRUD transactions or CRUD batches, in the `PowerSyncBackendConnector` `uploadData` handler, now has a simpler invocation. + +```diff +- _ = try await transaction.complete.invoke(p1: nil) ++ try await transaction.complete() +``` + +- `index` based `SqlCursor` getters now throw if the query result column value is `nil`. This is now consistent with the behaviour of named column getter operations. New `getXxxxxOptional(index: index)` methods are available if the query result value could be `nil`. + +```diff +let results = try transaction.getAll( + sql: "SELECT * FROM my_table", + parameters: [id] + ) { cursor in +- cursor.getString(index: 0)! ++ cursor.getStringOptional(index: 0) ++ // OR ++ // try cursor.getString(index: 0) // if the value should be required + } +``` + +- `SqlCursor` getters now directly return Swift types. `getLong` has been replaced with `getInt64`. + +```diff +let results = try transaction.getAll( + sql: "SELECT * FROM my_table", + parameters: [id] + ) { cursor in +- cursor.getBoolean(index: 0)?.boolValue, ++ cursor.getBooleanOptional(index: 0), +- cursor.getLong(index: 0)?.int64Value, ++ cursor.getInt64Optional(index: 0) ++ // OR ++ // try cursor.getInt64(index: 0) // if the value should be required + } +``` + +- Client parameters now need to be specified with strictly typed `JsonValue` enums. + +```diff +try await database.connect( + connector: PowerSyncBackendConnector(), + params: [ +- "foo": "bar" ++ "foo": .string("bar") + ] +) +``` + +- `SyncStatus` values now use Swift primitives for status attributes. `lastSyncedAt` now is of `Date` type. + +```diff +- let lastTime: Date? = db.currentStatus.lastSyncedAt.map { +- Date(timeIntervalSince1970: TimeInterval($0.epochSeconds)) +- } ++ let time: Date? = db.currentStatus.lastSyncedAt +``` + +- `crudThrottleMs` and `retryDelayMs` in the `connect` method have been updated to `crudThrottle` and `retryDelay` which are now of type `TimeInterval`. Previously the parameters were specified in milliseconds, the `TimeInterval` typing now requires values to be specified in seconds. + +```diff +try await database.connect( + connector: PowerSyncBackendConnector(), +- crudThrottleMs: 1000, +- retryDelayMs: 5000, ++ crudThrottle: 1, ++ retryDelay: 5, + params: [ + "foo": .string("bar"), + ] + ) +``` + +- `throttleMs` in the watched query `WatchOptions` has been updated to `throttle` which is now of type `TimeInterval`. Previously the parameters were specified in milliseconds, the `TimeInterval` typing now requires values to be specified in seconds. + +```diff +let stream = try database.watch( + options: WatchOptions( + sql: "SELECT name FROM users ORDER BY id", +- throttleMs: 1000, ++ throttle: 1, + mapper: { cursor in + try cursor.getString(index: 0) + } + )) +``` + # 1.0.0-Beta.13 - Update `powersync-kotlin` dependency to version `1.0.0-BETA32`, which includes: diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d2cc323..4f14951 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -10,22 +10,13 @@ "version" : "0.6.7" } }, - { - "identity" : "powersync-kotlin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/powersync-ja/powersync-kotlin.git", - "state" : { - "revision" : "633a2924f7893f7ebeb064cbcd9c202937673633", - "version" : "1.0.0-BETA30.0" - } - }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "5041116d295e61d3c54f27117c02fd81071a1ab3", - "version" : "0.3.12" + "revision" : "3a7fcb3be83db5b450effa5916726b19828cbcb7", + "version" : "0.3.14" } }, { diff --git a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift index 9c3ef46..bb27290 100644 --- a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift +++ b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift @@ -101,19 +101,18 @@ class SupabaseConnector: PowerSyncBackendConnector { switch entry.op { case .put: - var data: [String: AnyCodable] = entry.opData?.mapValues { AnyCodable($0) } ?? [:] - data["id"] = AnyCodable(entry.id) + var data = entry.opData ?? [:] + data["id"] = entry.id try await table.upsert(data).execute() case .patch: guard let opData = entry.opData else { continue } - let encodableData = opData.mapValues { AnyCodable($0) } - try await table.update(encodableData).eq("id", value: entry.id).execute() + try await table.update(opData).eq("id", value: entry.id).execute() case .delete: try await table.delete().eq("id", value: entry.id).execute() } } - _ = try await transaction.complete.invoke(p1: nil) + try await transaction.complete() } catch { if let errorCode = PostgresFatalCodes.extractErrorCode(from: error), @@ -127,7 +126,7 @@ class SupabaseConnector: PowerSyncBackendConnector { /// elsewhere instead of discarding, and/or notify the user. print("Data upload error: \(error)") print("Discarding entry: \(lastEntry!)") - _ = try await transaction.complete.invoke(p1: nil) + try await transaction.complete() return } diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 1c0e693..0246f7f 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -127,9 +127,8 @@ class SystemManager { sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE list_id = ? AND photo_id IS NOT NULL", parameters: [id] ) { cursor in - // FIXME Transactions should allow throwing in the mapper and should use generics correctly - cursor.getString(index: 0) ?? "invalid" // :( - } as? [String] // :( + try cursor.getString(index: 0) + } _ = try transaction.execute( sql: "DELETE FROM \(LISTS_TABLE) WHERE id = ?", @@ -141,7 +140,7 @@ class SystemManager { parameters: [id] ) - return attachmentIDs ?? [] // :( + return attachmentIDs }) if let attachments { diff --git a/Package.resolved b/Package.resolved index 9dfc8b4..43ac3aa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "144d2110eaca2537f49f5e86e5a6c78acf502f94", - "version" : "1.0.0-BETA32.0" + "revision" : "ccd2e595195c59d570eb93a878ad6a5cfca72ada", + "version" : "1.0.1+SWIFT.0" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "5041116d295e61d3c54f27117c02fd81071a1ab3", - "version" : "0.3.12" + "revision" : "3a7fcb3be83db5b450effa5916726b19828cbcb7", + "version" : "0.3.14" } } ], diff --git a/Package.swift b/Package.swift index e5df44a..b8b5491 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( targets: ["PowerSync"]), ], dependencies: [ - .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA32.0"), + .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", "1.0.1+SWIFT.0"..<"1.1.0+SWIFT.0"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.14"..<"0.4.0") ], targets: [ diff --git a/Sources/PowerSync/Kotlin/DatabaseLogger.swift b/Sources/PowerSync/Kotlin/DatabaseLogger.swift index 21229d1..141bf2d 100644 --- a/Sources/PowerSync/Kotlin/DatabaseLogger.swift +++ b/Sources/PowerSync/Kotlin/DatabaseLogger.swift @@ -44,7 +44,7 @@ private class KermitLogWriterAdapter: Kermit_coreLogWriter { /// /// This class bridges Swift log writers with the Kotlin logging system and supports /// runtime configuration of severity levels and writer lists. -internal class DatabaseLogger: LoggerProtocol { +class DatabaseLogger: LoggerProtocol { /// The underlying Kermit logger instance provided by the PowerSyncKotlin SDK. public let kLogger = PowerSyncKotlin.generateLogger(logger: nil) public let logger: any LoggerProtocol diff --git a/Sources/PowerSync/Kotlin/KotlinAdapter.swift b/Sources/PowerSync/Kotlin/KotlinAdapter.swift index 0418709..4d5f0f6 100644 --- a/Sources/PowerSync/Kotlin/KotlinAdapter.swift +++ b/Sources/PowerSync/Kotlin/KotlinAdapter.swift @@ -1,7 +1,6 @@ import PowerSyncKotlin - -internal struct KotlinAdapter { +enum KotlinAdapter { struct Index { static func toKotlin(_ index: IndexProtocol) -> PowerSyncKotlin.Index { PowerSyncKotlin.Index( @@ -26,7 +25,7 @@ internal struct KotlinAdapter { static func toKotlin(_ table: TableProtocol) -> PowerSyncKotlin.Table { PowerSyncKotlin.Table( name: table.name, - columns: table.columns.map {Column.toKotlin($0)}, + columns: table.columns.map { Column.toKotlin($0) }, indexes: table.indexes.map { Index.toKotlin($0) }, localOnly: table.localOnly, insertOnly: table.insertOnly, diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 9eeae98..bdc5893 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -5,8 +5,8 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { let logger: any LoggerProtocol private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase - - var currentStatus: SyncStatus { kotlinDatabase.currentStatus } + private let encoder = JSONEncoder() + let currentStatus: SyncStatus init( schema: Schema, @@ -21,6 +21,9 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { logger: logger.kLogger ) self.logger = logger + currentStatus = KotlinSyncStatus( + baseStatus: kotlinDatabase.currentStatus + ) } func waitForFirstSync() async throws { @@ -28,38 +31,52 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } func updateSchema(schema: any SchemaProtocol) async throws { - try await kotlinDatabase.updateSchema(schema: KotlinAdapter.Schema.toKotlin(schema)) + try await kotlinDatabase.updateSchema( + schema: KotlinAdapter.Schema.toKotlin(schema) + ) } func waitForFirstSync(priority: Int32) async throws { - try await kotlinDatabase.waitForFirstSync(priority: priority) + try await kotlinDatabase.waitForFirstSync( + priority: priority + ) } func connect( connector: PowerSyncBackendConnector, - crudThrottleMs: Int64 = 1000, - retryDelayMs: Int64 = 5000, - params: [String: JsonParam?] = [:] + options: ConnectOptions? ) async throws { let connectorAdapter = PowerSyncBackendConnectorAdapter( swiftBackendConnector: connector, db: self ) + let resolvedOptions = options ?? ConnectOptions() + try await kotlinDatabase.connect( connector: connectorAdapter, - crudThrottleMs: crudThrottleMs, - retryDelayMs: retryDelayMs, - params: params + crudThrottleMs: Int64(resolvedOptions.crudThrottle * 1000), + retryDelayMs: Int64(resolvedOptions.retryDelay * 1000), + params: resolvedOptions.params.mapValues { $0.toKotlinMap() } ) } func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? { - try await kotlinDatabase.getCrudBatch(limit: limit) + guard let base = try await kotlinDatabase.getCrudBatch(limit: limit) else { + return nil + } + return try KotlinCrudBatch( + batch: base + ) } func getNextCrudTransaction() async throws -> CrudTransaction? { - try await kotlinDatabase.getNextCrudTransaction() + guard let base = try await kotlinDatabase.getNextCrudTransaction() else { + return nil + } + return try KotlinCrudTransaction( + transaction: base + ) } func getPowerSyncVersion() async throws -> String { @@ -71,117 +88,131 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } func disconnectAndClear(clearLocal: Bool = true) async throws { - try await kotlinDatabase.disconnectAndClear(clearLocal: clearLocal) + try await kotlinDatabase.disconnectAndClear( + clearLocal: clearLocal + ) } - func execute(sql: String, parameters: [Any]?) async throws -> Int64 { - try Int64(truncating: await kotlinDatabase.execute(sql: sql, parameters: parameters)) + @discardableResult + func execute(sql: String, parameters: [Any?]?) async throws -> Int64 { + try await writeTransaction { ctx in + try ctx.execute( + sql: sql, + parameters: parameters + ) + } } func get( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) async throws -> RowType { - try safeCast(await kotlinDatabase.get( - sql: sql, - parameters: parameters, - mapper: mapper - ), to: RowType.self) + try await readLock { ctx in + try ctx.get( + sql: sql, + parameters: parameters, + mapper: mapper + ) + } } func get( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType { - return try await wrapQueryCursorTyped( - mapper: mapper, - executor: { wrappedMapper in - try await self.kotlinDatabase.get( - sql: sql, - parameters: parameters, - mapper: wrappedMapper - ) - }, - resultType: RowType.self - ) + try await readLock { ctx in + try ctx.get( + sql: sql, + parameters: parameters, + mapper: mapper + ) + } } func getAll( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) async throws -> [RowType] { - try safeCast(await kotlinDatabase.getAll( - sql: sql, - parameters: parameters, - mapper: mapper - ), to: [RowType].self) + try await readLock { ctx in + try ctx.getAll( + sql: sql, + parameters: parameters, + mapper: mapper + ) + } } func getAll( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> [RowType] { - try await wrapQueryCursorTyped( - mapper: mapper, - executor: { wrappedMapper in - try await self.kotlinDatabase.getAll( - sql: sql, - parameters: parameters, - mapper: wrappedMapper - ) - }, - resultType: [RowType].self - ) + try await readLock { ctx in + try ctx.getAll( + sql: sql, + parameters: parameters, + mapper: mapper + ) + } } func getOptional( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) async throws -> RowType? { - try safeCast(await kotlinDatabase.getOptional( - sql: sql, - parameters: parameters, - mapper: mapper - ), to: RowType?.self) + try await readLock { ctx in + try ctx.getOptional( + sql: sql, + parameters: parameters, + mapper: mapper + ) + } } func getOptional( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType? { - try await wrapQueryCursorTyped( - mapper: mapper, - executor: { wrappedMapper in - try await self.kotlinDatabase.getOptional( - sql: sql, - parameters: parameters, - mapper: wrappedMapper - ) - }, - resultType: RowType?.self - ) + try await readLock { ctx in + try ctx.getOptional( + sql: sql, + parameters: parameters, + mapper: mapper + ) + } } func watch( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType - ) throws -> AsyncThrowingStream<[RowType], Error> { - try watch(options: WatchOptions(sql: sql, parameters: parameters, mapper: mapper)) + ) throws -> AsyncThrowingStream<[RowType], any Error> { + try watch( + options: WatchOptions( + sql: sql, + parameters: parameters, + mapper: mapper + ) + ) } func watch( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType - ) throws -> AsyncThrowingStream<[RowType], Error> { - try watch(options: WatchOptions(sql: sql, parameters: parameters, mapper: mapper)) + ) throws -> AsyncThrowingStream<[RowType], any Error> { + try watch( + options: WatchOptions( + sql: sql, + parameters: parameters, + mapper: mapper + ) + ) } func watch( @@ -191,43 +222,30 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { // Create an outer task to monitor cancellation let task = Task { do { - var mapperError: Error? - // HACK! - // SKIEE doesn't support custom exceptions in Flows - // Exceptions which occur in the Flow itself cause runtime crashes. - // The most probable crash would be the internal EXPLAIN statement. - // This attempts to EXPLAIN the query before passing it to Kotlin - // We could introduce an onChange API in Kotlin which we use to implement watches here. - // This would prevent most issues with exceptions. - // EXPLAIN statement to prevent crashes in SKIEE - _ = try await self.kotlinDatabase.getAll( - sql: "EXPLAIN \(options.sql)", - parameters: options.parameters, - mapper: { _ in "" } + let watchedTables = try await self.getQuerySourceTables( + sql: options.sql, + parameters: options.parameters ) // Watching for changes in the database - for try await values in try self.kotlinDatabase.watch( - sql: options.sql, - parameters: options.parameters, - throttleMs: options.throttleMs, - mapper: { cursor in - do { - return try options.mapper(cursor) - } catch { - mapperError = error - return () - } - } + for try await _ in try self.kotlinDatabase.onChange( + tables: Set(watchedTables), + throttleMs: Int64(options.throttle * 1000), + triggerImmediately: true // Allows emitting the first result even if there aren't changes ) { // Check if the outer task is cancelled - try Task.checkCancellation() // This checks if the calling task was cancelled - - if mapperError != nil { - throw mapperError! - } - - try continuation.yield(safeCast(values, to: [RowType].self)) + try Task.checkCancellation() + + try continuation.yield( + safeCast( + await self.getAll( + sql: options.sql, + parameters: options.parameters, + mapper: options.mapper + ), + to: [RowType].self + ) + ) } continuation.finish() @@ -247,15 +265,148 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } } - func writeTransaction(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R { - return try safeCast(await kotlinDatabase.writeTransaction(callback: TransactionCallback(callback: callback)), to: R.self) + func writeLock( + callback: @escaping (any ConnectionContext) throws -> R + ) async throws -> R { + return try await wrapPowerSyncException { + try safeCast( + await kotlinDatabase.writeLock( + callback: LockCallback( + callback: callback + ) + ), + to: R.self + ) + } } - func readTransaction(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R { - return try safeCast(await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)), to: R.self) + func writeTransaction( + callback: @escaping (any Transaction) throws -> R + ) async throws -> R { + return try await wrapPowerSyncException { + try safeCast( + await kotlinDatabase.writeTransaction( + callback: TransactionCallback( + callback: callback + ) + ), + to: R.self + ) + } + } + + func readLock( + callback: @escaping (any ConnectionContext) throws -> R + ) + async throws -> R + { + return try await wrapPowerSyncException { + try safeCast( + await kotlinDatabase.readLock( + callback: LockCallback( + callback: callback + ) + ), + to: R.self + ) + } + } + + func readTransaction( + callback: @escaping (any Transaction) throws -> R + ) async throws -> R { + return try await wrapPowerSyncException { + try safeCast( + await kotlinDatabase.readTransaction( + callback: TransactionCallback( + callback: callback + ) + ), + to: R.self + ) + } } func close() async throws { try await kotlinDatabase.close() } + + /// Tries to convert Kotlin PowerSyncExceptions to Swift Exceptions + private func wrapPowerSyncException( + handler: () async throws -> R) + async throws -> R + { + do { + return try await handler() + } catch { + // Try and parse errors back from the Kotlin side + if let mapperError = SqlCursorError.fromDescription(error.localizedDescription) { + throw mapperError + } + + throw PowerSyncError.operationFailed( + underlyingError: error + ) + } + } + + private func getQuerySourceTables( + sql: String, + parameters: [Any?] + ) async throws -> Set { + let rows = try await getAll( + sql: "EXPLAIN \(sql)", + parameters: parameters, + mapper: { cursor in + try ExplainQueryResult( + addr: cursor.getString(index: 0), + opcode: cursor.getString(index: 1), + p1: cursor.getInt64(index: 2), + p2: cursor.getInt64(index: 3), + p3: cursor.getInt64(index: 4) + ) + } + ) + + let rootPages = rows.compactMap { r in + if (r.opcode == "OpenRead" || r.opcode == "OpenWrite") && + r.p3 == 0 && r.p2 != 0 + { + return r.p2 + } + return nil + } + + do { + let pagesData = try encoder.encode(rootPages) + + guard let pagesString = String(data: pagesData, encoding: .utf8) else { + throw PowerSyncError.operationFailed( + message: "Failed to convert pages data to UTF-8 string" + ) + } + + let tableRows = try await getAll( + sql: "SELECT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))", + parameters: [ + pagesString + ] + ) { try $0.getString(index: 0) } + + return Set(tableRows) + } catch { + throw PowerSyncError.operationFailed( + message: "Could not determine watched query tables", + underlyingError: error + ) + } + } +} + +private struct ExplainQueryResult { + let addr: String + let opcode: String + let p1: Int64 + let p2: Int64 + let p3: Int64 } diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift index 23c361f..18edcbd 100644 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ b/Sources/PowerSync/Kotlin/KotlinTypes.swift @@ -1,13 +1,5 @@ import PowerSyncKotlin typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector -public typealias CrudEntry = PowerSyncKotlin.CrudEntry -public typealias CrudBatch = PowerSyncKotlin.CrudBatch -public typealias SyncStatus = PowerSyncKotlin.SyncStatus -public typealias SqlCursor = PowerSyncKotlin.SqlCursor -public typealias JsonParam = PowerSyncKotlin.JsonParam -public typealias CrudTransaction = PowerSyncKotlin.CrudTransaction typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase -public typealias Transaction = PowerSyncKotlin.PowerSyncTransaction -public typealias ConnectionContext = PowerSyncKotlin.ConnectionContext diff --git a/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift similarity index 60% rename from Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift rename to Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift index b41c2b3..8e8da4c 100644 --- a/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift +++ b/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift @@ -1,10 +1,10 @@ import OSLog -internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector { +class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector { let swiftBackendConnector: PowerSyncBackendConnector let db: any PowerSyncDatabaseProtocol let logTag = "PowerSyncBackendConnector" - + init( swiftBackendConnector: PowerSyncBackendConnector, db: any PowerSyncDatabaseProtocol @@ -19,6 +19,9 @@ internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector return 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 } } @@ -26,9 +29,14 @@ internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector override func __uploadData(database: KotlinPowerSyncDatabase) async throws { do { // Pass the Swift DB protocal to the connector - return try await swiftBackendConnector.uploadData(database: db) + return try await swiftBackendConnector.uploadData(database: db) } 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 + ) } } } diff --git a/Sources/PowerSync/Kotlin/SafeCastError.swift b/Sources/PowerSync/Kotlin/SafeCastError.swift index ccf736d..35ef8cb 100644 --- a/Sources/PowerSync/Kotlin/SafeCastError.swift +++ b/Sources/PowerSync/Kotlin/SafeCastError.swift @@ -1,3 +1,5 @@ +import Foundation + enum SafeCastError: Error, CustomStringConvertible { case typeMismatch(expected: Any.Type, actual: Any?) @@ -10,7 +12,16 @@ enum SafeCastError: Error, CustomStringConvertible { } } -internal func safeCast(_ value: Any?, to type: T.Type) throws -> T { +func safeCast(_ value: Any?, to type: T.Type) throws -> T { + // Special handling for nil when T is an optional type + if value == nil || value is NSNull { + // Check if T is an optional type that can accept nil + let nilValue: Any? = nil + if let nilAsT = nilValue as? T { + return nilAsT + } + } + if let castedValue = value as? T { return castedValue } else { diff --git a/Sources/PowerSync/Kotlin/SqlCursor.swift b/Sources/PowerSync/Kotlin/SqlCursor.swift deleted file mode 100644 index 538142b..0000000 --- a/Sources/PowerSync/Kotlin/SqlCursor.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import PowerSyncKotlin - -extension SqlCursor { - private func getColumnIndex(name: String) throws -> Int32 { - guard let columnIndex = columnNames[name]?.int32Value else { - throw SqlCursorError.columnNotFound(name) - } - return columnIndex - } - - private func getValue(name: String, getter: (Int32) throws -> T?) throws -> T { - let columnIndex = try getColumnIndex(name: name) - guard let value = try getter(columnIndex) else { - throw SqlCursorError.nullValueFound(name) - } - return value - } - - private func getOptionalValue(name: String, getter: (String) throws -> T?) throws -> T? { - _ = try getColumnIndex(name: name) - return try getter(name) - } - - public func getBoolean(name: String) throws -> Bool { - try getValue(name: name) { getBoolean(index: $0)?.boolValue } - } - - public func getDouble(name: String) throws -> Double { - try getValue(name: name) { getDouble(index: $0)?.doubleValue } - } - - public func getLong(name: String) throws -> Int { - try getValue(name: name) { getLong(index: $0)?.intValue } - } - - public func getString(name: String) throws -> String { - try getValue(name: name) { getString(index: $0) } - } - - public func getBooleanOptional(name: String) throws -> Bool? { - try getOptionalValue(name: name) { try getBooleanOptional(name: $0)?.boolValue } - } - - public func getDoubleOptional(name: String) throws -> Double? { - try getOptionalValue(name: name) { try getDoubleOptional(name: $0)?.doubleValue } - } - - public func getLongOptional(name: String) throws -> Int? { - try getOptionalValue(name: name) { try getLongOptional(name: $0)?.intValue } - } - - public func getStringOptional(name: String) throws -> String? { - try getOptionalValue(name: name) { try PowerSyncKotlin.SqlCursorKt.getStringOptional(self, name: $0) } - } -} - -enum SqlCursorError: Error { - case nullValue(message: String) - - static func columnNotFound(_ name: String) -> SqlCursorError { - .nullValue(message: "Column '\(name)' not found") - } - - static func nullValueFound(_ name: String) -> SqlCursorError { - .nullValue(message: "Null value found for column \(name)") - } -} diff --git a/Sources/PowerSync/Kotlin/TransactionCallback.swift b/Sources/PowerSync/Kotlin/TransactionCallback.swift index 918c7a7..78f460d 100644 --- a/Sources/PowerSync/Kotlin/TransactionCallback.swift +++ b/Sources/PowerSync/Kotlin/TransactionCallback.swift @@ -1,9 +1,10 @@ import PowerSyncKotlin -class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { - let callback: (PowerSyncTransaction) throws -> R +/// Internal Wrapper for Kotlin lock context lambdas +class LockCallback: PowerSyncKotlin.ThrowableLockCallback { + let callback: (ConnectionContext) throws -> R - init(callback: @escaping (PowerSyncTransaction) throws -> R) { + init(callback: @escaping (ConnectionContext) throws -> R) { self.callback = callback } @@ -21,15 +22,46 @@ class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { // from a "core" package in Kotlin that provides better control over exception handling // and other functionality—without modifying the public `PowerSyncDatabase` API to include // Swift-specific logic. - func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any { + func execute(context: PowerSyncKotlin.ConnectionContext) throws -> Any { do { - return try callback(transaction) + return try callback( + KotlinConnectionContext( + ctx: context + ) + ) } catch { return PowerSyncKotlin.PowerSyncException( message: error.localizedDescription, - cause: PowerSyncKotlin.KotlinThrowable(message: error.localizedDescription) + cause: PowerSyncKotlin.KotlinThrowable( + message: error.localizedDescription + ) ) } } } +/// Internal Wrapper for Kotlin transaction context lambdas +class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { + let callback: (Transaction) throws -> R + + init(callback: @escaping (Transaction) throws -> R) { + self.callback = callback + } + + func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any { + do { + return try callback( + KotlinTransactionContext( + ctx: transaction + ) + ) + } catch { + return PowerSyncKotlin.PowerSyncException( + message: error.localizedDescription, + cause: PowerSyncKotlin.KotlinThrowable( + message: error.localizedDescription + ) + ) + } + } +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift new file mode 100644 index 0000000..dbfca2d --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift @@ -0,0 +1,96 @@ +import Foundation +import PowerSyncKotlin + +/// Extension of the `ConnectionContext` protocol which allows mixin of common logic required for Kotlin adapters +protocol KotlinConnectionContextProtocol: ConnectionContext { + /// Implementations should provide access to a Kotlin context. + /// The protocol extension will use this to provide shared implementation. + var ctx: PowerSyncKotlin.ConnectionContext { get } +} + +/// Implements most of `ConnectionContext` using the `ctx` provided. +extension KotlinConnectionContextProtocol { + func execute(sql: String, parameters: [Any?]?) throws -> Int64 { + try ctx.execute( + sql: sql, + parameters: mapParameters(parameters) + ) + } + + func getOptional( + sql: String, + parameters: [Any?]?, + mapper: @escaping (any SqlCursor) throws -> RowType + ) throws -> RowType? { + return try wrapQueryCursorTyped( + mapper: mapper, + executor: { wrappedMapper in + try self.ctx.getOptional( + sql: sql, + parameters: mapParameters(parameters), + mapper: wrappedMapper + ) + }, + resultType: RowType?.self + ) + } + + func getAll( + sql: String, + parameters: [Any?]?, + mapper: @escaping (any SqlCursor) throws -> RowType + ) throws -> [RowType] { + return try wrapQueryCursorTyped( + mapper: mapper, + executor: { wrappedMapper in + try self.ctx.getAll( + sql: sql, + parameters: mapParameters(parameters), + mapper: wrappedMapper + ) + }, + resultType: [RowType].self + ) + } + + func get( + sql: String, + parameters: [Any?]?, + mapper: @escaping (any SqlCursor) throws -> RowType + ) throws -> RowType { + return try wrapQueryCursorTyped( + mapper: mapper, + executor: { wrappedMapper in + try self.ctx.get( + sql: sql, + parameters: mapParameters(parameters), + mapper: wrappedMapper + ) + }, + resultType: RowType.self + ) + } +} + +class KotlinConnectionContext: KotlinConnectionContextProtocol { + let ctx: PowerSyncKotlin.ConnectionContext + + init(ctx: PowerSyncKotlin.ConnectionContext) { + self.ctx = ctx + } +} + +class KotlinTransactionContext: Transaction, KotlinConnectionContextProtocol { + let ctx: PowerSyncKotlin.ConnectionContext + + init(ctx: PowerSyncKotlin.PowerSyncTransaction) { + self.ctx = ctx + } +} + +// Allows nil values to be passed to the Kotlin [Any] params +func mapParameters(_ parameters: [Any?]?) -> [Any] { + parameters?.map { item in + item ?? NSNull() + } ?? [] +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift new file mode 100644 index 0000000..f94c29c --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift @@ -0,0 +1,27 @@ +import PowerSyncKotlin + +/// Implements `CrudBatch` using the Kotlin SDK +struct KotlinCrudBatch: CrudBatch { + let batch: PowerSyncKotlin.CrudBatch + let crud: [CrudEntry] + + init( + batch: PowerSyncKotlin.CrudBatch) + throws + { + self.batch = batch + self.crud = try batch.crud.map { try KotlinCrudEntry( + entry: $0 + ) } + } + + var hasMore: Bool { + batch.hasMore + } + + func complete( + writeCheckpoint: String? + ) async throws { + _ = try await batch.complete.invoke(p1: writeCheckpoint) + } +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift new file mode 100644 index 0000000..53bbbf5 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift @@ -0,0 +1,36 @@ +import PowerSyncKotlin + +/// Implements `CrudEntry` using the KotlinSDK +struct KotlinCrudEntry : CrudEntry { + let entry: PowerSyncKotlin.CrudEntry + let op: UpdateType + + init ( + entry: PowerSyncKotlin.CrudEntry + ) throws { + self.entry = entry + self.op = try UpdateType.fromString(entry.op.name) + } + + var id: String { + entry.id + } + + var clientId: Int64 { + Int64(entry.clientId) + } + + var table: String { + entry.table + } + + var transactionId: Int64? { + entry.transactionId?.int64Value + } + + var opData: [String : String?]? { + /// Kotlin represents this as Map, but this is + /// converted to [String: Any] by SKIEE + entry.opData?.mapValues { $0 as? String } + } +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift new file mode 100644 index 0000000..fe4e2f5 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift @@ -0,0 +1,22 @@ +import PowerSyncKotlin + +/// Implements `CrudTransaction` using the Kotlin SDK +struct KotlinCrudTransaction: CrudTransaction { + let transaction: PowerSyncKotlin.CrudTransaction + let crud: [CrudEntry] + + init(transaction: PowerSyncKotlin.CrudTransaction) throws { + self.transaction = transaction + self.crud = try transaction.crud.map { try KotlinCrudEntry( + entry: $0 + ) } + } + + var transactionId: Int64? { + transaction.transactionId?.int64Value + } + + func complete(writeCheckpoint: String?) async throws { + _ = try await transaction.complete.invoke(p1: writeCheckpoint) + } +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift b/Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift new file mode 100644 index 0000000..d7ce0a8 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift @@ -0,0 +1,29 @@ +import PowerSyncKotlin + +/// Converts a Swift `JsonValue` to one accepted by the Kotlin SDK +extension JsonValue { + func toKotlinMap() -> PowerSyncKotlin.JsonParam { + switch self { + case .string(let value): + return PowerSyncKotlin.JsonParam.String(value: value) + case .int(let value): + return PowerSyncKotlin.JsonParam.Number(value: value) + case .double(let value): + return PowerSyncKotlin.JsonParam.Number(value: value) + case .bool(let value): + return PowerSyncKotlin.JsonParam.Boolean(value: value) + case .null: + return PowerSyncKotlin.JsonParam.Null() + case .array(let array): + return PowerSyncKotlin.JsonParam.Collection( + value: array.map { $0.toKotlinMap() } + ) + case .object(let dict): + var anyDict: [String: PowerSyncKotlin.JsonParam] = [:] + for (key, value) in dict { + anyDict[key] = value.toKotlinMap() + } + return PowerSyncKotlin.JsonParam.Map(value: anyDict) + } + } +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift new file mode 100644 index 0000000..7b57c4a --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift @@ -0,0 +1,136 @@ +import PowerSyncKotlin + +/// Implements `SqlCursor` using the Kotlin SDK +class KotlinSqlCursor: SqlCursor { + let base: PowerSyncKotlin.SqlCursor + + var columnCount: Int + + var columnNames: [String: Int] + + init(base: PowerSyncKotlin.SqlCursor) { + self.base = base + self.columnCount = Int(base.columnCount) + self.columnNames = base.columnNames.mapValues { input in input.intValue } + } + + func getBoolean(index: Int) throws -> Bool { + guard let result = getBooleanOptional(index: index) else { + throw SqlCursorError.nullValueFound(String(index)) + } + return result + } + + func getBooleanOptional(index: Int) -> Bool? { + base.getBoolean( + index: Int32(index) + )?.boolValue + } + + func getBoolean(name: String) throws -> Bool { + guard let result = try getBooleanOptional(name: name) else { + throw SqlCursorError.nullValueFound(name) + } + return result + } + + func getBooleanOptional(name: String) throws -> Bool? { + return getBooleanOptional(index: try guardColumnName(name)) + } + + func getDouble(index: Int) throws -> Double { + guard let result = getDoubleOptional(index: index) else { + throw SqlCursorError.nullValueFound(String(index)) + } + return result + } + + func getDoubleOptional(index: Int) -> Double? { + base.getDouble(index: Int32(index))?.doubleValue + } + + func getDouble(name: String) throws -> Double { + guard let result = try getDoubleOptional(name: name) else { + throw SqlCursorError.nullValueFound(name) + } + return result + } + + func getDoubleOptional(name: String) throws -> Double? { + return getDoubleOptional(index: try guardColumnName(name)) + } + + func getInt(index: Int) throws -> Int { + guard let result = getIntOptional(index: index) else { + throw SqlCursorError.nullValueFound(String(index)) + } + return result + } + + func getIntOptional(index: Int) -> Int? { + base.getLong(index: Int32(index))?.intValue + } + + func getInt(name: String) throws -> Int { + guard let result = try getIntOptional(name: name) else { + throw SqlCursorError.nullValueFound(name) + } + return result + } + + func getIntOptional(name: String) throws -> Int? { + return getIntOptional(index: try guardColumnName(name)) + } + + func getInt64(index: Int) throws -> Int64 { + guard let result = getInt64Optional(index: index) else { + throw SqlCursorError.nullValueFound(String(index)) + } + return result + } + + func getInt64Optional(index: Int) -> Int64? { + base.getLong(index: Int32(index))?.int64Value + } + + func getInt64(name: String) throws -> Int64 { + guard let result = try getInt64Optional(name: name) else { + throw SqlCursorError.nullValueFound(name) + } + return result + } + + func getInt64Optional(name: String) throws -> Int64? { + return getInt64Optional(index: try guardColumnName(name)) + } + + func getString(index: Int) throws -> String { + guard let result = getStringOptional(index: index) else { + throw SqlCursorError.nullValueFound(String(index)) + } + return result + } + + func getStringOptional(index: Int) -> String? { + base.getString(index: Int32(index)) + } + + func getString(name: String) throws -> String { + guard let result = try getStringOptional(name: name) else { + throw SqlCursorError.nullValueFound(name) + } + return result + } + + func getStringOptional(name: String) throws -> String? { + return getStringOptional(index: try guardColumnName(name)) + } + + @discardableResult + private func guardColumnName(_ name: String) throws -> Int { + guard let index = columnNames[name] else { + throw SqlCursorError.columnNotFound(name) + } + return index + } +} diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift new file mode 100644 index 0000000..b71615f --- /dev/null +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift @@ -0,0 +1,43 @@ +import Combine +import Foundation +import PowerSyncKotlin + +class KotlinSyncStatus: KotlinSyncStatusDataProtocol, SyncStatus { + private let baseStatus: PowerSyncKotlin.SyncStatus + + var base: any PowerSyncKotlin.SyncStatusData { + baseStatus + } + + init(baseStatus: PowerSyncKotlin.SyncStatus) { + self.baseStatus = baseStatus + } + + func asFlow() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + // Create an outer task to monitor cancellation + let task = Task { + do { + // Watching for changes in the database + for try await value in baseStatus.asFlow() { + // Check if the outer task is cancelled + try Task.checkCancellation() // This checks if the calling task was cancelled + + continuation.yield( + KotlinSyncStatusData(base: value) + ) + } + + continuation.finish() + } catch { + continuation.finish() + } + } + + // Propagate cancellation from the outer task to the inner task + continuation.onTermination = { @Sendable _ in + task.cancel() // This cancels the inner task when the stream is terminated + } + } + } +} diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift new file mode 100644 index 0000000..a1dd744 --- /dev/null +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift @@ -0,0 +1,82 @@ +import Foundation +import PowerSyncKotlin + +/// A protocol extension which allows sharing common implementation using a base sync status +protocol KotlinSyncStatusDataProtocol: SyncStatusData { + var base: PowerSyncKotlin.SyncStatusData { get } +} + +struct KotlinSyncStatusData: KotlinSyncStatusDataProtocol { + let base: PowerSyncKotlin.SyncStatusData +} + +/// Extension of `KotlinSyncStatusDataProtocol` which uses the shared `base` to implement `SyncStatusData` +extension KotlinSyncStatusDataProtocol { + var connected: Bool { + base.connected + } + + var connecting: Bool { + base.connecting + } + + var downloading: Bool { + base.downloading + } + + var uploading: Bool { + base.uploading + } + + var lastSyncedAt: Date? { + guard let lastSyncedAt = base.lastSyncedAt else { return nil } + return Date( + timeIntervalSince1970: Double( + lastSyncedAt.epochSeconds + ) + ) + } + + var hasSynced: Bool? { + base.hasSynced?.boolValue + } + + var uploadError: Any? { + base.uploadError + } + + var downloadError: Any? { + base.downloadError + } + + var anyError: Any? { + base.anyError + } + + public var priorityStatusEntries: [PriorityStatusEntry] { + base.priorityStatusEntries.map { mapPriorityStatus($0) } + } + + public func statusForPriority(_ priority: BucketPriority) -> PriorityStatusEntry { + mapPriorityStatus( + base.statusForPriority( + priority: Int32(priority.priorityCode) + ) + ) + } + + private func mapPriorityStatus(_ status: PowerSyncKotlin.PriorityStatusEntry) -> PriorityStatusEntry { + var lastSyncedAt: Date? + if let syncedAt = status.lastSyncedAt { + lastSyncedAt = Date( + timeIntervalSince1970: Double(syncedAt.epochSeconds) + ) + } + + return PriorityStatusEntry( + priority: BucketPriority(status.priority), + lastSyncedAt: lastSyncedAt, + hasSynced: status.hasSynced?.boolValue + ) + } +} diff --git a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift index ad57f31..05a99ca 100644 --- a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift +++ b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift @@ -1,29 +1,32 @@ +import PowerSyncKotlin -// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks. -// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash. -// -// This approach is a workaround. Ideally, we should introduce an internal mechanism -// in the Kotlin SDK to handle errors from Swift more robustly. -// -// This hoists any exceptions thrown in a cursor mapper in order for the error to propagate correctly. -// -// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our -// ability to handle exceptions cleanly. Instead, we should expose an internal implementation -// from a "core" package in Kotlin that provides better control over exception handling -// and other functionality—without modifying the public `PowerSyncDatabase` API to include -// Swift-specific logic. -internal func wrapQueryCursor( +/// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks. +/// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash. +/// +/// This approach is a workaround. Ideally, we should introduce an internal mechanism +/// in the Kotlin SDK to handle errors from Swift more robustly. +/// +/// This hoists any exceptions thrown in a cursor mapper in order for the error to propagate correctly. +/// +/// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our +/// ability to handle exceptions cleanly. Instead, we should expose an internal implementation +/// from a "core" package in Kotlin that provides better control over exception handling +/// and other functionality—without modifying the public `PowerSyncDatabase` API to include +/// Swift-specific logic. +func wrapQueryCursor( mapper: @escaping (SqlCursor) throws -> RowType, // The Kotlin APIs return the results as Any, we can explicitly cast internally - executor: @escaping (_ wrappedMapper: @escaping (SqlCursor) -> RowType?) async throws -> ReturnType -) async throws -> ReturnType { + executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> ReturnType +) throws -> ReturnType { var mapperException: Error? // Wrapped version of the mapper that catches exceptions and sets `mapperException` // In the case of an exception this will return an empty result. - let wrappedMapper: (SqlCursor) -> RowType? = { cursor in + let wrappedMapper: (PowerSyncKotlin.SqlCursor) -> RowType? = { cursor in do { - return try mapper(cursor) + return try mapper(KotlinSqlCursor( + base: cursor + )) } catch { // Store the error in order to propagate it mapperException = error @@ -32,20 +35,51 @@ internal func wrapQueryCursor( } } - let executionResult = try await executor(wrappedMapper) - if mapperException != nil { - // Allow propagating the error - throw mapperException! + let executionResult = try executor(wrappedMapper) + + if let mapperException { + // Allow propagating the error + throw mapperException } return executionResult } -internal func wrapQueryCursorTyped( + +func wrapQueryCursorTyped( mapper: @escaping (SqlCursor) throws -> RowType, // The Kotlin APIs return the results as Any, we can explicitly cast internally - executor: @escaping (_ wrappedMapper: @escaping (SqlCursor) -> RowType?) async throws -> Any?, + executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> Any?, resultType: ReturnType.Type -) async throws -> ReturnType { - return try safeCast(await wrapQueryCursor(mapper: mapper, executor: executor), to: resultType) +) throws -> ReturnType { + return try safeCast( + wrapQueryCursor( + mapper: mapper, + executor: executor + ), to: + resultType + ) +} + + +/// Throws a `PowerSyncException` using a helper provided by the Kotlin SDK. +/// We can't directly throw Kotlin `PowerSyncException`s from Swift, but we can delegate the throwing +/// to the Kotlin implementation. +/// Our Kotlin SDK methods handle thrown Kotlin `PowerSyncException` correctly. +/// The flow of events is as follows +/// Swift code calls `throwKotlinPowerSyncError` +/// This method calls the Kotlin helper `throwPowerSyncException` which is annotated as being able to throw `PowerSyncException` +/// The Kotlin helper throws the provided `PowerSyncException`. Since the method is annotated the exception propagates back to Swift, but in a form which can propagate back +/// to any calling Kotlin stack. +/// This only works for SKIEE methods which have an associated completion handler which handles annotated errors. +/// This seems to only apply for Kotlin suspending function bindings. +func throwKotlinPowerSyncError (message: String, cause: String? = nil) throws { + try throwPowerSyncException( + exception: PowerSyncKotlin.PowerSyncException( + message: message, + cause: PowerSyncKotlin.KotlinThrowable( + message: cause ?? message + ) + ) + ) } diff --git a/Sources/PowerSync/PowerSyncCredentials.swift b/Sources/PowerSync/PowerSyncCredentials.swift index 898a1fd..b1e1d27 100644 --- a/Sources/PowerSync/PowerSyncCredentials.swift +++ b/Sources/PowerSync/PowerSyncCredentials.swift @@ -12,22 +12,35 @@ public struct PowerSyncCredentials: Codable { public let token: String /// User ID. - public let userId: String? + @available(*, deprecated, message: "This value is not used anymore.") + public let userId: String? = nil + + enum CodingKeys: String, CodingKey { + case endpoint + case token + } + + @available(*, deprecated, message: "Use init(endpoint:token:) instead. `userId` is no longer used.") + public init( + endpoint: String, + token: String, + userId: String? = nil) { + self.endpoint = endpoint + self.token = token + } - public init(endpoint: String, token: String, userId: String? = nil) { + public init(endpoint: String, token: String) { self.endpoint = endpoint self.token = token - self.userId = userId } internal init(kotlin: KotlinPowerSyncCredentials) { self.endpoint = kotlin.endpoint self.token = kotlin.token - self.userId = kotlin.userId } internal var kotlinCredentials: KotlinPowerSyncCredentials { - return KotlinPowerSyncCredentials(endpoint: endpoint, token: token, userId: userId) + return KotlinPowerSyncCredentials(endpoint: endpoint, token: token, userId: nil) } public func endpointUri(path: String) -> String { @@ -37,6 +50,6 @@ public struct PowerSyncCredentials: Codable { extension PowerSyncCredentials: CustomStringConvertible { public var description: String { - return "PowerSyncCredentials" + return "PowerSyncCredentials" } } diff --git a/Sources/PowerSync/LoggerProtocol.swift b/Sources/PowerSync/Protocol/LoggerProtocol.swift similarity index 100% rename from Sources/PowerSync/LoggerProtocol.swift rename to Sources/PowerSync/Protocol/LoggerProtocol.swift diff --git a/Sources/PowerSync/PowerSyncBackendConnector.swift b/Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift similarity index 100% rename from Sources/PowerSync/PowerSyncBackendConnector.swift rename to Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift diff --git a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift similarity index 52% rename from Sources/PowerSync/PowerSyncDatabaseProtocol.swift rename to Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index 8fa6a0e..6f26f9e 100644 --- a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -1,5 +1,58 @@ import Foundation +/// Options for configuring a PowerSync connection. +/// +/// Provides optional parameters to customize sync behavior such as throttling and retry policies. +public struct ConnectOptions { + /// Defaults to 1 second + public static let DefaultCrudThrottle: TimeInterval = 1 + + /// Defaults to 5 seconds + public static let DefaultRetryDelay: TimeInterval = 5 + + /// TimeInterval (in seconds) between CRUD (Create, Read, Update, Delete) operations. + /// + /// Default is ``ConnectOptions/DefaultCrudThrottle``. + /// Increase this value to reduce load on the backend server. + public var crudThrottle: TimeInterval + + /// Delay TimeInterval (in seconds) before retrying after a connection failure. + /// + /// Default is ``ConnectOptions/DefaultRetryDelay``. + /// Increase this value to wait longer before retrying connections in case of persistent failures. + public var retryDelay: TimeInterval + + /// Additional sync parameters passed to the server during connection. + /// + /// This can be used to send custom values such as user identifiers, feature flags, etc. + /// + /// Example: + /// ```swift + /// [ + /// "userId": .string("abc123"), + /// "debugMode": .boolean(true) + /// ] + /// ``` + public var params: JsonParam + + /// Initializes a `ConnectOptions` instance with optional values. + /// + /// - Parameters: + /// - crudThrottle: TimeInterval between CRUD operations in milliseconds. Defaults to `1` second. + /// - 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. + public init( + crudThrottle: TimeInterval = 1, + retryDelay: TimeInterval = 5, + params: JsonParam = [:] + ) { + self.crudThrottle = crudThrottle + self.retryDelay = retryDelay + self.params = params + } +} + + /// A PowerSync managed database. /// /// Use one instance per database file. @@ -27,36 +80,40 @@ public protocol PowerSyncDatabaseProtocol: Queries { /// Wait for the first (possibly partial) sync to occur that contains all buckets in the given priority. func waitForFirstSync(priority: Int32) async throws - /// Connect to the PowerSync service, and keep the databases in sync. + /// Connects to the PowerSync service and keeps the local database in sync with the remote database. /// /// The connection is automatically re-opened if it fails for any reason. + /// You can customize connection behavior using the `ConnectOptions` parameter. /// /// - Parameters: - /// - connector: The PowerSyncBackendConnector to use - /// - crudThrottleMs: Time between CRUD operations. Defaults to 1000ms. - /// - retryDelayMs: Delay between retries after failure. Defaults to 5000ms. - /// - params: Sync parameters from the client + /// - connector: The `PowerSyncBackendConnector` used to manage the backend connection. + /// - options: Optional `ConnectOptions` to customize CRUD throttling, retry delays, and sync parameters. + /// If `nil`, default options are used (1000ms CRUD throttle, 5000ms retry delay, empty parameters). /// /// Example usage: /// ```swift - /// let params: [String: JsonParam] = [ - /// "name": .string("John Doe"), - /// "age": .number(30), - /// "isStudent": .boolean(false) - /// ] - /// - /// try await connect( + /// try await database.connect( /// connector: connector, - /// crudThrottleMs: 2000, - /// retryDelayMs: 10000, - /// params: params + /// options: ConnectOptions( + /// crudThrottleMs: 2000, + /// retryDelayMs: 10000, + /// params: [ + /// "deviceId": .string("abc123"), + /// "platform": .string("iOS") + /// ] + /// ) /// ) /// ``` + /// + /// You can also omit the `options` parameter to use the default connection behavior: + /// ```swift + /// try await database.connect(connector: connector) + /// ``` + /// + /// - Throws: An error if the connection fails or if the database is not properly configured. func connect( connector: PowerSyncBackendConnector, - crudThrottleMs: Int64, - retryDelayMs: Int64, - params: [String: JsonParam?] + options: ConnectOptions? ) async throws /// Get a batch of crud data to upload. @@ -112,25 +169,52 @@ public protocol PowerSyncDatabaseProtocol: Queries { } public extension PowerSyncDatabaseProtocol { + /// + /// The connection is automatically re-opened if it fails for any reason. + /// + /// - Parameters: + /// - connector: The PowerSyncBackendConnector to use + /// - crudThrottle: TimeInterval between CRUD operations. Defaults to ``ConnectOptions/DefaultCrudThrottle``. + /// - retryDelay: Delay TimeInterval between retries after failure. Defaults to ``ConnectOptions/DefaultRetryDelay``. + /// - params: Sync parameters from the client + /// + /// Example usage: + /// ```swift + /// let params: JsonParam = [ + /// "name": .string("John Doe"), + /// "age": .number(30), + /// "isStudent": .boolean(false) + /// ] + /// + /// try await connect( + /// connector: connector, + /// crudThrottleMs: 2000, + /// retryDelayMs: 10000, + /// params: params + /// ) func connect( connector: PowerSyncBackendConnector, - crudThrottleMs: Int64 = 1000, - retryDelayMs: Int64 = 5000, - params: [String: JsonParam?] = [:] + crudThrottle: TimeInterval = 1, + retryDelay: TimeInterval = 5, + params: JsonParam = [:] ) async throws { try await connect( connector: connector, - crudThrottleMs: crudThrottleMs, - retryDelayMs: retryDelayMs, - params: params + options: ConnectOptions( + crudThrottle: crudThrottle, + retryDelay: retryDelay, + params: params + ) ) } func disconnectAndClear(clearLocal: Bool = true) async throws { - try await disconnectAndClear(clearLocal: clearLocal) + try await self.disconnectAndClear(clearLocal: clearLocal) } func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? { - try await getCrudBatch(limit: 100) + try await getCrudBatch( + limit: limit + ) } } diff --git a/Sources/PowerSync/Protocol/PowerSyncError.swift b/Sources/PowerSync/Protocol/PowerSyncError.swift new file mode 100644 index 0000000..a33c26e --- /dev/null +++ b/Sources/PowerSync/Protocol/PowerSyncError.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Enum representing errors that can occur in the PowerSync system. +public enum PowerSyncError: Error, LocalizedError { + + /// Represents a failure in an operation, potentially with a custom message and an underlying error. + case operationFailed(message: String? = nil, underlyingError: Error? = nil) + + /// A localized description of the error, providing details about the failure. + public var errorDescription: String? { + switch self { + case let .operationFailed(message, underlyingError): + // Combine message and underlying error description if both are available + if let message = message, let underlyingError = underlyingError { + return "\(message): \(underlyingError.localizedDescription)" + } else if let message = message { + // Return only the message if no underlying error is available + return message + } else if let underlyingError = underlyingError { + // Return only the underlying error description if no message is provided + return underlyingError.localizedDescription + } else { + // Fallback to a generic error description if neither message nor underlying error is provided + return "An unknown error occurred." + } + } + } +} diff --git a/Sources/PowerSync/QueriesProtocol.swift b/Sources/PowerSync/Protocol/QueriesProtocol.swift similarity index 51% rename from Sources/PowerSync/QueriesProtocol.swift rename to Sources/PowerSync/Protocol/QueriesProtocol.swift index 755707f..1e94702 100644 --- a/Sources/PowerSync/QueriesProtocol.swift +++ b/Sources/PowerSync/Protocol/QueriesProtocol.swift @@ -1,18 +1,22 @@ import Combine import Foundation -public let DEFAULT_WATCH_THROTTLE_MS = Int64(30) +public let DEFAULT_WATCH_THROTTLE: TimeInterval = 0.03 // 30ms public struct WatchOptions { public var sql: String - public var parameters: [Any] - public var throttleMs: Int64 + public var parameters: [Any?] + public var throttle: TimeInterval public var mapper: (SqlCursor) throws -> RowType - public init(sql: String, parameters: [Any]? = [], throttleMs: Int64? = DEFAULT_WATCH_THROTTLE_MS, mapper: @escaping (SqlCursor) throws -> RowType) { + public init( + sql: String, parameters: [Any?]? = [], + throttle: TimeInterval? = DEFAULT_WATCH_THROTTLE, + mapper: @escaping (SqlCursor) throws -> RowType + ) { self.sql = sql - self.parameters = parameters ?? [] // Default to empty array if nil - self.throttleMs = throttleMs ?? DEFAULT_WATCH_THROTTLE_MS // Default to the constant if nil + self.parameters = parameters ?? [] + self.throttle = throttle ?? DEFAULT_WATCH_THROTTLE self.mapper = mapper } } @@ -20,51 +24,29 @@ public struct WatchOptions { public protocol Queries { /// Execute a write query (INSERT, UPDATE, DELETE) /// Using `RETURNING *` will result in an error. - func execute(sql: String, parameters: [Any]?) async throws -> Int64 - - /// Execute a read-only (SELECT) query and return a single result. - /// If there is no result, throws an IllegalArgumentException. - /// See `getOptional` for queries where the result might be empty. - func get( - sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> RowType + @discardableResult + func execute(sql: String, parameters: [Any?]?) async throws -> Int64 /// Execute a read-only (SELECT) query and return a single result. /// If there is no result, throws an IllegalArgumentException. /// See `getOptional` for queries where the result might be empty. func get( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType /// Execute a read-only (SELECT) query and return the results. func getAll( sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> [RowType] - - /// Execute a read-only (SELECT) query and return the results. - func getAll( - sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> [RowType] /// Execute a read-only (SELECT) query and return a single optional result. func getOptional( sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> RowType? - - /// Execute a read-only (SELECT) query and return a single optional result. - func getOptional( - sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType? @@ -72,15 +54,7 @@ public protocol Queries { /// and return the results as an array in a Publisher. func watch( sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) throws -> AsyncThrowingStream<[RowType], Error> - - /// Execute a read-only (SELECT) query every time the source tables are modified - /// and return the results as an array in a Publisher. - func watch( - sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) throws -> AsyncThrowingStream<[RowType], Error> @@ -88,43 +62,63 @@ public protocol Queries { options: WatchOptions ) throws -> AsyncThrowingStream<[RowType], Error> + /// Takes a global lock, without starting a transaction. + /// + /// In most cases, [writeTransaction] should be used instead. + func writeLock( + callback: @escaping (any ConnectionContext) throws -> R + ) async throws -> R + + /// Takes a read lock, without starting a transaction. + /// + /// The lock only applies to a single connection, and multiple + /// connections may hold read locks at the same time. + func readLock( + callback: @escaping (any ConnectionContext) throws -> R + ) async throws -> R + /// Execute a write transaction with the given callback - func writeTransaction(callback: @escaping (any Transaction) throws -> R) async throws -> R + func writeTransaction( + callback: @escaping (any Transaction) throws -> R + ) async throws -> R /// Execute a read transaction with the given callback - func readTransaction(callback: @escaping (any Transaction) throws -> R) async throws -> R + func readTransaction( + callback: @escaping (any Transaction) throws -> R + ) async throws -> R } public extension Queries { + @discardableResult func execute(_ sql: String) async throws -> Int64 { return try await execute(sql: sql, parameters: []) } func get( _ sql: String, - mapper: @escaping (SqlCursor) -> RowType + mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType { return try await get(sql: sql, parameters: [], mapper: mapper) } func getAll( _ sql: String, - mapper: @escaping (SqlCursor) -> RowType + mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> [RowType] { return try await getAll(sql: sql, parameters: [], mapper: mapper) } func getOptional( _ sql: String, - mapper: @escaping (SqlCursor) -> RowType + mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType? { return try await getOptional(sql: sql, parameters: [], mapper: mapper) } func watch( _ sql: String, - mapper: @escaping (SqlCursor) -> RowType + mapper: @escaping (SqlCursor) throws -> RowType ) throws -> AsyncThrowingStream<[RowType], Error> { - return try watch(sql: sql, parameters: [], mapper: mapper) + return try watch(sql: sql, parameters: [Any?](), mapper: mapper) } } diff --git a/Sources/PowerSync/Schema/Column.swift b/Sources/PowerSync/Protocol/Schema/Column.swift similarity index 100% rename from Sources/PowerSync/Schema/Column.swift rename to Sources/PowerSync/Protocol/Schema/Column.swift diff --git a/Sources/PowerSync/Schema/Index.swift b/Sources/PowerSync/Protocol/Schema/Index.swift similarity index 100% rename from Sources/PowerSync/Schema/Index.swift rename to Sources/PowerSync/Protocol/Schema/Index.swift diff --git a/Sources/PowerSync/Schema/IndexedColumn.swift b/Sources/PowerSync/Protocol/Schema/IndexedColumn.swift similarity index 100% rename from Sources/PowerSync/Schema/IndexedColumn.swift rename to Sources/PowerSync/Protocol/Schema/IndexedColumn.swift diff --git a/Sources/PowerSync/Schema/Schema.swift b/Sources/PowerSync/Protocol/Schema/Schema.swift similarity index 100% rename from Sources/PowerSync/Schema/Schema.swift rename to Sources/PowerSync/Protocol/Schema/Schema.swift diff --git a/Sources/PowerSync/Schema/Table.swift b/Sources/PowerSync/Protocol/Schema/Table.swift similarity index 100% rename from Sources/PowerSync/Schema/Table.swift rename to Sources/PowerSync/Protocol/Schema/Table.swift diff --git a/Sources/PowerSync/Protocol/db/ConnectionContext.swift b/Sources/PowerSync/Protocol/db/ConnectionContext.swift new file mode 100644 index 0000000..13dd939 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/ConnectionContext.swift @@ -0,0 +1,71 @@ +import Foundation + +public protocol ConnectionContext { + /** + Executes a SQL statement with optional parameters. + + - Parameters: + - sql: The SQL statement to execute + - parameters: Optional list of parameters for the SQL statement + + - Returns: A value indicating the number of rows affected + + - Throws: PowerSyncError if execution fails + */ + @discardableResult + func execute(sql: String, parameters: [Any?]?) throws -> Int64 + + /** + Retrieves an optional value from the database using the provided SQL query. + + - Parameters: + - sql: The SQL query to execute + - parameters: Optional list of parameters for the SQL query + - mapper: A closure that maps the SQL cursor result to the desired type + + - Returns: An optional value of type RowType or nil if no result + + - Throws: PowerSyncError if the query fails + */ + func getOptional( + sql: String, + parameters: [Any?]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) throws -> RowType? + + /** + Retrieves all matching rows from the database using the provided SQL query. + + - Parameters: + - sql: The SQL query to execute + - parameters: Optional list of parameters for the SQL query + - mapper: A closure that maps each SQL cursor result to the desired type + + - Returns: An array of RowType objects + + - Throws: PowerSyncError if the query fails + */ + func getAll( + sql: String, + parameters: [Any?]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) throws -> [RowType] + + /** + Retrieves a single value from the database using the provided SQL query. + + - Parameters: + - sql: The SQL query to execute + - parameters: Optional list of parameters for the SQL query + - mapper: A closure that maps the SQL cursor result to the desired type + + - Returns: A value of type RowType + + - Throws: PowerSyncError if the query fails or no result is found + */ + func get( + sql: String, + parameters: [Any?]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) throws -> RowType +} diff --git a/Sources/PowerSync/Protocol/db/CrudBatch.swift b/Sources/PowerSync/Protocol/db/CrudBatch.swift new file mode 100644 index 0000000..6fb7fe8 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/CrudBatch.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A transaction of client-side changes. +public protocol CrudBatch { + /// Indicates if there are additional Crud items in the queue which are not included in this batch + var hasMore: Bool { get } + + /// List of client-side changes. + var crud: [any CrudEntry] { get } + + /// Call to remove the changes from the local queue, once successfully uploaded. + /// + /// `writeCheckpoint` is optional. + func complete(writeCheckpoint: String?) async throws +} diff --git a/Sources/PowerSync/Protocol/db/CrudEntry.swift b/Sources/PowerSync/Protocol/db/CrudEntry.swift new file mode 100644 index 0000000..58fd037 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/CrudEntry.swift @@ -0,0 +1,49 @@ +/// Represents the type of CRUD update operation that can be performed on a row. +public enum UpdateType: String, Codable { + /// A row has been inserted or replaced + case put = "PUT" + + /// A row has been updated + case patch = "PATCH" + + /// A row has been deleted + case delete = "DELETE" + + /// Errors related to invalid `UpdateType` states. + enum UpdateTypeStateError: Error { + /// Indicates an invalid state with the provided string value. + case invalidState(String) + } + + /// Converts a string to an `UpdateType` enum value. + /// - Parameter input: The string representation of the update type. + /// - Throws: `UpdateTypeStateError.invalidState` if the input string does not match any `UpdateType`. + /// - Returns: The corresponding `UpdateType` enum value. + static func fromString(_ input: String) throws -> UpdateType { + guard let mapped = UpdateType.init(rawValue: input) else { + throw UpdateTypeStateError.invalidState(input) + } + return mapped + } +} + +/// Represents a CRUD (Create, Read, Update, Delete) entry in the system. +public protocol CrudEntry { + /// The unique identifier of the entry. + var id: String { get } + + /// The client ID associated with the entry. + var clientId: Int64 { get } + + /// The type of update operation performed on the entry. + var op: UpdateType { get } + + /// The name of the table where the entry resides. + var table: String { get } + + /// The transaction ID associated with the entry, if any. + var transactionId: Int64? { get } + + /// The operation data associated with the entry, represented as a dictionary of column names to their values. + var opData: [String: String?]? { get } +} diff --git a/Sources/PowerSync/Protocol/db/CrudTransaction.swift b/Sources/PowerSync/Protocol/db/CrudTransaction.swift new file mode 100644 index 0000000..3ce8147 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/CrudTransaction.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A transaction of client-side changes. +public protocol CrudTransaction { + /// Unique transaction id. + /// + /// If nil, this contains a list of changes recorded without an explicit transaction associated. + var transactionId: Int64? { get } + + /// List of client-side changes. + var crud: [any CrudEntry] { get } + + /// Call to remove the changes from the local queue, once successfully uploaded. + /// + /// `writeCheckpoint` is optional. + func complete(writeCheckpoint: String?) async throws +} + +public extension CrudTransaction { + /// Call to remove the changes from the local queue, once successfully uploaded. + /// + /// `writeCheckpoint` is optional. + func complete(writeCheckpoint: String? = nil) async throws { + try await self.complete( + writeCheckpoint: writeCheckpoint + ) + } +} diff --git a/Sources/PowerSync/Protocol/db/JsonParam.swift b/Sources/PowerSync/Protocol/db/JsonParam.swift new file mode 100644 index 0000000..a9d2835 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/JsonParam.swift @@ -0,0 +1,56 @@ +/// A strongly-typed representation of a JSON value. +/// +/// Supports all standard JSON types: string, number (integer and double), +/// boolean, null, arrays, and nested objects. +public enum JsonValue: Codable { + /// A JSON string value. + case string(String) + + /// A JSON integer value. + case int(Int) + + /// A JSON double-precision floating-point value. + case double(Double) + + /// A JSON boolean value (`true` or `false`). + case bool(Bool) + + /// A JSON null value. + case null + + /// A JSON array containing a list of `JSONValue` elements. + case array([JsonValue]) + + /// A JSON object containing key-value pairs where values are `JSONValue` instances. + case object([String: JsonValue]) + + /// Converts the `JSONValue` into a native Swift representation. + /// + /// - Returns: A corresponding Swift type (`String`, `Int`, `Double`, `Bool`, `nil`, `[Any]`, or `[String: Any]`), + /// or `nil` if the value is `.null`. + func toValue() -> Any? { + switch self { + case .string(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .bool(let value): + return value + case .null: + return nil + case .array(let array): + return array.map { $0.toValue() } + case .object(let dict): + var anyDict: [String: Any] = [:] + for (key, value) in dict { + anyDict[key] = value.toValue() + } + return anyDict + } + } +} + +/// A typealias representing a top-level JSON object with string keys and `JSONValue` values. +public typealias JsonParam = [String: JsonValue] diff --git a/Sources/PowerSync/Protocol/db/SqlCursor.swift b/Sources/PowerSync/Protocol/db/SqlCursor.swift new file mode 100644 index 0000000..46d85d2 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/SqlCursor.swift @@ -0,0 +1,166 @@ +import Foundation + +/// A protocol representing a cursor for SQL query results, providing methods to retrieve values by column index or name. +public protocol SqlCursor { + /// Retrieves a `Bool` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Bool` value. + func getBoolean(index: Int) throws -> Bool + + /// Retrieves a `Bool` value from the specified column index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Bool` value if present, or `nil` if the value is null. + func getBooleanOptional(index: Int) -> Bool? + + /// Retrieves a `Bool` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Bool` value. + func getBoolean(name: String) throws -> Bool + + /// Retrieves an optional `Bool` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. + /// - Returns: The `Bool` value if present, or `nil` if the value is null. + func getBooleanOptional(name: String) throws -> Bool? + + /// Retrieves a `Double` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Double` value. + func getDouble(index: Int) throws -> Double + + /// Retrieves a `Double` value from the specified column index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Double` value if present, or `nil` if the value is null. + func getDoubleOptional(index: Int) -> Double? + + /// Retrieves a `Double` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Double` value. + func getDouble(name: String) throws -> Double + + /// Retrieves an optional `Double` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. + /// - Returns: The `Double` value if present, or `nil` if the value is null. + func getDoubleOptional(name: String) throws -> Double? + + /// Retrieves an `Int` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Int` value. + func getInt(index: Int) throws -> Int + + /// Retrieves an `Int` value from the specified column index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Int` value if present, or `nil` if the value is null. + func getIntOptional(index: Int) -> Int? + + /// Retrieves an `Int` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Int` value. + func getInt(name: String) throws -> Int + + /// Retrieves an optional `Int` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. + /// - Returns: The `Int` value if present, or `nil` if the value is null. + func getIntOptional(name: String) throws -> Int? + + /// Retrieves an `Int64` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Int64` value. + func getInt64(index: Int) throws -> Int64 + + /// Retrieves an `Int64` value from the specified column index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Int64` value if present, or `nil` if the value is null. + func getInt64Optional(index: Int) -> Int64? + + /// Retrieves an `Int64` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `Int64` value. + func getInt64(name: String) throws -> Int64 + + /// Retrieves an optional `Int64` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. + /// - Returns: The `Int64` value if present, or `nil` if the value is null. + func getInt64Optional(name: String) throws -> Int64? + + /// Retrieves a `String` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `String` value. + func getString(index: Int) throws -> String + + /// Retrieves a `String` value from the specified column index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `String` value if present, or `nil` if the value is null. + func getStringOptional(index: Int) -> String? + + /// Retrieves a `String` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. + /// - Returns: The `String` value. + func getString(name: String) throws -> String + + /// Retrieves an optional `String` value from the specified column name. + /// - Parameter name: The name of the column. + /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. + /// - Returns: The `String` value if present, or `nil` if the value is null. + func getStringOptional(name: String) throws -> String? + + /// The number of columns in the result set. + var columnCount: Int { get } + + /// A dictionary mapping column names to their zero-based indices. + var columnNames: [String: Int] { get } +} + +/// An error type representing issues encountered while working with a `SqlCursor`. +public enum SqlCursorError: Error { + /// An expected column was not found. + case columnNotFound(_ name: String) + + /// A column contained a null value when a non-null was expected. + case nullValueFound(_ name: String) + + /// In some cases we have to serialize an error to a single string. This deserializes potential error strings. + static func fromDescription(_ description: String) -> SqlCursorError? { + // Example: "SqlCursorError:columnNotFound:user_id" + let parts = description.split(separator: ":") + + // Ensure that the string follows the expected format + guard parts.count == 3 else { return nil } + + let type = parts[1] // "columnNotFound" or "nullValueFound" + let name = String(parts[2]) // The column name (e.g., "user_id") + + switch type { + case "columnNotFound": + return .columnNotFound(name) + case "nullValueFound": + return .nullValueFound(name) + default: + return nil + } + } +} + +extension SqlCursorError: LocalizedError { + public var errorDescription: String? { + switch self { + case .columnNotFound(let name): + return "SqlCursorError:columnNotFound:\(name)" + case .nullValueFound(let name): + return "SqlCursorError:nullValueFound:\(name)" + } + } +} diff --git a/Sources/PowerSync/Protocol/db/Transaction.swift b/Sources/PowerSync/Protocol/db/Transaction.swift new file mode 100644 index 0000000..153e4f3 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/Transaction.swift @@ -0,0 +1,3 @@ +/// Represents a database transaction, inheriting the behavior of a connection context. +/// This protocol can be used to define operations that should be executed within the scope of a transaction. +public protocol Transaction: ConnectionContext {} diff --git a/Sources/PowerSync/Protocol/sync/BucketPriority.swift b/Sources/PowerSync/Protocol/sync/BucketPriority.swift new file mode 100644 index 0000000..0ff8a1d --- /dev/null +++ b/Sources/PowerSync/Protocol/sync/BucketPriority.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Represents the priority of a bucket, used for sorting and managing operations based on priority levels. +public struct BucketPriority: Comparable { + /// The priority code associated with the bucket. Higher values indicate lower priority. + public let priorityCode: Int32 + + /// Initializes a new `BucketPriority` with the given priority code. + /// - Parameter priorityCode: The priority code. Must be greater than or equal to 0. + /// - Precondition: `priorityCode` must be >= 0. + public init(_ priorityCode: Int32) { + precondition(priorityCode >= 0, "priorityCode must be >= 0") + self.priorityCode = priorityCode + } + + /// Compares two `BucketPriority` instances to determine their order. + /// - Parameters: + /// - lhs: The left-hand side `BucketPriority` instance. + /// - rhs: The right-hand side `BucketPriority` instance. + /// - Returns: `true` if the left-hand side has a higher priority (lower `priorityCode`) than the right-hand side. + /// - Note: Sorting is reversed, where a higher `priorityCode` means a lower priority. + public static func < (lhs: BucketPriority, rhs: BucketPriority) -> Bool { + return rhs.priorityCode < lhs.priorityCode + } + + /// Represents the priority for a full synchronization operation, which has the lowest priority. + public static let fullSyncPriority = BucketPriority(Int32.max) + + /// Represents the default priority for general operations. + public static let defaultPriority = BucketPriority(3) +} diff --git a/Sources/PowerSync/Protocol/sync/PriorityStatusEntry.swift b/Sources/PowerSync/Protocol/sync/PriorityStatusEntry.swift new file mode 100644 index 0000000..be9bc4b --- /dev/null +++ b/Sources/PowerSync/Protocol/sync/PriorityStatusEntry.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Represents the status of a bucket priority, including synchronization details. +public struct PriorityStatusEntry { + /// The priority of the bucket. + public let priority: BucketPriority + + /// The date and time when the bucket was last synchronized. + /// - Note: This value is optional and may be `nil` if the bucket has not been synchronized yet. + public let lastSyncedAt: Date? + + /// Indicates whether the bucket has been successfully synchronized. + /// - Note: This value is optional and may be `nil` if the synchronization status is unknown. + public let hasSynced: Bool? +} diff --git a/Sources/PowerSync/Protocol/sync/SyncStatusData.swift b/Sources/PowerSync/Protocol/sync/SyncStatusData.swift new file mode 100644 index 0000000..d4aa035 --- /dev/null +++ b/Sources/PowerSync/Protocol/sync/SyncStatusData.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A protocol representing the synchronization status of a system, providing various indicators and error states. +public protocol SyncStatusData { + /// Indicates whether the system is currently connected. + var connected: Bool { get } + + /// Indicates whether the system is in the process of connecting. + var connecting: Bool { get } + + /// Indicates whether the system is actively downloading changes. + var downloading: Bool { get } + + /// Indicates whether the system is actively uploading changes. + var uploading: Bool { get } + + /// The date and time when the last synchronization was fully completed, if any. + var lastSyncedAt: Date? { get } + + /// Indicates whether there has been at least one full synchronization. + /// - Note: This value is `nil` when the state is unknown, for example, when the state is still being loaded. + var hasSynced: Bool? { get } + + /// Represents any error that occurred during uploading. + /// - Note: This value is cleared on the next successful upload. + var uploadError: Any? { get } + + /// Represents any error that occurred during downloading (including connecting). + /// - Note: This value is cleared on the next successful data download. + var downloadError: Any? { get } + + /// A convenience property that returns either the `downloadError` or `uploadError`, if any. + var anyError: Any? { get } + + /// A list of `PriorityStatusEntry` objects reporting the synchronization status for buckets within priorities. + /// - Note: When buckets with different priorities are defined, this may contain entries before `hasSynced` + /// and `lastSyncedAt` are set, indicating that a partial (but not complete) sync has completed. + var priorityStatusEntries: [PriorityStatusEntry] { get } + + /// Retrieves the synchronization status for a specific priority. + /// - 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 +} + +/// A protocol extending `SyncStatusData` to include flow-based updates for synchronization status. +public protocol SyncStatus: SyncStatusData { + /// Provides a flow of synchronization status updates. + /// - Returns: An `AsyncStream` that emits updates whenever the synchronization status changes. + func asFlow() -> AsyncStream +} diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift index effb104..42ad8f5 100644 --- a/Sources/PowerSync/attachments/Attachment.swift +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -133,12 +133,12 @@ public struct Attachment { return try Attachment( id: cursor.getString(name: "id"), filename: cursor.getString(name: "filename"), - state: AttachmentState.from(cursor.getLong(name: "state")), - timestamp: cursor.getLong(name: "timestamp"), - hasSynced: cursor.getLong(name: "has_synced") > 0, + state: AttachmentState.from(cursor.getInt(name: "state")), + timestamp: cursor.getInt(name: "timestamp"), + hasSynced: cursor.getInt(name: "has_synced") > 0, localUri: cursor.getStringOptional(name: "local_uri"), mediaType: cursor.getStringOptional(name: "media_type"), - size: cursor.getLongOptional(name: "size")?.int64Value, + size: cursor.getInt64Optional(name: "size"), metaData: cursor.getStringOptional(name: "meta_data") ) } diff --git a/Sources/PowerSync/attachments/AttachmentContext.swift b/Sources/PowerSync/attachments/AttachmentContext.swift index c7f01a6..394d028 100644 --- a/Sources/PowerSync/attachments/AttachmentContext.swift +++ b/Sources/PowerSync/attachments/AttachmentContext.swift @@ -1,7 +1,7 @@ import Foundation /// Context which performs actions on the attachment records -public class AttachmentContext { +open class AttachmentContext { private let db: any PowerSyncDatabaseProtocol private let tableName: String private let logger: any LoggerProtocol @@ -206,12 +206,12 @@ public class AttachmentContext { updatedRecord.id, updatedRecord.timestamp, updatedRecord.filename, - updatedRecord.localUri ?? NSNull(), - updatedRecord.mediaType ?? NSNull(), - updatedRecord.size ?? NSNull(), + updatedRecord.localUri, + updatedRecord.mediaType, + updatedRecord.size, updatedRecord.state.rawValue, updatedRecord.hasSynced ?? 0, - updatedRecord.metaData ?? NSNull() + updatedRecord.metaData ] ) diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 62d35ee..b7aeee2 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -3,7 +3,7 @@ import Foundation /// Class used to implement the attachment queue /// Requires a PowerSyncDatabase, a RemoteStorageAdapter implementation, and a directory name for attachments. -public class AttachmentQueue { +open class AttachmentQueue { /// Default name of the attachments table public static let defaultTableName = "attachments" diff --git a/Sources/PowerSync/attachments/AttachmentService.swift b/Sources/PowerSync/attachments/AttachmentService.swift index b5736d4..3690439 100644 --- a/Sources/PowerSync/attachments/AttachmentService.swift +++ b/Sources/PowerSync/attachments/AttachmentService.swift @@ -1,7 +1,7 @@ import Foundation /// Service which manages attachment records. -public class AttachmentService { +open class AttachmentService { private let db: any PowerSyncDatabaseProtocol private let tableName: String private let logger: any LoggerProtocol diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index e1d03cd..3c3a551 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -6,7 +6,7 @@ import Foundation /// This watches for changes to active attachments and performs queued /// download, upload, and delete operations. Syncs can be triggered manually, /// periodically, or based on database changes. -public class SyncingService { +open class SyncingService { private let remoteStorage: RemoteStorageAdapter private let localStorage: LocalStorageAdapter private let attachmentsService: AttachmentService diff --git a/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index cecc6f5..b4427e4 100644 --- a/Tests/PowerSyncTests/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -171,11 +171,12 @@ final class AttachmentTests: XCTestCase { } } -enum WaitForMatchError: Error { - case timeout +public enum WaitForMatchError: Error { + case timeout(lastError: Error? = nil) + case predicateFail(message: String) } -func waitForMatch( +public func waitForMatch( iterator: AsyncThrowingStream.Iterator, where predicate: @escaping (T) -> Bool, timeout: TimeInterval @@ -191,13 +192,13 @@ func waitForMatch( return value } } - throw WaitForMatchError.timeout // stream ended before match + throw WaitForMatchError.timeout() // stream ended before match } // Task to enforce timeout group.addTask { try await Task.sleep(nanoseconds: timeoutNanoseconds) - throw WaitForMatchError.timeout + throw WaitForMatchError.timeout() } // First one to succeed or fail @@ -206,3 +207,31 @@ func waitForMatch( return result! } } + +func waitFor( + timeout: TimeInterval = 0.5, + interval: TimeInterval = 0.1, + predicate: () async throws -> Void +) async throws { + let intervalNanoseconds = UInt64(interval * 1_000_000_000) + + let timeoutDate = Date( + timeIntervalSinceNow: timeout + ) + + var lastError: Error? + + while Date() < timeoutDate { + do { + try await predicate() + return + } catch { + lastError = error + } + try await Task.sleep(nanoseconds: intervalNanoseconds) + } + + throw WaitForMatchError.timeout( + lastError: lastError + ) +} diff --git a/Tests/PowerSyncTests/ConnectTests.swift b/Tests/PowerSyncTests/ConnectTests.swift new file mode 100644 index 0000000..3df8894 --- /dev/null +++ b/Tests/PowerSyncTests/ConnectTests.swift @@ -0,0 +1,91 @@ +@testable import PowerSync +import XCTest + +final class ConnectTests: XCTestCase { + private var database: (any PowerSyncDatabaseProtocol)! + private var schema: Schema! + + override func setUp() async throws { + try await super.setUp() + schema = Schema(tables: [ + Table( + name: "users", + columns: [ + .text("name"), + .text("email"), + .text("photo_id"), + ] + ), + ]) + + database = KotlinPowerSyncDatabaseImpl( + schema: schema, + dbFilename: ":memory:", + logger: DatabaseLogger(DefaultLogger()) + ) + try await database.disconnectAndClear() + } + + override func tearDown() async throws { + try await database.disconnectAndClear() + try await database.close() + database = nil + try await super.tearDown() + } + + /// Tests passing basic JSON as client parameters + func testClientParameters() async throws { + /// This is an example of specifying JSON client params. + /// The test here just ensures that the Kotlin SDK accepts these params and does not crash + try await database.connect( + connector: PowerSyncBackendConnector(), + params: [ + "foo": .string("bar"), + ] + ) + } + + func testSyncStatus() async throws { + XCTAssert(database.currentStatus.connected == false) + XCTAssert(database.currentStatus.connecting == false) + + try await database.connect( + connector: PowerSyncBackendConnector() + ) + + try await waitFor(timeout: 10) { + guard database.currentStatus.connecting == true else { + throw WaitForMatchError.predicateFail(message: "Should be connecting") + } + } + + try await database.disconnect() + + try await waitFor(timeout: 10) { + guard database.currentStatus.connecting == false else { + throw WaitForMatchError.predicateFail(message: "Should not be connecting after disconnect") + } + } + } + + func testSyncStatusUpdates() async throws { + let expectation = XCTestExpectation( + description: "Watch Sync Status" + ) + + let watchTask = Task { + for try await _ in database.currentStatus.asFlow() { + expectation.fulfill() + } + } + + // Do some connecting operations + try await database.connect( + connector: PowerSyncBackendConnector() + ) + + // We should get an update + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } +} diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift new file mode 100644 index 0000000..408124c --- /dev/null +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -0,0 +1,93 @@ +@testable import PowerSync +import XCTest + +final class CrudTests: XCTestCase { + private var database: (any PowerSyncDatabaseProtocol)! + private var schema: Schema! + + override func setUp() async throws { + try await super.setUp() + schema = Schema(tables: [ + Table( + name: "users", + columns: [ + .text("name"), + .text("email"), + .integer("favorite_number"), + .text("photo_id"), + ] + ), + ]) + + database = KotlinPowerSyncDatabaseImpl( + schema: schema, + dbFilename: ":memory:", + logger: DatabaseLogger(DefaultLogger()) + ) + try await database.disconnectAndClear() + } + + override func tearDown() async throws { + try await database.disconnectAndClear() + try await database.close() + database = nil + try await super.tearDown() + } + + func testCrudBatch() async throws { + // Create some items + try await database.writeTransaction { tx in + for i in 0 ..< 100 { + try tx.execute( + sql: "INSERT INTO users (id, name, email, favorite_number) VALUES (uuid(), 'a', 'a@example.com', ?)", + parameters: [i] + ) + } + } + + // Get a limited set of batched operations + guard let limitedBatch = try await database.getCrudBatch(limit: 50) else { + return XCTFail("Failed to get crud batch") + } + + guard let crudItem = limitedBatch.crud.first else { + return XCTFail("Crud batch should contain crud entries") + } + + // This should show as a string even though it's a number + // This is what the typing conveys + let opData = crudItem.opData?["favorite_number"] + XCTAssert(opData == "0") + + XCTAssert(limitedBatch.hasMore == true) + XCTAssert(limitedBatch.crud.count == 50) + + guard let fullBatch = try await database.getCrudBatch() else { + return XCTFail("Failed to get crud batch") + } + + XCTAssert(fullBatch.hasMore == false) + XCTAssert(fullBatch.crud.count == 100) + + guard let nextTx = try await database.getNextCrudTransaction() else { + return XCTFail("Failed to get transaction crud batch") + } + + XCTAssert(nextTx.crud.count == 100) + + for r in nextTx.crud { + print(r) + } + + // Completing the transaction should clear the items + try await nextTx.complete() + + let afterCompleteBatch = try await database.getNextCrudTransaction() + + for r in afterCompleteBatch?.crud ?? [] { + print(r) + } + + XCTAssertNil(afterCompleteBatch) + } +} diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 4eef1b6..beaff22 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -2,17 +2,20 @@ import XCTest final class KotlinPowerSyncDatabaseImplTests: XCTestCase { - private var database: KotlinPowerSyncDatabaseImpl! + private var database: (any PowerSyncDatabaseProtocol)! private var schema: Schema! override func setUp() async throws { try await super.setUp() schema = Schema(tables: [ - Table(name: "users", columns: [ - .text("name"), - .text("email"), - .text("photo_id") - ]) + Table( + name: "users", + columns: [ + .text("name"), + .text("email"), + .text("photo_id"), + ] + ), ]) database = KotlinPowerSyncDatabaseImpl( @@ -25,19 +28,14 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { override func tearDown() async throws { try await database.disconnectAndClear() - // Tests currently fail if this is called. - // The watched query tests try and read from the DB while it's closing. - // This causes a PowerSyncException to be thrown in the Kotlin flow. - // Custom exceptions in flows are not supported by SKIEE. This causes a crash. - // FIXME: Reapply once watched query errors are handled better. - // try await database.close() + try await database.close() database = nil try await super.tearDown() } func testExecuteError() async throws { do { - _ = try await database.execute( + try await database.execute( sql: "INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?)", parameters: ["1", "Test User", "test@example.com"] ) @@ -51,7 +49,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } func testInsertAndGet() async throws { - _ = try await database.execute( + try await database.execute( sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", parameters: ["1", "Test User", "test@example.com"] ) @@ -71,8 +69,6 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTAssertEqual(user.1, "Test User") XCTAssertEqual(user.2, "test@example.com") } - - func testGetError() async throws { do { @@ -100,7 +96,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT name FROM users WHERE id = ?", parameters: ["999"] ) { cursor in - cursor.getString(index: 0)! + try cursor.getString(name: "name") } XCTAssertNil(nonExistent) @@ -114,7 +110,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT name FROM users WHERE id = ?", parameters: ["1"] ) { cursor in - cursor.getString(index: 0)! + try cursor.getString(index: 0) } XCTAssertEqual(existing, "Test User") @@ -142,7 +138,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } func testMapperError() async throws { - _ = try await database.execute( + try await database.execute( sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", parameters: ["1", "Test User", "test@example.com"] ) @@ -151,7 +147,11 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT id, name, email FROM users WHERE id = ?", parameters: ["1"] ) { _ throws in - throw NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "cursor error"]) + throw NSError( + domain: "TestError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "cursor error"] + ) } XCTFail("Expected an error to be thrown") } catch { @@ -160,7 +160,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } func testGetAll() async throws { - _ = try await database.execute( + try await database.execute( sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?), (?, ?, ?)", parameters: ["1", "User 1", "user1@example.com", "2", "User 2", "user2@example.com"] ) @@ -229,7 +229,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { options: WatchOptions( sql: "SELECT name FROM users ORDER BY id", mapper: { cursor in - cursor.getString(index: 0)! + try cursor.getString(index: 0) } )) @@ -270,7 +270,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT name FROM usersfail ORDER BY id", parameters: nil ) { cursor in - cursor.getString(index: 0)! + try cursor.getString(index: 0) } // Actually consume the stream to trigger the error @@ -328,10 +328,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getLong(index: 0) + try cursor.getInt(index: 0) } - XCTAssertEqual(result as! Int, 2) + XCTAssertEqual(result, 2) } func testWriteLongerTransaction() async throws { @@ -355,10 +355,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getLong(index: 0) + try cursor.getInt(index: 0) } - XCTAssertEqual(result as! Int, 2 * loopCount) + XCTAssertEqual(result, 2 * loopCount) } func testWriteTransactionError() async throws { @@ -400,7 +400,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let result = try await database.getOptional( sql: "SELECT COUNT(*) FROM users", parameters: [] - ) { cursor in try cursor.getLong(index: 0) + ) { cursor in try cursor.getInt(index: 0) } XCTAssertEqual(result, 0) @@ -417,21 +417,21 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getLong(index: 0) + try cursor.getInt(index: 0) } - XCTAssertEqual(result as! Int, 1) + XCTAssertEqual(result, 1) } } func testReadTransactionError() async throws { do { _ = try await database.readTransaction { transaction in - let result = try transaction.get( + _ = try transaction.get( sql: "SELECT COUNT(*) FROM usersfail", parameters: [] ) { cursor in - cursor.getLong(index: 0) + try cursor.getInt(index: 0) } } } catch { @@ -442,11 +442,41 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } + /// Transactions should return the value returned from the callback + func testTransactionReturnValue() async throws { + // Should pass through nil + let txNil = try await database.writeTransaction { _ in + nil as Any? + } + XCTAssertNil(txNil) + + let txString = try await database.writeTransaction { _ in + "Hello" + } + XCTAssertEqual(txString, "Hello") + } + + /// Transactions should return the value returned from the callback + func testTransactionGenerics() async throws { + // Should pass through nil + try await database.writeTransaction { tx in + let result = try tx.get( + sql: "SELECT FALSE as col", + parameters: [] + ) { cursor in + try cursor.getBoolean(name: "col") + } + + // result should be typed as Bool + XCTAssertFalse(result) + } + } + func testFTS() async throws { let supported = try await database.get( "SELECT sqlite_compileoption_used('ENABLE_FTS5');" ) { cursor in - cursor.getLong(index: 0) + try cursor.getInt(index: 0) } XCTAssertEqual(supported, 1) @@ -474,50 +504,50 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let peopleCount = try await database.get( sql: "SELECT COUNT(*) FROM people", parameters: [] - ) { cursor in cursor.getLong(index: 0) } + ) { cursor in try cursor.getInt(index: 0) } XCTAssertEqual(peopleCount, 1) } - + func testCustomLogger() async throws { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.debug, writers: [testWriter]) - + let db2 = KotlinPowerSyncDatabaseImpl( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(logger) ) - + try await db2.close() - + let warningIndex = testWriter.logs.firstIndex( where: { value in value.contains("warning: Multiple PowerSync instances for the same database have been detected") } ) - + XCTAssert(warningIndex! >= 0) } - + func testMinimumSeverity() async throws { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.error, writers: [testWriter]) - + let db2 = KotlinPowerSyncDatabaseImpl( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(logger) ) - + try await db2.close() - + let warningIndex = testWriter.logs.firstIndex( where: { value in value.contains("warning: Multiple PowerSync instances for the same database have been detected") } ) - + // The warning should not be present due to the min severity XCTAssert(warningIndex == nil) } diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index 26fa833..6fa5cf5 100644 --- a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PowerSync +import XCTest struct User { let id: String @@ -14,6 +14,40 @@ struct UserOptional { let isActive: Bool? let weight: Double? let description: String? + + init( + id: String, + count: Int? = nil, + isActive: Bool? = nil, + weight: Double? = nil, + description: String? = nil + ) { + self.id = id + self.count = count + self.isActive = isActive + self.weight = weight + self.description = description + } +} + +func createTestUser( + db: PowerSyncDatabaseProtocol, + userData: UserOptional = UserOptional( + id: "1", + count: 110, + isActive: false, + weight: 1.1111 + ) +) async throws { + try await db.execute( + sql: "INSERT INTO users (id, count, is_active, weight) VALUES (?, ?, ?, ?)", + parameters: [ + userData.id, + userData.count, + userData.isActive, + userData.weight + ] + ) } final class SqlCursorTests: XCTestCase { @@ -34,7 +68,7 @@ final class SqlCursorTests: XCTestCase { database = KotlinPowerSyncDatabaseImpl( schema: schema, dbFilename: ":memory:", - logger: DatabaseLogger(DefaultLogger()) + logger: DatabaseLogger(DefaultLogger()) ) try await database.disconnectAndClear() } @@ -46,20 +80,67 @@ final class SqlCursorTests: XCTestCase { } func testValidValues() async throws { - _ = try await database.execute( - sql: "INSERT INTO users (id, count, is_active, weight) VALUES (?, ?, ?, ?)", - parameters: ["1", 110, 0, 1.1111] + try await createTestUser( + db: database ) let user: User = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] ) { cursor in - User( - id: try cursor.getString(name: "id"), - count: try cursor.getLong(name: "count"), - isActive: try cursor.getBoolean(name: "is_active"), - weight: try cursor.getDouble(name: "weight") + try User( + id: cursor.getString(name: "id"), + count: cursor.getInt(name: "count"), + isActive: cursor.getBoolean(name: "is_active"), + weight: cursor.getDouble(name: "weight") + ) + } + + XCTAssertEqual(user.id, "1") + XCTAssertEqual(user.count, 110) + XCTAssertEqual(user.isActive, false) + XCTAssertEqual(user.weight, 1.1111) + } + + /// Uses the indexed based cursor methods to obtain a required column value + func testValidValuesWithIndex() async throws { + try await createTestUser( + db: database + ) + + let user = try await database.get( + sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + try UserOptional( + id: cursor.getString(index: 0), + count: cursor.getInt(index: 1), + isActive: cursor.getBoolean(index: 2), + weight: cursor.getDoubleOptional(index: 3) + ) + } + + XCTAssertEqual(user.id, "1") + XCTAssertEqual(user.count, 110) + XCTAssertEqual(user.isActive, false) + XCTAssertEqual(user.weight, 1.1111) + } + + /// Uses index based cursor methods which are optional and don't throw + func testIndexNoThrow() async throws { + try await createTestUser( + db: database + ) + + let user = try await database.get( + sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + UserOptional( + id: cursor.getStringOptional(index: 0) ?? "1", + count: cursor.getIntOptional(index: 1), + isActive: cursor.getBooleanOptional(index: 2), + weight: cursor.getDoubleOptional(index: 3) ) } @@ -70,21 +151,27 @@ final class SqlCursorTests: XCTestCase { } func testOptionalValues() async throws { - _ = try await database.execute( - sql: "INSERT INTO users (id, count, is_active, weight, description) VALUES (?, ?, ?, ?, ?)", - parameters: ["1", nil, nil, nil, nil, nil] + try await createTestUser( + db: database, + userData: UserOptional( + id: "1", + count: nil, + isActive: nil, + weight: nil, + description: nil + ) ) let user: UserOptional = try await database.get( sql: "SELECT id, count, is_active, weight, description FROM users WHERE id = ?", parameters: ["1"] ) { cursor in - UserOptional( - id: try cursor.getString(name: "id"), - count: try cursor.getLongOptional(name: "count"), - isActive: try cursor.getBooleanOptional(name: "is_active"), - weight: try cursor.getDoubleOptional(name: "weight"), - description: try cursor.getStringOptional(name: "description") + try UserOptional( + id: cursor.getString(name: "id"), + count: cursor.getIntOptional(name: "count"), + isActive: cursor.getBooleanOptional(name: "is_active"), + weight: cursor.getDoubleOptional(name: "weight"), + description: cursor.getStringOptional(name: "description") ) } @@ -94,4 +181,111 @@ final class SqlCursorTests: XCTestCase { XCTAssertNil(user.weight) XCTAssertNil(user.description) } + + /// Tests that a `mapper` which does not throw is accepted by the protocol + func testNoThrow() async throws { + try await createTestUser( + db: database + ) + + let user = try await database.get( + sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + try UserOptional( + id: cursor.getString(index: 0), + count: cursor.getInt(index: 1), + isActive: cursor.getBoolean(index: 2), + weight: cursor.getDouble(index: 3), + description: nil + ) + } + + XCTAssertEqual(user.id, "1") + XCTAssertEqual(user.count, 110) + XCTAssertEqual(user.isActive, false) + XCTAssertEqual(user.weight, 1.1111) + } + + func testThrowsForMissingColumn() async throws { + try await createTestUser( + db: database + ) + + do { + _ = try await database.get( + sql: "SELECT id FROM users", + parameters: [] + ) { cursor in + try cursor.getString(name: "missing") + } + XCTFail("An Error should have been thrown due to a missing column") + } catch let SqlCursorError.columnNotFound(columnName) { + // The throw Error should contain the missing column name + XCTAssertEqual(columnName, "missing") + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testThrowsForNullValuedRequiredColumn() async throws { + /// Create a test user with nil stored in columns + try await createTestUser( + db: database, + userData: UserOptional( + id: "1", + count: nil, + isActive: nil, + weight: nil, + description: nil + ) + ) + + do { + _ = try await database.get( + sql: "SELECT description FROM users", + parameters: [] + ) { cursor in + // Request a required column. A nil value here will throw + try cursor.getString(name: "description") + } + XCTFail("An Error should have been thrown due to a missing column") + } catch let SqlCursorError.nullValueFound(columnName) { + // The throw Error should contain the missing column name + XCTAssertEqual(columnName, "description") + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + /// Index based cursor methods should throw if null is returned for required values + func testThrowsForNullValuedRequiredColumnIndex() async throws { + /// Create a test user with nil stored in columns + try await createTestUser( + db: database, + userData: UserOptional( + id: "1", + count: nil, + isActive: nil, + weight: nil, + description: nil + ) + ) + + do { + _ = try await database.get( + sql: "SELECT description FROM users", + parameters: [] + ) { cursor in + // Request a required column. A nil value here will throw + try cursor.getString(index: 0) + } + XCTFail("An Error should have been thrown due to a missing column") + } catch let SqlCursorError.nullValueFound(columnName) { + // The throw Error should contain the missing column name + XCTAssertEqual(columnName, "0") + } catch { + XCTFail("Unexpected error type: \(error)") + } + } } diff --git a/Tests/PowerSyncTests/test-utils/MockConnector.swift b/Tests/PowerSyncTests/test-utils/MockConnector.swift new file mode 100644 index 0000000..09cab45 --- /dev/null +++ b/Tests/PowerSyncTests/test-utils/MockConnector.swift @@ -0,0 +1,5 @@ +import PowerSync + +class MockConnector: PowerSyncBackendConnector { + +}