From 7524bc73926e2363383a58a674978422a821545f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 23 Apr 2025 18:16:04 +0200 Subject: [PATCH 01/24] wip: cleanup API --- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 185 +++++++++++------- Sources/PowerSync/Kotlin/KotlinTypes.swift | 9 +- Sources/PowerSync/Kotlin/SafeCastError.swift | 11 ++ Sources/PowerSync/Kotlin/SqlCursor.swift | 68 ------- .../Kotlin/TransactionCallback.swift | 11 +- .../Kotlin/db/KotlinConnectionContext.swift | 66 +++++++ .../PowerSync/Kotlin/db/KotlinCrudBatch.swift | 19 ++ .../PowerSync/Kotlin/db/KotlinCrudEntry.swift | 33 ++++ .../Kotlin/db/KotlinCrudTransaction.swift | 19 ++ .../PowerSync/Kotlin/db/KotlinSqlCursor.swift | 94 +++++++++ .../Kotlin/sync/KotlinSyncStatus.swift | 44 +++++ .../Kotlin/sync/KotlinSyncStatusData.swift | 72 +++++++ .../PowerSync/Kotlin/wrapQueryCursor.swift | 67 ++++++- Sources/PowerSync/QueriesProtocol.swift | 34 ++-- .../PowerSync/attachments/Attachment.swift | 8 +- .../protocol/db/ConnectionContext.swift | 71 +++++++ Sources/PowerSync/protocol/db/CrudBatch.swift | 19 ++ Sources/PowerSync/protocol/db/CrudEntry.swift | 30 +++ .../protocol/db/CrudTransaction.swift | 17 ++ Sources/PowerSync/protocol/db/JsonParam.swift | 11 ++ Sources/PowerSync/protocol/db/SqlCursor.swift | 37 ++++ .../PowerSync/protocol/db/Transaction.swift | 4 + .../protocol/sync/BucketPriority.swift | 19 ++ .../protocol/sync/PriorityStatusEntry.swift | 7 + .../protocol/sync/SyncStatusData.swift | 22 +++ .../KotlinPowerSyncDatabaseImplTests.swift | 22 +-- .../Kotlin/SqlCursorTests.swift | 4 +- 27 files changed, 810 insertions(+), 193 deletions(-) delete mode 100644 Sources/PowerSync/Kotlin/SqlCursor.swift create mode 100644 Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift create mode 100644 Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift create mode 100644 Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift create mode 100644 Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift create mode 100644 Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift create mode 100644 Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift create mode 100644 Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift create mode 100644 Sources/PowerSync/protocol/db/ConnectionContext.swift create mode 100644 Sources/PowerSync/protocol/db/CrudBatch.swift create mode 100644 Sources/PowerSync/protocol/db/CrudEntry.swift create mode 100644 Sources/PowerSync/protocol/db/CrudTransaction.swift create mode 100644 Sources/PowerSync/protocol/db/JsonParam.swift create mode 100644 Sources/PowerSync/protocol/db/SqlCursor.swift create mode 100644 Sources/PowerSync/protocol/db/Transaction.swift create mode 100644 Sources/PowerSync/protocol/sync/BucketPriority.swift create mode 100644 Sources/PowerSync/protocol/sync/PriorityStatusEntry.swift create mode 100644 Sources/PowerSync/protocol/sync/SyncStatusData.swift diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 874d4ca..c94ec83 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -5,8 +5,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { let logger: any LoggerProtocol private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase - - var currentStatus: SyncStatus { kotlinDatabase.currentStatus } + let currentStatus: SyncStatus init( schema: Schema, @@ -21,6 +20,9 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { logger: logger.kLogger ) self.logger = logger + self.currentStatus = KotlinSyncStatus( + baseStatus: kotlinDatabase.currentStatus + ) } func waitForFirstSync() async throws { @@ -55,11 +57,17 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } 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(base) } func getNextCrudTransaction() async throws -> CrudTransaction? { - try await kotlinDatabase.getNextCrudTransaction() + guard let base = try await kotlinDatabase.getNextCrudTransaction() else { + return nil + } + return try KotlinCrudTransaction(base) } func getPowerSyncVersion() async throws -> String { @@ -71,117 +79,130 @@ 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)) + 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 readTransaction { 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 readTransaction { 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 readTransaction { 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 readTransaction { 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 readTransaction { 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 readTransaction { 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( @@ -202,18 +223,18 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { // EXPLAIN statement to prevent crashes in SKIEE _ = try await self.kotlinDatabase.getAll( sql: "EXPLAIN \(options.sql)", - parameters: options.parameters, + parameters: mapParameters(options.parameters), mapper: { _ in "" } ) // Watching for changes in the database for try await values in try self.kotlinDatabase.watch( sql: options.sql, - parameters: options.parameters, + parameters: mapParameters(options.parameters), throttleMs: KotlinLong(value: options.throttleMs), mapper: { cursor in do { - return try options.mapper(cursor) + return try options.mapper(KotlinSqlCursor(base: cursor)) } catch { mapperError = error return () @@ -247,12 +268,26 @@ 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 writeTransaction(callback: @escaping (any ConnectionContext) throws -> R) async throws -> R { + return try safeCast( + await kotlinDatabase.writeTransaction( + callback: TransactionCallback( + 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 readTransaction(callback: @escaping (any ConnectionContext) throws -> R) async throws -> R { + return try safeCast( + await kotlinDatabase.readTransaction( + callback: TransactionCallback( + callback: callback + ) + ), + to: R.self + ) } func close() async throws { diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift index 23c361f..70b902a 100644 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ b/Sources/PowerSync/Kotlin/KotlinTypes.swift @@ -1,13 +1,6 @@ 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/Kotlin/SafeCastError.swift b/Sources/PowerSync/Kotlin/SafeCastError.swift index ccf736d..4eb3c28 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?) @@ -11,6 +13,15 @@ enum SafeCastError: Error, CustomStringConvertible { } internal 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..7b0709a 100644 --- a/Sources/PowerSync/Kotlin/TransactionCallback.swift +++ b/Sources/PowerSync/Kotlin/TransactionCallback.swift @@ -1,9 +1,9 @@ import PowerSyncKotlin class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { - let callback: (PowerSyncTransaction) throws -> R + let callback: (ConnectionContext) throws -> R - init(callback: @escaping (PowerSyncTransaction) throws -> R) { + init(callback: @escaping (ConnectionContext) throws -> R) { self.callback = callback } @@ -23,7 +23,11 @@ class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { // Swift-specific logic. func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any { do { - return try callback(transaction) + return try callback( + KotlinConnectionContext( + ctx: transaction + ) + ) } catch { return PowerSyncKotlin.PowerSyncException( message: error.localizedDescription, @@ -32,4 +36,3 @@ class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { } } } - diff --git a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift new file mode 100644 index 0000000..61b6df9 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift @@ -0,0 +1,66 @@ +import Foundation +import PowerSyncKotlin + +class KotlinConnectionContext: ConnectionContext { + private let ctx: PowerSyncKotlin.ConnectionContext + + init(ctx: PowerSyncKotlin.ConnectionContext) { + self.ctx = ctx + } + + 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 wrapQueryCursorTypedSync( + 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 wrapQueryCursorTypedSync( + 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 wrapQueryCursorTypedSync( + mapper: mapper, + executor: { wrappedMapper in + try self.ctx.get( + sql: sql, + parameters: mapParameters(parameters), + mapper: wrappedMapper + ) + }, + resultType: RowType.self + ) + } +} + +// Allows nil values to be passed to the Kotlin [Any] params +internal 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..898a076 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift @@ -0,0 +1,19 @@ +import PowerSyncKotlin + +struct KotlinCrudBatch: CrudBatch { + let base: PowerSyncKotlin.CrudBatch + let crud: [CrudEntry] + + init (_ base: PowerSyncKotlin.CrudBatch) throws { + self.base = base + self.crud = try base.crud.map { try KotlinCrudEntry($0) } + } + + var hasMore: Bool { + base.hasMore + } + + func complete(writeCheckpoint: String?) async throws { + _ = try await base.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..8822520 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift @@ -0,0 +1,33 @@ +import PowerSyncKotlin + +struct KotlinCrudEntry : CrudEntry { + let base: PowerSyncKotlin.CrudEntry + let op: UpdateType + + init (_ base: PowerSyncKotlin.CrudEntry) throws { + self.base = base + self.op = try UpdateType.fromString(base.op.name) + } + + var id: String { + base.id + } + + var clientId: Int32 { + base.clientId + } + + var table: String { + base.table + } + + var transactionId: Int32? { + base.transactionId?.int32Value + } + + var opData: [String : String?]? { + /// Kotlin represents this as Map, but this is + /// converted to [String: Any] by SKIEE + base.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..f3433a2 --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift @@ -0,0 +1,19 @@ +import PowerSyncKotlin + +struct KotlinCrudTransaction: CrudTransaction { + let base: PowerSyncKotlin.CrudTransaction + let crud: [CrudEntry] + + init (_ base: PowerSyncKotlin.CrudTransaction) throws { + self.base = base + self.crud = try base.crud.map { try KotlinCrudEntry($0) } + } + + var transactionId: Int32? { + base.transactionId?.int32Value + } + + func complete(writeCheckpoint: String?) async throws { + _ = try await base.complete.invoke(p1: writeCheckpoint) + } +} diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift new file mode 100644 index 0000000..843587a --- /dev/null +++ b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift @@ -0,0 +1,94 @@ +import PowerSyncKotlin + +class KotlinSqlCursor: SqlCursor { + let base: PowerSyncKotlin.SqlCursor + + var columnCount: Int + + var columnNames: Dictionary + + 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) -> 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? { + try base.getBooleanOptional(name: name)?.boolValue + } + + func getDouble(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? { + try base.getDoubleOptional(name: name)?.doubleValue + } + + func getInt(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? { + try base.getLongOptional(name: name)?.intValue + } + + func getInt64(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? { + try base.getLongOptional(name: name)?.int64Value + } + + func getString(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? { + /// For some reason this method is not exposed from the Kotlin side + guard let columnIndex = columnNames[name] else { + throw SqlCursorError.columnNotFound(name) + } + return getString(index: columnIndex) + } +} diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift new file mode 100644 index 0000000..e7eedfd --- /dev/null +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift @@ -0,0 +1,44 @@ +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..9f63c24 --- /dev/null +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift @@ -0,0 +1,72 @@ +import PowerSyncKotlin +import Foundation + +protocol KotlinSyncStatusDataProtocol: SyncStatusData { + var base: PowerSyncKotlin.SyncStatusData { get } +} + +struct KotlinSyncStatusData: KotlinSyncStatusDataProtocol { + let base: PowerSyncKotlin.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: 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..ebcadc1 100644 --- a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift +++ b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift @@ -1,3 +1,4 @@ +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. @@ -12,18 +13,48 @@ // 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( +func wrapQueryCursorSync( 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 + 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: (PowerSyncKotlin.SqlCursor) -> RowType? = { cursor in + do { + return try mapper(KotlinSqlCursor(base: cursor)) + } catch { + // Store the error in order to propagate it + mapperException = error + // Return nothing here. Kotlin should handle this as an empty object/row + return nil + } + } + + let executionResult = try executor(wrappedMapper) + if mapperException != nil { + // Allow propagating the error + throw mapperException! + } + + return executionResult +} + + +func wrapQueryCursor( + mapper: @escaping (SqlCursor) throws -> RowType, + // The Kotlin APIs return the results as Any, we can explicitly cast internally + executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) async throws -> ReturnType ) async 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 @@ -41,11 +72,33 @@ internal func wrapQueryCursor( 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?) async throws -> Any?, resultType: ReturnType.Type ) async throws -> ReturnType { - return try safeCast(await wrapQueryCursor(mapper: mapper, executor: executor), to: resultType) + return try safeCast( + await wrapQueryCursor( + mapper: mapper, + executor: executor + ), to: + resultType + ) +} + +func wrapQueryCursorTypedSync( + mapper: @escaping (SqlCursor) throws -> RowType, + // The Kotlin APIs return the results as Any, we can explicitly cast internally + executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> Any?, + resultType: ReturnType.Type +) throws -> ReturnType { + return try safeCast( + wrapQueryCursorSync( + mapper: mapper, + executor: executor + ), to: + resultType + ) } diff --git a/Sources/PowerSync/QueriesProtocol.swift b/Sources/PowerSync/QueriesProtocol.swift index 755707f..b26328d 100644 --- a/Sources/PowerSync/QueriesProtocol.swift +++ b/Sources/PowerSync/QueriesProtocol.swift @@ -5,11 +5,15 @@ public let DEFAULT_WATCH_THROTTLE_MS = Int64(30) public struct WatchOptions { public var sql: String - public var parameters: [Any] + public var parameters: [Any?] public var throttleMs: Int64 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?]? = [], + throttleMs: Int64? = DEFAULT_WATCH_THROTTLE_MS, + 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 @@ -20,14 +24,15 @@ 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 + @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) -> RowType ) async throws -> RowType @@ -36,35 +41,35 @@ public protocol Queries { /// 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]?, + 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]?, + 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,7 +77,7 @@ public protocol Queries { /// and return the results as an array in a Publisher. func watch( sql: String, - parameters: [Any]?, + parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) throws -> AsyncThrowingStream<[RowType], Error> @@ -80,7 +85,7 @@ public protocol Queries { /// 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> @@ -89,13 +94,14 @@ public protocol Queries { ) throws -> AsyncThrowingStream<[RowType], Error> /// Execute a write transaction with the given callback - func writeTransaction(callback: @escaping (any Transaction) throws -> R) async throws -> R + func writeTransaction(callback: @escaping (any ConnectionContext) 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 ConnectionContext) throws -> R) async throws -> R } public extension Queries { + @discardableResult func execute(_ sql: String) async throws -> Int64 { return try await execute(sql: sql, parameters: []) } @@ -125,6 +131,6 @@ public extension Queries { _ sql: String, mapper: @escaping (SqlCursor) -> 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/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/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..3191b36 --- /dev/null +++ b/Sources/PowerSync/protocol/db/CrudBatch.swift @@ -0,0 +1,19 @@ + + +import Foundation + +/// A transaction of client-side changes. +public protocol CrudBatch { + /// Unique transaction id. + /// + /// If nil, this contains a list of changes recorded without an explicit transaction associated. + 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..5049145 --- /dev/null +++ b/Sources/PowerSync/protocol/db/CrudEntry.swift @@ -0,0 +1,30 @@ +public enum UpdateType: String, Codable { + /// Insert or replace a row. All non-null columns are included in the data. + case put = "PUT" + + /// Update a row if it exists. All updated columns are included in the data. + case patch = "PATCH" + + /// Delete a row if it exists. + case delete = "DELETE" + + enum UpdateTypeStateError: Error { + case invalidState(String) + } + + static func fromString(_ input: String) throws -> UpdateType { + guard let mapped = UpdateType.init(rawValue: input) else { + throw UpdateTypeStateError.invalidState(input) + } + return mapped + } +} + +public protocol CrudEntry { + var id: String { get } + var clientId: Int32 { get } + var op: UpdateType { get } + var table: String { get } + var transactionId: Int32? { get } + 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..af18ea9 --- /dev/null +++ b/Sources/PowerSync/protocol/db/CrudTransaction.swift @@ -0,0 +1,17 @@ +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: Int32? { 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/JsonParam.swift b/Sources/PowerSync/protocol/db/JsonParam.swift new file mode 100644 index 0000000..848b403 --- /dev/null +++ b/Sources/PowerSync/protocol/db/JsonParam.swift @@ -0,0 +1,11 @@ +public enum JSONValue: Codable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + case array([JSONValue]) + case object([String: JSONValue]) +} + +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..c01d572 --- /dev/null +++ b/Sources/PowerSync/protocol/db/SqlCursor.swift @@ -0,0 +1,37 @@ +public protocol SqlCursor { + func getBoolean(index: Int) -> Bool? + func getBoolean(name: String) throws -> Bool + func getBooleanOptional(name: String) throws -> Bool? + + func getDouble(index: Int) -> Double? + func getDouble(name: String) throws -> Double + func getDoubleOptional(name: String) throws -> Double? + + func getInt(index: Int) -> Int? + func getInt(name: String) throws -> Int + func getIntOptional(name: String) throws -> Int? + + func getInt64(index: Int) -> Int64? + func getInt64(name: String) throws -> Int64 + func getInt64Optional(name: String) throws -> Int64? + + func getString(index: Int) -> String? + func getString(name: String) throws -> String + func getStringOptional(name: String) throws -> String? + + var columnCount: Int { get } + var columnNames: Dictionary { get } +} + + +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/protocol/db/Transaction.swift b/Sources/PowerSync/protocol/db/Transaction.swift new file mode 100644 index 0000000..0dbad74 --- /dev/null +++ b/Sources/PowerSync/protocol/db/Transaction.swift @@ -0,0 +1,4 @@ + +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..9be0115 --- /dev/null +++ b/Sources/PowerSync/protocol/sync/BucketPriority.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct BucketPriority: Comparable { + public let priorityCode: Int32 + + public init(_ priorityCode: Int32) { + precondition(priorityCode >= 0, "priorityCode must be >= 0") + self.priorityCode = priorityCode + } + + // Reverse sorting: higher `priorityCode` means lower priority + public static func < (lhs: BucketPriority, rhs: BucketPriority) -> Bool { + return rhs.priorityCode < lhs.priorityCode + } + + // MARK: - Predefined priorities + public static let fullSyncPriority = BucketPriority(Int32.max) + 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..2b77a0c --- /dev/null +++ b/Sources/PowerSync/protocol/sync/PriorityStatusEntry.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct PriorityStatusEntry { + public let priority: BucketPriority + public let lastSyncedAt: Date? + 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..71e98ea --- /dev/null +++ b/Sources/PowerSync/protocol/sync/SyncStatusData.swift @@ -0,0 +1,22 @@ +import Foundation + +public protocol SyncStatusData { + var connected: Bool { get } + var connecting: Bool { get } + var downloading: Bool { get } + var uploading: Bool { get } + var lastSyncedAt: Date? { get } + var hasSynced: Bool? { get } + var uploadError: Any? { get } + var downloadError: Any? { get } + var anyError: Any? { get } + var priorityStatusEntries: [PriorityStatusEntry] { get } + + func statusForPriority(_ priority: BucketPriority) -> PriorityStatusEntry +} + + +public protocol SyncStatus : SyncStatusData { + func asFlow() -> AsyncStream +} + diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 4eef1b6..4ae3bc3 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -100,7 +100,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT name FROM users WHERE id = ?", parameters: ["999"] ) { cursor in - cursor.getString(index: 0)! + try cursor.getString(name: "") } XCTAssertNil(nonExistent) @@ -328,10 +328,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getLong(index: 0) + 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) + 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 cursor.getInt(index: 0) } XCTAssertEqual(result, 0) @@ -417,10 +417,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getLong(index: 0) + cursor.getInt(index: 0) } - XCTAssertEqual(result as! Int, 1) + XCTAssertEqual(result, 1) } } @@ -431,7 +431,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM usersfail", parameters: [] ) { cursor in - cursor.getLong(index: 0) + cursor.getInt(index: 0) } } } catch { @@ -446,7 +446,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let supported = try await database.get( "SELECT sqlite_compileoption_used('ENABLE_FTS5');" ) { cursor in - cursor.getLong(index: 0) + cursor.getInt(index: 0) } XCTAssertEqual(supported, 1) @@ -474,7 +474,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let peopleCount = try await database.get( sql: "SELECT COUNT(*) FROM people", parameters: [] - ) { cursor in cursor.getLong(index: 0) } + ) { cursor in cursor.getInt(index: 0) } XCTAssertEqual(peopleCount, 1) } diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index 26fa833..8f81525 100644 --- a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -57,7 +57,7 @@ final class SqlCursorTests: XCTestCase { ) { cursor in User( id: try cursor.getString(name: "id"), - count: try cursor.getLong(name: "count"), + count: try cursor.getInt(name: "count"), isActive: try cursor.getBoolean(name: "is_active"), weight: try cursor.getDouble(name: "weight") ) @@ -81,7 +81,7 @@ final class SqlCursorTests: XCTestCase { ) { cursor in UserOptional( id: try cursor.getString(name: "id"), - count: try cursor.getLongOptional(name: "count"), + count: try cursor.getIntOptional(name: "count"), isActive: try cursor.getBooleanOptional(name: "is_active"), weight: try cursor.getDoubleOptional(name: "weight"), description: try cursor.getStringOptional(name: "description") From dbaba586d79501d5952e79339d45ed6f0503c345 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 28 Apr 2025 08:38:11 +0200 Subject: [PATCH 02/24] Cleanup --- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 75 ++++++++++++---- .../Kotlin/TransactionCallback.swift | 27 +++++- .../Kotlin/db/KotlinConnectionContext.swift | 52 ++++++++--- .../PowerSync/Kotlin/db/KotlinCrudBatch.swift | 14 +-- .../PowerSync/Kotlin/db/KotlinCrudEntry.swift | 22 ++--- .../Kotlin/db/KotlinCrudTransaction.swift | 16 ++-- .../PowerSync/Kotlin/db/KotlinSqlCursor.swift | 29 ++++-- .../Kotlin/sync/KotlinSyncStatusData.swift | 11 ++- .../PowerSync/Kotlin/wrapQueryCursor.swift | 49 +--------- Sources/PowerSync/QueriesProtocol.swift | 18 +++- Sources/PowerSync/protocol/db/CrudEntry.swift | 23 ++++- .../protocol/db/CrudTransaction.swift | 2 +- Sources/PowerSync/protocol/db/JsonParam.swift | 45 ++++++++++ Sources/PowerSync/protocol/db/SqlCursor.swift | 89 +++++++++++++++++-- .../PowerSync/protocol/db/Transaction.swift | 7 +- .../protocol/sync/BucketPriority.swift | 16 +++- .../protocol/sync/PriorityStatusEntry.swift | 8 ++ .../KotlinPowerSyncDatabaseImplTests.swift | 36 ++++---- 18 files changed, 395 insertions(+), 144 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index c94ec83..0e121f8 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -20,7 +20,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { logger: logger.kLogger ) self.logger = logger - self.currentStatus = KotlinSyncStatus( + currentStatus = KotlinSyncStatus( baseStatus: kotlinDatabase.currentStatus ) } @@ -30,18 +30,22 @@ 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?] = [:] + params: JsonParam = [:] ) async throws { let connectorAdapter = PowerSyncBackendConnectorAdapter( swiftBackendConnector: connector, @@ -52,7 +56,8 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { connector: connectorAdapter, crudThrottleMs: crudThrottleMs, retryDelayMs: retryDelayMs, - params: params + // We map to basic values and use NSNull to avoid SKIEE thinking the values must be of Any type + params: params.mapValues { $0.toValue() ?? NSNull() } ) } @@ -60,14 +65,18 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { guard let base = try await kotlinDatabase.getCrudBatch(limit: limit) else { return nil } - return try KotlinCrudBatch(base) + return try KotlinCrudBatch( + batch: base + ) } func getNextCrudTransaction() async throws -> CrudTransaction? { guard let base = try await kotlinDatabase.getNextCrudTransaction() else { return nil } - return try KotlinCrudTransaction(base) + return try KotlinCrudTransaction( + transaction: base + ) } func getPowerSyncVersion() async throws -> String { @@ -85,7 +94,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } func execute(sql: String, parameters: [Any?]?) async throws -> Int64 { - try await writeTransaction {ctx in + try await writeTransaction { ctx in try ctx.execute( sql: sql, parameters: parameters @@ -98,7 +107,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) async throws -> RowType { - try await readTransaction { ctx in + try await readLock { ctx in try ctx.get( sql: sql, parameters: parameters, @@ -112,7 +121,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType { - try await readTransaction { ctx in + try await readLock { ctx in try ctx.get( sql: sql, parameters: parameters, @@ -126,7 +135,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) async throws -> [RowType] { - try await readTransaction { ctx in + try await readLock { ctx in try ctx.getAll( sql: sql, parameters: parameters, @@ -140,7 +149,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> [RowType] { - try await readTransaction { ctx in + try await readLock { ctx in try ctx.getAll( sql: sql, parameters: parameters, @@ -154,7 +163,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { parameters: [Any?]?, mapper: @escaping (SqlCursor) -> RowType ) async throws -> RowType? { - try await readTransaction { ctx in + try await readLock { ctx in try ctx.getOptional( sql: sql, parameters: parameters, @@ -168,7 +177,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { parameters: [Any?]?, mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType? { - try await readTransaction { ctx in + try await readLock { ctx in try ctx.getOptional( sql: sql, parameters: parameters, @@ -176,7 +185,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { ) } } - + func watch( sql: String, parameters: [Any?]?, @@ -268,7 +277,22 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } } - func writeTransaction(callback: @escaping (any ConnectionContext) throws -> R) async throws -> R { + func writeLock( + callback: @escaping (any ConnectionContext) throws -> R + ) async throws -> R { + return try safeCast( + await kotlinDatabase.writeLock( + callback: LockCallback( + callback: callback + ) + ), + to: R.self + ) + } + + func writeTransaction( + callback: @escaping (any Transaction) throws -> R + ) async throws -> R { return try safeCast( await kotlinDatabase.writeTransaction( callback: TransactionCallback( @@ -279,7 +303,24 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { ) } - func readTransaction(callback: @escaping (any ConnectionContext) throws -> R) async throws -> R { + func readLock( + callback: @escaping (any ConnectionContext) throws -> R + ) + async throws -> R + { + return 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 safeCast( await kotlinDatabase.readTransaction( callback: TransactionCallback( diff --git a/Sources/PowerSync/Kotlin/TransactionCallback.swift b/Sources/PowerSync/Kotlin/TransactionCallback.swift index 7b0709a..05df36d 100644 --- a/Sources/PowerSync/Kotlin/TransactionCallback.swift +++ b/Sources/PowerSync/Kotlin/TransactionCallback.swift @@ -1,6 +1,6 @@ import PowerSyncKotlin -class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { +class LockCallback: PowerSyncKotlin.ThrowableLockCallback { let callback: (ConnectionContext) throws -> R init(callback: @escaping (ConnectionContext) throws -> R) { @@ -21,10 +21,33 @@ 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( KotlinConnectionContext( + ctx: context + ) + ) + } catch { + return PowerSyncKotlin.PowerSyncException( + message: error.localizedDescription, + cause: PowerSyncKotlin.KotlinThrowable(message: error.localizedDescription) + ) + } + } +} + +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 ) ) diff --git a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift index 61b6df9..c1298dc 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift @@ -1,13 +1,11 @@ import Foundation import PowerSyncKotlin -class KotlinConnectionContext: ConnectionContext { - private let ctx: PowerSyncKotlin.ConnectionContext - - init(ctx: PowerSyncKotlin.ConnectionContext) { - self.ctx = ctx - } - +protocol KotlinConnectionContextProtocol: ConnectionContext { + var ctx: PowerSyncKotlin.ConnectionContext { get } +} + +extension KotlinConnectionContextProtocol { func execute(sql: String, parameters: [Any?]?) throws -> Int64 { try ctx.execute( sql: sql, @@ -15,8 +13,12 @@ class KotlinConnectionContext: ConnectionContext { ) } - func getOptional(sql: String, parameters: [Any?]?, mapper: @escaping (any SqlCursor) throws -> RowType) throws -> RowType? { - return try wrapQueryCursorTypedSync( + 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( @@ -29,8 +31,12 @@ class KotlinConnectionContext: ConnectionContext { ) } - func getAll(sql: String, parameters: [Any?]?, mapper: @escaping (any SqlCursor) throws -> RowType) throws -> [RowType] { - return try wrapQueryCursorTypedSync( + 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( @@ -43,8 +49,12 @@ class KotlinConnectionContext: ConnectionContext { ) } - func get(sql: String, parameters: [Any?]?, mapper: @escaping (any SqlCursor) throws -> RowType) throws -> RowType { - return try wrapQueryCursorTypedSync( + 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( @@ -58,6 +68,22 @@ class KotlinConnectionContext: ConnectionContext { } } +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 internal func mapParameters(_ parameters: [Any?]?) -> [Any] { parameters?.map { item in diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift index 898a076..01fa5d3 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift @@ -1,19 +1,21 @@ import PowerSyncKotlin struct KotlinCrudBatch: CrudBatch { - let base: PowerSyncKotlin.CrudBatch + let batch: PowerSyncKotlin.CrudBatch let crud: [CrudEntry] - init (_ base: PowerSyncKotlin.CrudBatch) throws { - self.base = base - self.crud = try base.crud.map { try KotlinCrudEntry($0) } + init (batch: PowerSyncKotlin.CrudBatch) throws { + self.batch = batch + self.crud = try batch.crud.map { try KotlinCrudEntry( + entry: $0 + ) } } var hasMore: Bool { - base.hasMore + batch.hasMore } func complete(writeCheckpoint: String?) async throws { - _ = try await base.complete.invoke(p1: writeCheckpoint) + _ = try await batch.complete.invoke(p1: writeCheckpoint) } } diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift index 8822520..64c89ed 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift @@ -1,33 +1,33 @@ import PowerSyncKotlin struct KotlinCrudEntry : CrudEntry { - let base: PowerSyncKotlin.CrudEntry + let entry: PowerSyncKotlin.CrudEntry let op: UpdateType - init (_ base: PowerSyncKotlin.CrudEntry) throws { - self.base = base - self.op = try UpdateType.fromString(base.op.name) + init (entry: PowerSyncKotlin.CrudEntry) throws { + self.entry = entry + self.op = try UpdateType.fromString(entry.op.name) } var id: String { - base.id + entry.id } - var clientId: Int32 { - base.clientId + var clientId: Int64 { + Int64(entry.clientId) } var table: String { - base.table + entry.table } - var transactionId: Int32? { - base.transactionId?.int32Value + var transactionId: Int64? { + entry.transactionId?.int64Value } var opData: [String : String?]? { /// Kotlin represents this as Map, but this is /// converted to [String: Any] by SKIEE - base.opData?.mapValues { $0 as? String } + entry.opData?.mapValues { $0 as? String } } } diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift index f3433a2..c87cc13 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift @@ -1,19 +1,21 @@ import PowerSyncKotlin struct KotlinCrudTransaction: CrudTransaction { - let base: PowerSyncKotlin.CrudTransaction + let transaction: PowerSyncKotlin.CrudTransaction let crud: [CrudEntry] - init (_ base: PowerSyncKotlin.CrudTransaction) throws { - self.base = base - self.crud = try base.crud.map { try KotlinCrudEntry($0) } + init(transaction: PowerSyncKotlin.CrudTransaction) throws { + self.transaction = transaction + self.crud = try transaction.crud.map { try KotlinCrudEntry( + entry: $0 + ) } } - var transactionId: Int32? { - base.transactionId?.int32Value + var transactionId: Int64? { + transaction.transactionId?.int64Value } func complete(writeCheckpoint: String?) async throws { - _ = try await base.complete.invoke(p1: writeCheckpoint) + _ = try await transaction.complete.invoke(p1: writeCheckpoint) } } diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift index 843587a..78109f2 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift @@ -14,10 +14,13 @@ class KotlinSqlCursor: SqlCursor { } func getBoolean(index: Int) -> Bool? { - base.getBoolean(index: Int32(index))?.boolValue + base.getBoolean( + index: Int32(index) + )?.boolValue } func getBoolean(name: String) throws -> Bool { + try guardColumnName(name) guard let result = try getBooleanOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -25,7 +28,8 @@ class KotlinSqlCursor: SqlCursor { } func getBooleanOptional(name: String) throws -> Bool? { - try base.getBooleanOptional(name: name)?.boolValue + try guardColumnName(name) + return try base.getBooleanOptional(name: name)?.boolValue } func getDouble(index: Int) -> Double? { @@ -33,6 +37,7 @@ class KotlinSqlCursor: SqlCursor { } func getDouble(name: String) throws -> Double { + try guardColumnName(name) guard let result = try getDoubleOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -40,7 +45,8 @@ class KotlinSqlCursor: SqlCursor { } func getDoubleOptional(name: String) throws -> Double? { - try base.getDoubleOptional(name: name)?.doubleValue + try guardColumnName(name) + return try base.getDoubleOptional(name: name)?.doubleValue } func getInt(index: Int) -> Int? { @@ -48,6 +54,7 @@ class KotlinSqlCursor: SqlCursor { } func getInt(name: String) throws -> Int { + try guardColumnName(name) guard let result = try getIntOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -55,7 +62,8 @@ class KotlinSqlCursor: SqlCursor { } func getIntOptional(name: String) throws -> Int? { - try base.getLongOptional(name: name)?.intValue + try guardColumnName(name) + return try base.getLongOptional(name: name)?.intValue } func getInt64(index: Int) -> Int64? { @@ -63,6 +71,7 @@ class KotlinSqlCursor: SqlCursor { } func getInt64(name: String) throws -> Int64 { + try guardColumnName(name) guard let result = try getInt64Optional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -70,7 +79,8 @@ class KotlinSqlCursor: SqlCursor { } func getInt64Optional(name: String) throws -> Int64? { - try base.getLongOptional(name: name)?.int64Value + try guardColumnName(name) + return try base.getLongOptional(name: name)?.int64Value } func getString(index: Int) -> String? { @@ -78,6 +88,7 @@ class KotlinSqlCursor: SqlCursor { } func getString(name: String) throws -> String { + try guardColumnName(name) guard let result = try getStringOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -91,4 +102,12 @@ class KotlinSqlCursor: SqlCursor { } return getString(index: columnIndex) } + + @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/KotlinSyncStatusData.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift index 9f63c24..c54e215 100644 --- a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift @@ -28,7 +28,10 @@ extension KotlinSyncStatusDataProtocol { var lastSyncedAt: Date? { guard let lastSyncedAt = base.lastSyncedAt else { return nil } - return Date(timeIntervalSince1970: Double(lastSyncedAt.epochSeconds)) + return Date( + timeIntervalSince1970: Double(lastSyncedAt.epochSeconds + ) + ) } var hasSynced: Bool? { @@ -48,12 +51,14 @@ extension KotlinSyncStatusDataProtocol { } public var priorityStatusEntries: [PriorityStatusEntry] { - base.priorityStatusEntries.map { mapPriorityStatus($0)} + base.priorityStatusEntries.map { mapPriorityStatus($0) } } public func statusForPriority(_ priority: BucketPriority) -> PriorityStatusEntry { mapPriorityStatus( - base.statusForPriority(priority: priority.priorityCode) + base.statusForPriority( + priority: Int32(priority.priorityCode) + ) ) } diff --git a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift index ebcadc1..e1b2127 100644 --- a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift +++ b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift @@ -13,7 +13,7 @@ import PowerSyncKotlin // 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 wrapQueryCursorSync( +func wrapQueryCursor( mapper: @escaping (SqlCursor) throws -> RowType, // The Kotlin APIs return the results as Any, we can explicitly cast internally executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> ReturnType @@ -43,59 +43,14 @@ func wrapQueryCursorSync( } -func wrapQueryCursor( - mapper: @escaping (SqlCursor) throws -> RowType, - // The Kotlin APIs return the results as Any, we can explicitly cast internally - executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) async throws -> ReturnType -) async 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: (PowerSyncKotlin.SqlCursor) -> RowType? = { cursor in - do { - return try mapper(KotlinSqlCursor(base: cursor)) - } catch { - // Store the error in order to propagate it - mapperException = error - // Return nothing here. Kotlin should handle this as an empty object/row - return nil - } - } - - let executionResult = try await executor(wrappedMapper) - if mapperException != nil { - // Allow propagating the error - throw mapperException! - } - - return executionResult -} - - func wrapQueryCursorTyped( - mapper: @escaping (SqlCursor) throws -> RowType, - // The Kotlin APIs return the results as Any, we can explicitly cast internally - executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) async throws -> Any?, - resultType: ReturnType.Type -) async throws -> ReturnType { - return try safeCast( - await wrapQueryCursor( - mapper: mapper, - executor: executor - ), to: - resultType - ) -} - -func wrapQueryCursorTypedSync( mapper: @escaping (SqlCursor) throws -> RowType, // The Kotlin APIs return the results as Any, we can explicitly cast internally executor: @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> RowType?) throws -> Any?, resultType: ReturnType.Type ) throws -> ReturnType { return try safeCast( - wrapQueryCursorSync( + wrapQueryCursor( mapper: mapper, executor: executor ), to: diff --git a/Sources/PowerSync/QueriesProtocol.swift b/Sources/PowerSync/QueriesProtocol.swift index b26328d..cf7152f 100644 --- a/Sources/PowerSync/QueriesProtocol.swift +++ b/Sources/PowerSync/QueriesProtocol.swift @@ -94,10 +94,24 @@ public protocol Queries { ) throws -> AsyncThrowingStream<[RowType], Error> /// Execute a write transaction with the given callback - func writeTransaction(callback: @escaping (any ConnectionContext) throws -> R) async throws -> R + func writeLock( + callback: @escaping (any ConnectionContext) throws -> R + ) async throws -> R /// Execute a read transaction with the given callback - func readTransaction(callback: @escaping (any ConnectionContext) throws -> R) async throws -> R + 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 + + /// Execute a read transaction with the given callback + func readTransaction( + callback: @escaping (any Transaction) throws -> R + ) async throws -> R } public extension Queries { diff --git a/Sources/PowerSync/protocol/db/CrudEntry.swift b/Sources/PowerSync/protocol/db/CrudEntry.swift index 5049145..1c55330 100644 --- a/Sources/PowerSync/protocol/db/CrudEntry.swift +++ b/Sources/PowerSync/protocol/db/CrudEntry.swift @@ -1,3 +1,4 @@ +/// Represents the type of update operation that can be performed on a row. public enum UpdateType: String, Codable { /// Insert or replace a row. All non-null columns are included in the data. case put = "PUT" @@ -8,10 +9,16 @@ public enum UpdateType: String, Codable { /// Delete a row if it exists. 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) @@ -20,11 +27,23 @@ public enum UpdateType: String, Codable { } } +/// Represents a CRUD (Create, Read, Update, Delete) entry in the system. public protocol CrudEntry { + /// The unique identifier of the entry. var id: String { get } - var clientId: Int32 { 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 } - var transactionId: Int32? { 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 index af18ea9..48113b1 100644 --- a/Sources/PowerSync/protocol/db/CrudTransaction.swift +++ b/Sources/PowerSync/protocol/db/CrudTransaction.swift @@ -5,7 +5,7 @@ public protocol CrudTransaction { /// Unique transaction id. /// /// If nil, this contains a list of changes recorded without an explicit transaction associated. - var transactionId: Int32? { get } + var transactionId: Int64? { get } /// List of client-side changes. var crud: [any CrudEntry] { get } diff --git a/Sources/PowerSync/protocol/db/JsonParam.swift b/Sources/PowerSync/protocol/db/JsonParam.swift index 848b403..fe5a652 100644 --- a/Sources/PowerSync/protocol/db/JsonParam.swift +++ b/Sources/PowerSync/protocol/db/JsonParam.swift @@ -1,11 +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 index c01d572..d68bab4 100644 --- a/Sources/PowerSync/protocol/db/SqlCursor.swift +++ b/Sources/PowerSync/protocol/db/SqlCursor.swift @@ -1,36 +1,115 @@ +/// 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 index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Bool` value if present, or `nil` if the value is null. func getBoolean(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 index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Double` value if present, or `nil` if the value is null. func getDouble(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 index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Int` value if present, or `nil` if the value is null. func getInt(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 index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `Int64` value if present, or `nil` if the value is null. func getInt64(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 index. + /// - Parameter index: The zero-based index of the column. + /// - Returns: The `String` value if present, or `nil` if the value is null. func getString(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: Dictionary { get } } + +/// An error type representing issues encountered while working with `SqlCursor`. enum SqlCursorError: Error { + /// Represents a null value or a missing column. + /// - Parameter message: A descriptive message about the error. case nullValue(message: String) + /// Creates an error for a column that was not found. + /// - Parameter name: The name of the missing column. + /// - Returns: A `SqlCursorError` indicating the column was not found. static func columnNotFound(_ name: String) -> SqlCursorError { .nullValue(message: "Column '\(name)' not found") } + /// Creates an error for a null value found in a column. + /// - Parameter name: The name of the column with a null value. + /// - Returns: A `SqlCursorError` indicating a null value was found. static func nullValueFound(_ name: String) -> SqlCursorError { .nullValue(message: "Null value found for column \(name)") } diff --git a/Sources/PowerSync/protocol/db/Transaction.swift b/Sources/PowerSync/protocol/db/Transaction.swift index 0dbad74..153e4f3 100644 --- a/Sources/PowerSync/protocol/db/Transaction.swift +++ b/Sources/PowerSync/protocol/db/Transaction.swift @@ -1,4 +1,3 @@ - -public protocol Transaction: ConnectionContext { - -} +/// 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 index 9be0115..0ff8a1d 100644 --- a/Sources/PowerSync/protocol/sync/BucketPriority.swift +++ b/Sources/PowerSync/protocol/sync/BucketPriority.swift @@ -1,19 +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 } - // Reverse sorting: higher `priorityCode` means lower priority + /// 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 } - // MARK: - Predefined priorities + /// 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 index 2b77a0c..be9bc4b 100644 --- a/Sources/PowerSync/protocol/sync/PriorityStatusEntry.swift +++ b/Sources/PowerSync/protocol/sync/PriorityStatusEntry.swift @@ -1,7 +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/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 4ae3bc3..cf9d0b7 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -11,8 +11,8 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { Table(name: "users", columns: [ .text("name"), .text("email"), - .text("photo_id") - ]) + .text("photo_id"), + ]), ]) database = KotlinPowerSyncDatabaseImpl( @@ -26,7 +26,7 @@ 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. + // 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. @@ -71,8 +71,6 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTAssertEqual(user.1, "Test User") XCTAssertEqual(user.2, "test@example.com") } - - func testGetError() async throws { do { @@ -151,7 +149,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 { @@ -427,7 +429,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { 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 @@ -478,46 +480,46 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { 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) } From d6fd7ebdc6f46769a37b858e9256e186b2485401 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 10:35:52 +0200 Subject: [PATCH 03/24] cleanup apis --- Sources/PowerSync/Kotlin/DatabaseLogger.swift | 2 +- Sources/PowerSync/Kotlin/KotlinAdapter.swift | 5 +- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 105 +++++--- Sources/PowerSync/Kotlin/KotlinTypes.swift | 1 - Sources/PowerSync/Kotlin/SafeCastError.swift | 18 +- .../Kotlin/TransactionCallback.swift | 10 +- .../Kotlin/db/KotlinConnectionContext.swift | 16 +- .../PowerSync/Kotlin/db/KotlinCrudBatch.swift | 10 +- .../PowerSync/Kotlin/db/KotlinCrudEntry.swift | 5 +- .../Kotlin/db/KotlinCrudTransaction.swift | 1 + .../PowerSync/Kotlin/db/KotlinJsonParam.swift | 29 +++ .../PowerSync/Kotlin/db/KotlinSqlCursor.swift | 58 ++++- .../Kotlin/sync/KotlinSyncStatus.swift | 9 +- .../Kotlin/sync/KotlinSyncStatusData.swift | 15 +- .../PowerSync/Kotlin/wrapQueryCursor.swift | 37 +-- .../PowerSync/PowerSyncDatabaseProtocol.swift | 126 ++++++++-- Sources/PowerSync/QueriesProtocol.swift | 41 +--- .../attachments/AttachmentContext.swift | 2 +- .../attachments/AttachmentQueue.swift | 2 +- .../attachments/AttachmentService.swift | 2 +- .../attachments/SyncingService.swift | 2 +- Sources/PowerSync/protocol/db/CrudEntry.swift | 2 +- .../protocol/db/CrudTransaction.swift | 11 + Sources/PowerSync/protocol/db/JsonParam.swift | 8 +- Sources/PowerSync/protocol/db/SqlCursor.swift | 96 ++++++-- .../protocol/sync/SyncStatusData.swift | 35 ++- Tests/PowerSyncTests/AttachmentTests.swift | 39 ++- Tests/PowerSyncTests/ConnectTests.swift | 91 +++++++ Tests/PowerSyncTests/CrudTests.swift | 84 +++++++ .../KotlinPowerSyncDatabaseImplTests.swift | 80 ++++-- .../Kotlin/SqlCursorTests.swift | 232 ++++++++++++++++-- .../test-utils/MockConnector.swift | 5 + 32 files changed, 932 insertions(+), 247 deletions(-) create mode 100644 Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift create mode 100644 Tests/PowerSyncTests/ConnectTests.swift create mode 100644 Tests/PowerSyncTests/CrudTests.swift create mode 100644 Tests/PowerSyncTests/test-utils/MockConnector.swift 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 0e121f8..f6f00ca 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -43,21 +43,20 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { func connect( connector: PowerSyncBackendConnector, - crudThrottleMs: Int64 = 1000, - retryDelayMs: Int64 = 5000, - params: 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, - // We map to basic values and use NSNull to avoid SKIEE thinking the values must be of Any type - params: params.mapValues { $0.toValue() ?? NSNull() } + crudThrottleMs: resolvedOptions.crudThrottleMs, + retryDelayMs: resolvedOptions.retryDelayMs, + params: resolvedOptions.params.mapValues { $0.toKotlinMap() } ) } @@ -93,6 +92,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { ) } + @discardableResult func execute(sql: String, parameters: [Any?]?) async throws -> Int64 { try await writeTransaction { ctx in try ctx.execute( @@ -280,27 +280,31 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { func writeLock( callback: @escaping (any ConnectionContext) throws -> R ) async throws -> R { - return try safeCast( - await kotlinDatabase.writeLock( - callback: LockCallback( - callback: callback - ) - ), - to: R.self - ) + return try await wrapPowerSyncException { + try safeCast( + await kotlinDatabase.writeLock( + callback: LockCallback( + callback: callback + ) + ), + to: R.self + ) + } } func writeTransaction( callback: @escaping (any Transaction) throws -> R ) async throws -> R { - return try safeCast( - await kotlinDatabase.writeTransaction( - callback: TransactionCallback( - callback: callback - ) - ), - to: R.self - ) + return try await wrapPowerSyncException { + try safeCast( + await kotlinDatabase.writeTransaction( + callback: TransactionCallback( + callback: callback + ) + ), + to: R.self + ) + } } func readLock( @@ -308,30 +312,53 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { ) async throws -> R { - return try safeCast( - await kotlinDatabase.readLock( - callback: LockCallback( - callback: callback - ) - ), - to: R.self - ) + 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 safeCast( - await kotlinDatabase.readTransaction( - callback: TransactionCallback( - callback: callback - ) - ), - to: R.self - ) + 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 remaining errors as-is + throw error + } + } } diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift index 70b902a..18edcbd 100644 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ b/Sources/PowerSync/Kotlin/KotlinTypes.swift @@ -3,4 +3,3 @@ import PowerSyncKotlin typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase - diff --git a/Sources/PowerSync/Kotlin/SafeCastError.swift b/Sources/PowerSync/Kotlin/SafeCastError.swift index 4eb3c28..35ef8cb 100644 --- a/Sources/PowerSync/Kotlin/SafeCastError.swift +++ b/Sources/PowerSync/Kotlin/SafeCastError.swift @@ -12,16 +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 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/TransactionCallback.swift b/Sources/PowerSync/Kotlin/TransactionCallback.swift index 05df36d..78f460d 100644 --- a/Sources/PowerSync/Kotlin/TransactionCallback.swift +++ b/Sources/PowerSync/Kotlin/TransactionCallback.swift @@ -1,5 +1,6 @@ import PowerSyncKotlin +/// Internal Wrapper for Kotlin lock context lambdas class LockCallback: PowerSyncKotlin.ThrowableLockCallback { let callback: (ConnectionContext) throws -> R @@ -31,12 +32,15 @@ class LockCallback: PowerSyncKotlin.ThrowableLockCallback { } 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 @@ -54,7 +58,9 @@ class TransactionCallback: PowerSyncKotlin.ThrowableTransactionCallback { } catch { return PowerSyncKotlin.PowerSyncException( message: error.localizedDescription, - cause: PowerSyncKotlin.KotlinThrowable(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 index c1298dc..dbfca2d 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift @@ -1,10 +1,14 @@ 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( @@ -12,7 +16,7 @@ extension KotlinConnectionContextProtocol { parameters: mapParameters(parameters) ) } - + func getOptional( sql: String, parameters: [Any?]?, @@ -30,7 +34,7 @@ extension KotlinConnectionContextProtocol { resultType: RowType?.self ) } - + func getAll( sql: String, parameters: [Any?]?, @@ -48,7 +52,7 @@ extension KotlinConnectionContextProtocol { resultType: [RowType].self ) } - + func get( sql: String, parameters: [Any?]?, @@ -70,7 +74,7 @@ extension KotlinConnectionContextProtocol { class KotlinConnectionContext: KotlinConnectionContextProtocol { let ctx: PowerSyncKotlin.ConnectionContext - + init(ctx: PowerSyncKotlin.ConnectionContext) { self.ctx = ctx } @@ -78,14 +82,14 @@ class KotlinConnectionContext: KotlinConnectionContextProtocol { 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 -internal func mapParameters(_ parameters: [Any?]?) -> [Any] { +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 index 01fa5d3..f94c29c 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift @@ -1,10 +1,14 @@ import PowerSyncKotlin +/// Implements `CrudBatch` using the Kotlin SDK struct KotlinCrudBatch: CrudBatch { let batch: PowerSyncKotlin.CrudBatch let crud: [CrudEntry] - init (batch: PowerSyncKotlin.CrudBatch) throws { + init( + batch: PowerSyncKotlin.CrudBatch) + throws + { self.batch = batch self.crud = try batch.crud.map { try KotlinCrudEntry( entry: $0 @@ -15,7 +19,9 @@ struct KotlinCrudBatch: CrudBatch { batch.hasMore } - func complete(writeCheckpoint: String?) async throws { + 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 index 64c89ed..53bbbf5 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift @@ -1,10 +1,13 @@ import PowerSyncKotlin +/// Implements `CrudEntry` using the KotlinSDK struct KotlinCrudEntry : CrudEntry { let entry: PowerSyncKotlin.CrudEntry let op: UpdateType - init (entry: PowerSyncKotlin.CrudEntry) throws { + init ( + entry: PowerSyncKotlin.CrudEntry + ) throws { self.entry = entry self.op = try UpdateType.fromString(entry.op.name) } diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift index c87cc13..fe4e2f5 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift @@ -1,5 +1,6 @@ import PowerSyncKotlin +/// Implements `CrudTransaction` using the Kotlin SDK struct KotlinCrudTransaction: CrudTransaction { let transaction: PowerSyncKotlin.CrudTransaction let crud: [CrudEntry] 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 index 78109f2..712df99 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift @@ -1,19 +1,27 @@ import PowerSyncKotlin +/// Implements `SqlCursor` using the Kotlin SDK class KotlinSqlCursor: SqlCursor { let base: PowerSyncKotlin.SqlCursor var columnCount: Int - var columnNames: Dictionary + 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 } + self.columnNames = base.columnNames.mapValues { input in input.intValue } } - func getBoolean(index: Int) -> Bool? { + 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 @@ -32,7 +40,14 @@ class KotlinSqlCursor: SqlCursor { return try base.getBooleanOptional(name: name)?.boolValue } - func getDouble(index: Int) -> Double? { + 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 } @@ -43,13 +58,20 @@ class KotlinSqlCursor: SqlCursor { } return result } - + func getDoubleOptional(name: String) throws -> Double? { try guardColumnName(name) return try base.getDoubleOptional(name: name)?.doubleValue } - func getInt(index: Int) -> Int? { + 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 } @@ -66,7 +88,14 @@ class KotlinSqlCursor: SqlCursor { return try base.getLongOptional(name: name)?.intValue } - func getInt64(index: Int) -> Int64? { + 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 } @@ -82,8 +111,15 @@ class KotlinSqlCursor: SqlCursor { try guardColumnName(name) return try base.getLongOptional(name: name)?.int64Value } + + func getString(index: Int) throws -> String { + guard let result = getStringOptional(index: index) else { + throw SqlCursorError.nullValueFound(String(index)) + } + return result + } - func getString(index: Int) -> String? { + func getStringOptional(index: Int) -> String? { base.getString(index: Int32(index)) } @@ -100,11 +136,11 @@ class KotlinSqlCursor: SqlCursor { guard let columnIndex = columnNames[name] else { throw SqlCursorError.columnNotFound(name) } - return getString(index: columnIndex) + return getStringOptional(index: columnIndex) } - + @discardableResult - private func guardColumnName(_ name: String) throws -> Int { + private func guardColumnName(_ name: String) throws -> Int { guard let index = columnNames[name] else { throw SqlCursorError.columnNotFound(name) } diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift index e7eedfd..b71615f 100644 --- a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift @@ -4,17 +4,17 @@ 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 + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in // Create an outer task to monitor cancellation let task = Task { do { @@ -23,7 +23,7 @@ class KotlinSyncStatus: KotlinSyncStatusDataProtocol, SyncStatus { // Check if the outer task is cancelled try Task.checkCancellation() // This checks if the calling task was cancelled - continuation.yield( + continuation.yield( KotlinSyncStatusData(base: value) ) } @@ -40,5 +40,4 @@ class KotlinSyncStatus: KotlinSyncStatusDataProtocol, SyncStatus { } } } - } diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift index c54e215..a1dd744 100644 --- a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift +++ b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift @@ -1,6 +1,7 @@ -import PowerSyncKotlin 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 } } @@ -9,6 +10,7 @@ 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 @@ -29,8 +31,9 @@ extension KotlinSyncStatusDataProtocol { var lastSyncedAt: Date? { guard let lastSyncedAt = base.lastSyncedAt else { return nil } return Date( - timeIntervalSince1970: Double(lastSyncedAt.epochSeconds - ) + timeIntervalSince1970: Double( + lastSyncedAt.epochSeconds + ) ) } @@ -65,10 +68,12 @@ extension KotlinSyncStatusDataProtocol { private func mapPriorityStatus(_ status: PowerSyncKotlin.PriorityStatusEntry) -> PriorityStatusEntry { var lastSyncedAt: Date? if let syncedAt = status.lastSyncedAt { - lastSyncedAt = Date(timeIntervalSince1970: Double(syncedAt.epochSeconds)) + lastSyncedAt = Date( + timeIntervalSince1970: Double(syncedAt.epochSeconds) + ) } - return PriorityStatusEntry( + 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 e1b2127..fa20003 100644 --- a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift +++ b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift @@ -1,18 +1,18 @@ 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. +/// 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 @@ -24,7 +24,9 @@ func wrapQueryCursor( // In the case of an exception this will return an empty result. let wrappedMapper: (PowerSyncKotlin.SqlCursor) -> RowType? = { cursor in do { - return try mapper(KotlinSqlCursor(base: cursor)) + return try mapper(KotlinSqlCursor( + base: cursor + )) } catch { // Store the error in order to propagate it mapperException = error @@ -34,9 +36,10 @@ func wrapQueryCursor( } let executionResult = try executor(wrappedMapper) - if mapperException != nil { - // Allow propagating the error - throw mapperException! + + if let mapperException { + // Allow propagating the error + throw mapperException } return executionResult diff --git a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/PowerSyncDatabaseProtocol.swift index 8fa6a0e..38497cc 100644 --- a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/PowerSyncDatabaseProtocol.swift @@ -1,5 +1,52 @@ import Foundation +/// Options for configuring a PowerSync connection. +/// +/// Provides optional parameters to customize sync behavior such as throttling and retry policies. +public struct ConnectOptions { + /// Time in milliseconds between CRUD (Create, Read, Update, Delete) operations. + /// + /// Default is `1000` ms (1 second). + /// Increase this value to reduce load on the backend server. + public var crudThrottleMs: Int64 + + /// Delay in milliseconds before retrying after a connection failure. + /// + /// Default is `5000` ms (5 seconds). + /// Increase this value to wait longer before retrying connections in case of persistent failures. + public var retryDelayMs: Int64 + + /// 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: + /// - crudThrottleMs: Time between CRUD operations in milliseconds. Defaults to `1000`. + /// - retryDelayMs: Delay between retry attempts in milliseconds. Defaults to `5000`. + /// - params: Custom sync parameters to send to the server. Defaults to an empty dictionary. + public init( + crudThrottleMs: Int64 = 1000, + retryDelayMs: Int64 = 5000, + params: JsonParam = [:] + ) { + self.crudThrottleMs = crudThrottleMs + self.retryDelayMs = retryDelayMs + self.params = params + } +} + + /// A PowerSync managed database. /// /// Use one instance per database file. @@ -27,36 +74,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 +163,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 + /// - crudThrottleMs: Time between CRUD operations. Defaults to 1000ms. + /// - retryDelayMs: Delay between retries after failure. Defaults to 5000ms. + /// - 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?] = [:] + params: JsonParam = [:] ) async throws { try await connect( connector: connector, - crudThrottleMs: crudThrottleMs, - retryDelayMs: retryDelayMs, - params: params + options: ConnectOptions( + crudThrottleMs: crudThrottleMs, + retryDelayMs: retryDelayMs, + 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/QueriesProtocol.swift b/Sources/PowerSync/QueriesProtocol.swift index cf7152f..ebca0a6 100644 --- a/Sources/PowerSync/QueriesProtocol.swift +++ b/Sources/PowerSync/QueriesProtocol.swift @@ -27,15 +27,6 @@ public protocol Queries { @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?]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> RowType - /// 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. @@ -45,13 +36,6 @@ public protocol Queries { 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, @@ -59,13 +43,6 @@ public protocol Queries { 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, @@ -73,14 +50,6 @@ public protocol Queries { mapper: @escaping (SqlCursor) throws -> RowType ) async throws -> RowType? - /// 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?]?, - 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( @@ -102,7 +71,7 @@ public protocol Queries { 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 @@ -122,28 +91,28 @@ public extension Queries { 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: [Any?](), mapper: mapper) } diff --git a/Sources/PowerSync/attachments/AttachmentContext.swift b/Sources/PowerSync/attachments/AttachmentContext.swift index c7f01a6..cf836da 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 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/Sources/PowerSync/protocol/db/CrudEntry.swift b/Sources/PowerSync/protocol/db/CrudEntry.swift index 1c55330..4e4a3c5 100644 --- a/Sources/PowerSync/protocol/db/CrudEntry.swift +++ b/Sources/PowerSync/protocol/db/CrudEntry.swift @@ -1,4 +1,4 @@ -/// Represents the type of update operation that can be performed on a row. +/// Represents the type of CRUD update operation that can be performed on a row. public enum UpdateType: String, Codable { /// Insert or replace a row. All non-null columns are included in the data. case put = "PUT" diff --git a/Sources/PowerSync/protocol/db/CrudTransaction.swift b/Sources/PowerSync/protocol/db/CrudTransaction.swift index 48113b1..3ce8147 100644 --- a/Sources/PowerSync/protocol/db/CrudTransaction.swift +++ b/Sources/PowerSync/protocol/db/CrudTransaction.swift @@ -15,3 +15,14 @@ public protocol CrudTransaction { /// `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 index fe5a652..a9d2835 100644 --- a/Sources/PowerSync/protocol/db/JsonParam.swift +++ b/Sources/PowerSync/protocol/db/JsonParam.swift @@ -2,7 +2,7 @@ /// /// Supports all standard JSON types: string, number (integer and double), /// boolean, null, arrays, and nested objects. -public enum JSONValue: Codable { +public enum JsonValue: Codable { /// A JSON string value. case string(String) @@ -19,10 +19,10 @@ public enum JSONValue: Codable { case null /// A JSON array containing a list of `JSONValue` elements. - case array([JSONValue]) + case array([JsonValue]) /// A JSON object containing key-value pairs where values are `JSONValue` instances. - case object([String: JSONValue]) + case object([String: JsonValue]) /// Converts the `JSONValue` into a native Swift representation. /// @@ -53,4 +53,4 @@ public enum JSONValue: Codable { } /// A typealias representing a top-level JSON object with string keys and `JSONValue` values. -public typealias JsonParam = [String: JSONValue] +public typealias JsonParam = [String: JsonValue] diff --git a/Sources/PowerSync/protocol/db/SqlCursor.swift b/Sources/PowerSync/protocol/db/SqlCursor.swift index d68bab4..ffd0acd 100644 --- a/Sources/PowerSync/protocol/db/SqlCursor.swift +++ b/Sources/PowerSync/protocol/db/SqlCursor.swift @@ -1,9 +1,17 @@ +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 getBoolean(index: Int) -> Bool? + func getBooleanOptional(index: Int) -> Bool? /// Retrieves a `Bool` value from the specified column name. /// - Parameter name: The name of the column. @@ -17,10 +25,16 @@ public protocol SqlCursor { /// - 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 getDouble(index: Int) -> Double? + func getDoubleOptional(index: Int) -> Double? /// Retrieves a `Double` value from the specified column name. /// - Parameter name: The name of the column. @@ -34,10 +48,16 @@ public protocol SqlCursor { /// - 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 getInt(index: Int) -> Int? + func getIntOptional(index: Int) -> Int? /// Retrieves an `Int` value from the specified column name. /// - Parameter name: The name of the column. @@ -51,10 +71,16 @@ public protocol SqlCursor { /// - 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 getInt64(index: Int) -> Int64? + func getInt64Optional(index: Int) -> Int64? /// Retrieves an `Int64` value from the specified column name. /// - Parameter name: The name of the column. @@ -68,10 +94,16 @@ public protocol SqlCursor { /// - 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 getString(index: Int) -> String? + func getStringOptional(index: Int) -> String? /// Retrieves a `String` value from the specified column name. /// - Parameter name: The name of the column. @@ -89,28 +121,46 @@ public protocol SqlCursor { var columnCount: Int { get } /// A dictionary mapping column names to their zero-based indices. - var columnNames: Dictionary { get } + var columnNames: [String: Int] { get } } - - -/// An error type representing issues encountered while working with `SqlCursor`. +/// An error type representing issues encountered while working with a `SqlCursor`. enum SqlCursorError: Error { - /// Represents a null value or a missing column. - /// - Parameter message: A descriptive message about the error. - case nullValue(message: String) - - /// Creates an error for a column that was not found. - /// - Parameter name: The name of the missing column. - /// - Returns: A `SqlCursorError` indicating the column was not found. - static func columnNotFound(_ name: String) -> SqlCursorError { - .nullValue(message: "Column '\(name)' not found") + /// 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 + } } +} - /// Creates an error for a null value found in a column. - /// - Parameter name: The name of the column with a null value. - /// - Returns: A `SqlCursorError` indicating a null value was found. - static func nullValueFound(_ name: String) -> SqlCursorError { - .nullValue(message: "Null value found for column \(name)") +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/sync/SyncStatusData.swift b/Sources/PowerSync/protocol/sync/SyncStatusData.swift index 71e98ea..d4aa035 100644 --- a/Sources/PowerSync/protocol/sync/SyncStatusData.swift +++ b/Sources/PowerSync/protocol/sync/SyncStatusData.swift @@ -1,22 +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 } - -public protocol SyncStatus : SyncStatusData { +/// 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/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index cecc6f5..cd0bbf7 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! } } + +internal 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..56fa1b4 --- /dev/null +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -0,0 +1,84 @@ +@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 txBatch = try await database.getNextCrudTransaction() else { + return XCTFail("Failed to get transaction crud batch") + } + + XCTAssert(txBatch.crud.count == 100) + + // Completing the transaction should clear the items + try await txBatch.complete() + + let afterCompleteBatch = try await database.getNextCrudTransaction() + XCTAssertNil(afterCompleteBatch) + } +} diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index cf9d0b7..d2dfefa 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"] ) @@ -112,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") @@ -140,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"] ) @@ -162,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"] ) @@ -231,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) } )) @@ -272,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 @@ -330,7 +328,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getInt(index: 0) + try cursor.getInt(index: 0) } XCTAssertEqual(result, 2) @@ -357,7 +355,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getInt(index: 0) + try cursor.getInt(index: 0) } XCTAssertEqual(result, 2 * loopCount) @@ -402,7 +400,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let result = try await database.getOptional( sql: "SELECT COUNT(*) FROM users", parameters: [] - ) { cursor in cursor.getInt(index: 0) + ) { cursor in try cursor.getInt(index: 0) } XCTAssertEqual(result, 0) @@ -419,7 +417,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM users", parameters: [] ) { cursor in - cursor.getInt(index: 0) + try cursor.getInt(index: 0) } XCTAssertEqual(result, 1) @@ -433,7 +431,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT COUNT(*) FROM usersfail", parameters: [] ) { cursor in - cursor.getInt(index: 0) + try cursor.getInt(index: 0) } } } catch { @@ -444,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.getInt(index: 0) + try cursor.getInt(index: 0) } XCTAssertEqual(supported, 1) @@ -476,7 +504,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let peopleCount = try await database.get( sql: "SELECT COUNT(*) FROM people", parameters: [] - ) { cursor in cursor.getInt(index: 0) } + ) { cursor in try cursor.getInt(index: 0) } XCTAssertEqual(peopleCount, 1) } diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index 8f81525..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.getInt(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.getIntOptional(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 { + +} From 1c59de4b338fb99627c0a1bada66ffa5f5ee5578 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 13:47:11 +0200 Subject: [PATCH 04/24] update watched query implementation --- CHANGELOG.md | 5 + Package.resolved | 9 -- Package.swift | 3 +- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 109 ++++++++++++------ Sources/PowerSync/PowerSyncError.swift | 28 +++++ Sources/PowerSync/protocol/db/CrudBatch.swift | 4 +- Tests/PowerSyncTests/CrudTests.swift | 15 ++- .../KotlinPowerSyncDatabaseImplTests.swift | 2 +- 8 files changed, 124 insertions(+), 51 deletions(-) create mode 100644 Sources/PowerSync/PowerSyncError.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e632074..3955e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +# 1.0.0-Beta.14 + +- Removed references to the PowerSync Kotlin SDK from all public API protocols. +- 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. + # 1.0.0-Beta.13 - Update `powersync-kotlin` dependency to version `1.0.0-BETA32`, which includes: diff --git a/Package.resolved b/Package.resolved index 9dfc8b4..32c002c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "powersync-kotlin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/powersync-ja/powersync-kotlin.git", - "state" : { - "revision" : "144d2110eaca2537f49f5e86e5a6c78acf502f94", - "version" : "1.0.0-BETA32.0" - } - }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 38048da..f9d9d9f 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,8 @@ 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", exact: "1.0.0-BETA32.0"), + .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.12"..<"0.4.0") ], targets: [ diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index d1d2e3b..3033068 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -5,6 +5,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { let logger: any LoggerProtocol private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase + private let encoder = JSONEncoder() let currentStatus: SyncStatus init( @@ -221,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: mapParameters(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: mapParameters(options.parameters), + for try await _ in try self.kotlinDatabase.onChange( + tables: Set(watchedTables), throttleMs: options.throttleMs, - mapper: { cursor in - do { - return try options.mapper(KotlinSqlCursor(base: cursor)) - } catch { - mapperError = error - return () - } - } + 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() @@ -352,13 +340,66 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { return try await handler() } catch { // Try and parse errors back from the Kotlin side - if let mapperError = SqlCursorError.fromDescription(error.localizedDescription) { throw mapperError } - // Throw remaining errors as-is - throw error + 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) + let tableRows = try await getAll( + sql: "SELECT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))", + parameters: [ + String(data: pagesData, encoding: .utf8) + ] + ) { 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/PowerSyncError.swift b/Sources/PowerSync/PowerSyncError.swift new file mode 100644 index 0000000..a33c26e --- /dev/null +++ b/Sources/PowerSync/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/protocol/db/CrudBatch.swift b/Sources/PowerSync/protocol/db/CrudBatch.swift index 3191b36..fc48468 100644 --- a/Sources/PowerSync/protocol/db/CrudBatch.swift +++ b/Sources/PowerSync/protocol/db/CrudBatch.swift @@ -4,9 +4,7 @@ import Foundation /// A transaction of client-side changes. public protocol CrudBatch { - /// Unique transaction id. - /// - /// If nil, this contains a list of changes recorded without an explicit transaction associated. + /// 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. diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index 56fa1b4..408124c 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -69,16 +69,25 @@ final class CrudTests: XCTestCase { XCTAssert(fullBatch.hasMore == false) XCTAssert(fullBatch.crud.count == 100) - guard let txBatch = try await database.getNextCrudTransaction() else { + guard let nextTx = try await database.getNextCrudTransaction() else { return XCTFail("Failed to get transaction crud batch") } - XCTAssert(txBatch.crud.count == 100) + XCTAssert(nextTx.crud.count == 100) + + for r in nextTx.crud { + print(r) + } // Completing the transaction should clear the items - try await txBatch.complete() + 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 d2dfefa..beaff22 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -96,7 +96,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT name FROM users WHERE id = ?", parameters: ["999"] ) { cursor in - try cursor.getString(name: "") + try cursor.getString(name: "name") } XCTAssertNil(nonExistent) From 074f35d3d9475d3d8178671c9864255e41fed4a9 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 13:52:56 +0200 Subject: [PATCH 05/24] cleanup filestructure --- Sources/PowerSync/{ => Protocol}/LoggerProtocol.swift | 0 Sources/PowerSync/{ => Protocol}/PowerSyncBackendConnector.swift | 0 Sources/PowerSync/{ => Protocol}/PowerSyncDatabaseProtocol.swift | 0 Sources/PowerSync/{ => Protocol}/PowerSyncError.swift | 0 Sources/PowerSync/{ => Protocol}/QueriesProtocol.swift | 0 Sources/PowerSync/{ => Protocol}/Schema/Column.swift | 0 Sources/PowerSync/{ => Protocol}/Schema/Index.swift | 0 Sources/PowerSync/{ => Protocol}/Schema/IndexedColumn.swift | 0 Sources/PowerSync/{ => Protocol}/Schema/Schema.swift | 0 Sources/PowerSync/{ => Protocol}/Schema/Table.swift | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename Sources/PowerSync/{ => Protocol}/LoggerProtocol.swift (100%) rename Sources/PowerSync/{ => Protocol}/PowerSyncBackendConnector.swift (100%) rename Sources/PowerSync/{ => Protocol}/PowerSyncDatabaseProtocol.swift (100%) rename Sources/PowerSync/{ => Protocol}/PowerSyncError.swift (100%) rename Sources/PowerSync/{ => Protocol}/QueriesProtocol.swift (100%) rename Sources/PowerSync/{ => Protocol}/Schema/Column.swift (100%) rename Sources/PowerSync/{ => Protocol}/Schema/Index.swift (100%) rename Sources/PowerSync/{ => Protocol}/Schema/IndexedColumn.swift (100%) rename Sources/PowerSync/{ => Protocol}/Schema/Schema.swift (100%) rename Sources/PowerSync/{ => Protocol}/Schema/Table.swift (100%) 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 100% rename from Sources/PowerSync/PowerSyncDatabaseProtocol.swift rename to Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift diff --git a/Sources/PowerSync/PowerSyncError.swift b/Sources/PowerSync/Protocol/PowerSyncError.swift similarity index 100% rename from Sources/PowerSync/PowerSyncError.swift rename to Sources/PowerSync/Protocol/PowerSyncError.swift diff --git a/Sources/PowerSync/QueriesProtocol.swift b/Sources/PowerSync/Protocol/QueriesProtocol.swift similarity index 100% rename from Sources/PowerSync/QueriesProtocol.swift rename to Sources/PowerSync/Protocol/QueriesProtocol.swift 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 From f0fb9c4679f0b1c2436d4b9bdc65e74b89f3c531 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 13:57:18 +0200 Subject: [PATCH 06/24] temp --- Sources/PowerSync/{Protocol => tP}/LoggerProtocol.swift | 0 .../PowerSync/{Protocol => tP}/PowerSyncBackendConnector.swift | 0 .../PowerSync/{Protocol => tP}/PowerSyncDatabaseProtocol.swift | 0 Sources/PowerSync/{Protocol => tP}/PowerSyncError.swift | 0 Sources/PowerSync/{Protocol => tP}/QueriesProtocol.swift | 0 Sources/PowerSync/{Protocol => tP}/Schema/Column.swift | 0 Sources/PowerSync/{Protocol => tP}/Schema/Index.swift | 0 Sources/PowerSync/{Protocol => tP}/Schema/IndexedColumn.swift | 0 Sources/PowerSync/{Protocol => tP}/Schema/Schema.swift | 0 Sources/PowerSync/{Protocol => tP}/Schema/Table.swift | 0 Sources/PowerSync/{protocol => tP}/db/ConnectionContext.swift | 0 Sources/PowerSync/{protocol => tP}/db/CrudBatch.swift | 0 Sources/PowerSync/{protocol => tP}/db/CrudEntry.swift | 0 Sources/PowerSync/{protocol => tP}/db/CrudTransaction.swift | 0 Sources/PowerSync/{protocol => tP}/db/JsonParam.swift | 0 Sources/PowerSync/{protocol => tP}/db/SqlCursor.swift | 0 Sources/PowerSync/{protocol => tP}/db/Transaction.swift | 0 Sources/PowerSync/{protocol => tP}/sync/BucketPriority.swift | 0 Sources/PowerSync/{protocol => tP}/sync/PriorityStatusEntry.swift | 0 Sources/PowerSync/{protocol => tP}/sync/SyncStatusData.swift | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename Sources/PowerSync/{Protocol => tP}/LoggerProtocol.swift (100%) rename Sources/PowerSync/{Protocol => tP}/PowerSyncBackendConnector.swift (100%) rename Sources/PowerSync/{Protocol => tP}/PowerSyncDatabaseProtocol.swift (100%) rename Sources/PowerSync/{Protocol => tP}/PowerSyncError.swift (100%) rename Sources/PowerSync/{Protocol => tP}/QueriesProtocol.swift (100%) rename Sources/PowerSync/{Protocol => tP}/Schema/Column.swift (100%) rename Sources/PowerSync/{Protocol => tP}/Schema/Index.swift (100%) rename Sources/PowerSync/{Protocol => tP}/Schema/IndexedColumn.swift (100%) rename Sources/PowerSync/{Protocol => tP}/Schema/Schema.swift (100%) rename Sources/PowerSync/{Protocol => tP}/Schema/Table.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/ConnectionContext.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/CrudBatch.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/CrudEntry.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/CrudTransaction.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/JsonParam.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/SqlCursor.swift (100%) rename Sources/PowerSync/{protocol => tP}/db/Transaction.swift (100%) rename Sources/PowerSync/{protocol => tP}/sync/BucketPriority.swift (100%) rename Sources/PowerSync/{protocol => tP}/sync/PriorityStatusEntry.swift (100%) rename Sources/PowerSync/{protocol => tP}/sync/SyncStatusData.swift (100%) diff --git a/Sources/PowerSync/Protocol/LoggerProtocol.swift b/Sources/PowerSync/tP/LoggerProtocol.swift similarity index 100% rename from Sources/PowerSync/Protocol/LoggerProtocol.swift rename to Sources/PowerSync/tP/LoggerProtocol.swift diff --git a/Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift b/Sources/PowerSync/tP/PowerSyncBackendConnector.swift similarity index 100% rename from Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift rename to Sources/PowerSync/tP/PowerSyncBackendConnector.swift diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/tP/PowerSyncDatabaseProtocol.swift similarity index 100% rename from Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift rename to Sources/PowerSync/tP/PowerSyncDatabaseProtocol.swift diff --git a/Sources/PowerSync/Protocol/PowerSyncError.swift b/Sources/PowerSync/tP/PowerSyncError.swift similarity index 100% rename from Sources/PowerSync/Protocol/PowerSyncError.swift rename to Sources/PowerSync/tP/PowerSyncError.swift diff --git a/Sources/PowerSync/Protocol/QueriesProtocol.swift b/Sources/PowerSync/tP/QueriesProtocol.swift similarity index 100% rename from Sources/PowerSync/Protocol/QueriesProtocol.swift rename to Sources/PowerSync/tP/QueriesProtocol.swift diff --git a/Sources/PowerSync/Protocol/Schema/Column.swift b/Sources/PowerSync/tP/Schema/Column.swift similarity index 100% rename from Sources/PowerSync/Protocol/Schema/Column.swift rename to Sources/PowerSync/tP/Schema/Column.swift diff --git a/Sources/PowerSync/Protocol/Schema/Index.swift b/Sources/PowerSync/tP/Schema/Index.swift similarity index 100% rename from Sources/PowerSync/Protocol/Schema/Index.swift rename to Sources/PowerSync/tP/Schema/Index.swift diff --git a/Sources/PowerSync/Protocol/Schema/IndexedColumn.swift b/Sources/PowerSync/tP/Schema/IndexedColumn.swift similarity index 100% rename from Sources/PowerSync/Protocol/Schema/IndexedColumn.swift rename to Sources/PowerSync/tP/Schema/IndexedColumn.swift diff --git a/Sources/PowerSync/Protocol/Schema/Schema.swift b/Sources/PowerSync/tP/Schema/Schema.swift similarity index 100% rename from Sources/PowerSync/Protocol/Schema/Schema.swift rename to Sources/PowerSync/tP/Schema/Schema.swift diff --git a/Sources/PowerSync/Protocol/Schema/Table.swift b/Sources/PowerSync/tP/Schema/Table.swift similarity index 100% rename from Sources/PowerSync/Protocol/Schema/Table.swift rename to Sources/PowerSync/tP/Schema/Table.swift diff --git a/Sources/PowerSync/protocol/db/ConnectionContext.swift b/Sources/PowerSync/tP/db/ConnectionContext.swift similarity index 100% rename from Sources/PowerSync/protocol/db/ConnectionContext.swift rename to Sources/PowerSync/tP/db/ConnectionContext.swift diff --git a/Sources/PowerSync/protocol/db/CrudBatch.swift b/Sources/PowerSync/tP/db/CrudBatch.swift similarity index 100% rename from Sources/PowerSync/protocol/db/CrudBatch.swift rename to Sources/PowerSync/tP/db/CrudBatch.swift diff --git a/Sources/PowerSync/protocol/db/CrudEntry.swift b/Sources/PowerSync/tP/db/CrudEntry.swift similarity index 100% rename from Sources/PowerSync/protocol/db/CrudEntry.swift rename to Sources/PowerSync/tP/db/CrudEntry.swift diff --git a/Sources/PowerSync/protocol/db/CrudTransaction.swift b/Sources/PowerSync/tP/db/CrudTransaction.swift similarity index 100% rename from Sources/PowerSync/protocol/db/CrudTransaction.swift rename to Sources/PowerSync/tP/db/CrudTransaction.swift diff --git a/Sources/PowerSync/protocol/db/JsonParam.swift b/Sources/PowerSync/tP/db/JsonParam.swift similarity index 100% rename from Sources/PowerSync/protocol/db/JsonParam.swift rename to Sources/PowerSync/tP/db/JsonParam.swift diff --git a/Sources/PowerSync/protocol/db/SqlCursor.swift b/Sources/PowerSync/tP/db/SqlCursor.swift similarity index 100% rename from Sources/PowerSync/protocol/db/SqlCursor.swift rename to Sources/PowerSync/tP/db/SqlCursor.swift diff --git a/Sources/PowerSync/protocol/db/Transaction.swift b/Sources/PowerSync/tP/db/Transaction.swift similarity index 100% rename from Sources/PowerSync/protocol/db/Transaction.swift rename to Sources/PowerSync/tP/db/Transaction.swift diff --git a/Sources/PowerSync/protocol/sync/BucketPriority.swift b/Sources/PowerSync/tP/sync/BucketPriority.swift similarity index 100% rename from Sources/PowerSync/protocol/sync/BucketPriority.swift rename to Sources/PowerSync/tP/sync/BucketPriority.swift diff --git a/Sources/PowerSync/protocol/sync/PriorityStatusEntry.swift b/Sources/PowerSync/tP/sync/PriorityStatusEntry.swift similarity index 100% rename from Sources/PowerSync/protocol/sync/PriorityStatusEntry.swift rename to Sources/PowerSync/tP/sync/PriorityStatusEntry.swift diff --git a/Sources/PowerSync/protocol/sync/SyncStatusData.swift b/Sources/PowerSync/tP/sync/SyncStatusData.swift similarity index 100% rename from Sources/PowerSync/protocol/sync/SyncStatusData.swift rename to Sources/PowerSync/tP/sync/SyncStatusData.swift From f23f364db6398fac9b27897b0fdba7dae5ba2d0b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 13:57:33 +0200 Subject: [PATCH 07/24] move --- Sources/PowerSync/{tP => Protocol}/LoggerProtocol.swift | 0 .../PowerSync/{tP => Protocol}/PowerSyncBackendConnector.swift | 0 .../PowerSync/{tP => Protocol}/PowerSyncDatabaseProtocol.swift | 0 Sources/PowerSync/{tP => Protocol}/PowerSyncError.swift | 0 Sources/PowerSync/{tP => Protocol}/QueriesProtocol.swift | 0 Sources/PowerSync/{tP => Protocol}/Schema/Column.swift | 0 Sources/PowerSync/{tP => Protocol}/Schema/Index.swift | 0 Sources/PowerSync/{tP => Protocol}/Schema/IndexedColumn.swift | 0 Sources/PowerSync/{tP => Protocol}/Schema/Schema.swift | 0 Sources/PowerSync/{tP => Protocol}/Schema/Table.swift | 0 Sources/PowerSync/{tP => Protocol}/db/ConnectionContext.swift | 0 Sources/PowerSync/{tP => Protocol}/db/CrudBatch.swift | 0 Sources/PowerSync/{tP => Protocol}/db/CrudEntry.swift | 0 Sources/PowerSync/{tP => Protocol}/db/CrudTransaction.swift | 0 Sources/PowerSync/{tP => Protocol}/db/JsonParam.swift | 0 Sources/PowerSync/{tP => Protocol}/db/SqlCursor.swift | 0 Sources/PowerSync/{tP => Protocol}/db/Transaction.swift | 0 Sources/PowerSync/{tP => Protocol}/sync/BucketPriority.swift | 0 Sources/PowerSync/{tP => Protocol}/sync/PriorityStatusEntry.swift | 0 Sources/PowerSync/{tP => Protocol}/sync/SyncStatusData.swift | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename Sources/PowerSync/{tP => Protocol}/LoggerProtocol.swift (100%) rename Sources/PowerSync/{tP => Protocol}/PowerSyncBackendConnector.swift (100%) rename Sources/PowerSync/{tP => Protocol}/PowerSyncDatabaseProtocol.swift (100%) rename Sources/PowerSync/{tP => Protocol}/PowerSyncError.swift (100%) rename Sources/PowerSync/{tP => Protocol}/QueriesProtocol.swift (100%) rename Sources/PowerSync/{tP => Protocol}/Schema/Column.swift (100%) rename Sources/PowerSync/{tP => Protocol}/Schema/Index.swift (100%) rename Sources/PowerSync/{tP => Protocol}/Schema/IndexedColumn.swift (100%) rename Sources/PowerSync/{tP => Protocol}/Schema/Schema.swift (100%) rename Sources/PowerSync/{tP => Protocol}/Schema/Table.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/ConnectionContext.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/CrudBatch.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/CrudEntry.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/CrudTransaction.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/JsonParam.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/SqlCursor.swift (100%) rename Sources/PowerSync/{tP => Protocol}/db/Transaction.swift (100%) rename Sources/PowerSync/{tP => Protocol}/sync/BucketPriority.swift (100%) rename Sources/PowerSync/{tP => Protocol}/sync/PriorityStatusEntry.swift (100%) rename Sources/PowerSync/{tP => Protocol}/sync/SyncStatusData.swift (100%) diff --git a/Sources/PowerSync/tP/LoggerProtocol.swift b/Sources/PowerSync/Protocol/LoggerProtocol.swift similarity index 100% rename from Sources/PowerSync/tP/LoggerProtocol.swift rename to Sources/PowerSync/Protocol/LoggerProtocol.swift diff --git a/Sources/PowerSync/tP/PowerSyncBackendConnector.swift b/Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift similarity index 100% rename from Sources/PowerSync/tP/PowerSyncBackendConnector.swift rename to Sources/PowerSync/Protocol/PowerSyncBackendConnector.swift diff --git a/Sources/PowerSync/tP/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift similarity index 100% rename from Sources/PowerSync/tP/PowerSyncDatabaseProtocol.swift rename to Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift diff --git a/Sources/PowerSync/tP/PowerSyncError.swift b/Sources/PowerSync/Protocol/PowerSyncError.swift similarity index 100% rename from Sources/PowerSync/tP/PowerSyncError.swift rename to Sources/PowerSync/Protocol/PowerSyncError.swift diff --git a/Sources/PowerSync/tP/QueriesProtocol.swift b/Sources/PowerSync/Protocol/QueriesProtocol.swift similarity index 100% rename from Sources/PowerSync/tP/QueriesProtocol.swift rename to Sources/PowerSync/Protocol/QueriesProtocol.swift diff --git a/Sources/PowerSync/tP/Schema/Column.swift b/Sources/PowerSync/Protocol/Schema/Column.swift similarity index 100% rename from Sources/PowerSync/tP/Schema/Column.swift rename to Sources/PowerSync/Protocol/Schema/Column.swift diff --git a/Sources/PowerSync/tP/Schema/Index.swift b/Sources/PowerSync/Protocol/Schema/Index.swift similarity index 100% rename from Sources/PowerSync/tP/Schema/Index.swift rename to Sources/PowerSync/Protocol/Schema/Index.swift diff --git a/Sources/PowerSync/tP/Schema/IndexedColumn.swift b/Sources/PowerSync/Protocol/Schema/IndexedColumn.swift similarity index 100% rename from Sources/PowerSync/tP/Schema/IndexedColumn.swift rename to Sources/PowerSync/Protocol/Schema/IndexedColumn.swift diff --git a/Sources/PowerSync/tP/Schema/Schema.swift b/Sources/PowerSync/Protocol/Schema/Schema.swift similarity index 100% rename from Sources/PowerSync/tP/Schema/Schema.swift rename to Sources/PowerSync/Protocol/Schema/Schema.swift diff --git a/Sources/PowerSync/tP/Schema/Table.swift b/Sources/PowerSync/Protocol/Schema/Table.swift similarity index 100% rename from Sources/PowerSync/tP/Schema/Table.swift rename to Sources/PowerSync/Protocol/Schema/Table.swift diff --git a/Sources/PowerSync/tP/db/ConnectionContext.swift b/Sources/PowerSync/Protocol/db/ConnectionContext.swift similarity index 100% rename from Sources/PowerSync/tP/db/ConnectionContext.swift rename to Sources/PowerSync/Protocol/db/ConnectionContext.swift diff --git a/Sources/PowerSync/tP/db/CrudBatch.swift b/Sources/PowerSync/Protocol/db/CrudBatch.swift similarity index 100% rename from Sources/PowerSync/tP/db/CrudBatch.swift rename to Sources/PowerSync/Protocol/db/CrudBatch.swift diff --git a/Sources/PowerSync/tP/db/CrudEntry.swift b/Sources/PowerSync/Protocol/db/CrudEntry.swift similarity index 100% rename from Sources/PowerSync/tP/db/CrudEntry.swift rename to Sources/PowerSync/Protocol/db/CrudEntry.swift diff --git a/Sources/PowerSync/tP/db/CrudTransaction.swift b/Sources/PowerSync/Protocol/db/CrudTransaction.swift similarity index 100% rename from Sources/PowerSync/tP/db/CrudTransaction.swift rename to Sources/PowerSync/Protocol/db/CrudTransaction.swift diff --git a/Sources/PowerSync/tP/db/JsonParam.swift b/Sources/PowerSync/Protocol/db/JsonParam.swift similarity index 100% rename from Sources/PowerSync/tP/db/JsonParam.swift rename to Sources/PowerSync/Protocol/db/JsonParam.swift diff --git a/Sources/PowerSync/tP/db/SqlCursor.swift b/Sources/PowerSync/Protocol/db/SqlCursor.swift similarity index 100% rename from Sources/PowerSync/tP/db/SqlCursor.swift rename to Sources/PowerSync/Protocol/db/SqlCursor.swift diff --git a/Sources/PowerSync/tP/db/Transaction.swift b/Sources/PowerSync/Protocol/db/Transaction.swift similarity index 100% rename from Sources/PowerSync/tP/db/Transaction.swift rename to Sources/PowerSync/Protocol/db/Transaction.swift diff --git a/Sources/PowerSync/tP/sync/BucketPriority.swift b/Sources/PowerSync/Protocol/sync/BucketPriority.swift similarity index 100% rename from Sources/PowerSync/tP/sync/BucketPriority.swift rename to Sources/PowerSync/Protocol/sync/BucketPriority.swift diff --git a/Sources/PowerSync/tP/sync/PriorityStatusEntry.swift b/Sources/PowerSync/Protocol/sync/PriorityStatusEntry.swift similarity index 100% rename from Sources/PowerSync/tP/sync/PriorityStatusEntry.swift rename to Sources/PowerSync/Protocol/sync/PriorityStatusEntry.swift diff --git a/Sources/PowerSync/tP/sync/SyncStatusData.swift b/Sources/PowerSync/Protocol/sync/SyncStatusData.swift similarity index 100% rename from Sources/PowerSync/tP/sync/SyncStatusData.swift rename to Sources/PowerSync/Protocol/sync/SyncStatusData.swift From b71eca4cda3283ca6b0e21db06543519e7169ba1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 14:55:49 +0200 Subject: [PATCH 08/24] update demo and changelog --- CHANGELOG.md | 67 ++++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../PowerSync/SupabaseConnector.swift | 11 ++- .../PowerSync/SystemManager.swift | 8 +-- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3955e26..9f37b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,74 @@ # 1.0.0-Beta.14 -- Removed references to the PowerSync Kotlin SDK from all public API protocols. - 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. +- 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. + +- Database and transaction/lock level query `execute` methods now have `@discardableResult` annotation. + +- `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 `JsonParam` 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?.flatMap { +- Date(timeIntervalSince1970: $0.epochSeconds) +- } ++ let time: Date? = db.currentStatus.lastSyncedAt +``` + # 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..f6cc8a5 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "633a2924f7893f7ebeb064cbcd9c202937673633", - "version" : "1.0.0-BETA30.0" + "revision" : "144d2110eaca2537f49f5e86e5a6c78acf502f94", + "version" : "1.0.0-BETA32.0" } }, { 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..acd02a6 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -127,9 +127,9 @@ 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 +141,7 @@ class SystemManager { parameters: [id] ) - return attachmentIDs ?? [] // :( + return attachmentIDs }) if let attachments { From 977fa0e2389b35a747655114d46f2a3b2ddce382 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 15:05:51 +0200 Subject: [PATCH 09/24] more changelog --- CHANGELOG.md | 6 ++++-- Sources/PowerSync/Protocol/db/SqlCursor.swift | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f37b39..ca997f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - 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**: @@ -64,8 +66,8 @@ try await database.connect( - `SyncStatus` values now use Swift primitives for status attributes. `lastSyncedAt` now is of `Date` type. ```diff -- let lastTime: Date? = db.currentStatus.lastSyncedAt?.flatMap { -- Date(timeIntervalSince1970: $0.epochSeconds) +- let lastTime: Date? = db.currentStatus.lastSyncedAt.map { +- Date(timeIntervalSince1970: TimeInterval($0.epochSeconds)) - } + let time: Date? = db.currentStatus.lastSyncedAt ``` diff --git a/Sources/PowerSync/Protocol/db/SqlCursor.swift b/Sources/PowerSync/Protocol/db/SqlCursor.swift index ffd0acd..812e2d8 100644 --- a/Sources/PowerSync/Protocol/db/SqlCursor.swift +++ b/Sources/PowerSync/Protocol/db/SqlCursor.swift @@ -125,7 +125,7 @@ public protocol SqlCursor { } /// An error type representing issues encountered while working with a `SqlCursor`. -enum SqlCursorError: Error { +public enum SqlCursorError: Error { /// An expected column was not found. case columnNotFound(_ name: String) @@ -154,7 +154,7 @@ enum SqlCursorError: Error { } } -extension SqlCursorError: LocalizedError { +public extension SqlCursorError: LocalizedError { public var errorDescription: String? { switch self { case .columnNotFound(let name): From 2277b9dd8bf7b8b60f966dc549fe3b20a0ec9027 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 15:34:23 +0200 Subject: [PATCH 10/24] cleanup --- CHANGELOG.md | 14 +++++++++++++- .../PowerSyncExample/PowerSync/SystemManager.swift | 1 - Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift | 5 ----- Sources/PowerSync/Protocol/db/CrudBatch.swift | 2 -- Sources/PowerSync/Protocol/db/SqlCursor.swift | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca997f2..3ff515c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ - 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. -- 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. +- 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. diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index acd02a6..0246f7f 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -127,7 +127,6 @@ class SystemManager { sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE list_id = ? AND photo_id IS NOT NULL", parameters: [id] ) { cursor in - try cursor.getString(index: 0) } diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift index 712df99..8ad885c 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift @@ -28,7 +28,6 @@ class KotlinSqlCursor: SqlCursor { } func getBoolean(name: String) throws -> Bool { - try guardColumnName(name) guard let result = try getBooleanOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -52,7 +51,6 @@ class KotlinSqlCursor: SqlCursor { } func getDouble(name: String) throws -> Double { - try guardColumnName(name) guard let result = try getDoubleOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -76,7 +74,6 @@ class KotlinSqlCursor: SqlCursor { } func getInt(name: String) throws -> Int { - try guardColumnName(name) guard let result = try getIntOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -100,7 +97,6 @@ class KotlinSqlCursor: SqlCursor { } func getInt64(name: String) throws -> Int64 { - try guardColumnName(name) guard let result = try getInt64Optional(name: name) else { throw SqlCursorError.nullValueFound(name) } @@ -124,7 +120,6 @@ class KotlinSqlCursor: SqlCursor { } func getString(name: String) throws -> String { - try guardColumnName(name) guard let result = try getStringOptional(name: name) else { throw SqlCursorError.nullValueFound(name) } diff --git a/Sources/PowerSync/Protocol/db/CrudBatch.swift b/Sources/PowerSync/Protocol/db/CrudBatch.swift index fc48468..6fb7fe8 100644 --- a/Sources/PowerSync/Protocol/db/CrudBatch.swift +++ b/Sources/PowerSync/Protocol/db/CrudBatch.swift @@ -1,5 +1,3 @@ - - import Foundation /// A transaction of client-side changes. diff --git a/Sources/PowerSync/Protocol/db/SqlCursor.swift b/Sources/PowerSync/Protocol/db/SqlCursor.swift index 812e2d8..46d85d2 100644 --- a/Sources/PowerSync/Protocol/db/SqlCursor.swift +++ b/Sources/PowerSync/Protocol/db/SqlCursor.swift @@ -154,7 +154,7 @@ public enum SqlCursorError: Error { } } -public extension SqlCursorError: LocalizedError { +extension SqlCursorError: LocalizedError { public var errorDescription: String? { switch self { case .columnNotFound(let name): From 24051f9df66b528576099fdb87ced0b2f331c077 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 15:38:27 +0200 Subject: [PATCH 11/24] temp dependency --- Package.resolved | 9 +++++++++ Package.swift | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 32c002c..667d218 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "powersync-kotlin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-kotlin.git", + "state" : { + "branch" : "crudhasmore", + "revision" : "ae49c22188af0b0086b69b14787809b96f82899e" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index f9d9d9f..54cef31 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,8 @@ let package = Package( ], dependencies: [ // .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA32.0"), - .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), + // .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), + .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", .branch("crudhasmore")), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.12"..<"0.4.0") ], targets: [ From 2153b2279d640a6569fc9e3dd9e566975c615019 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 15:51:15 +0200 Subject: [PATCH 12/24] cleanup --- CHANGELOG.md | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- Package.swift | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff515c..8bc1d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,7 @@ let results = try transaction.getAll( } ``` -- Client parameters now need to be specified with strictly typed `JsonParam` enums. +- Client parameters now need to be specified with strictly typed `JsonValue` enums. ```diff try await database.connect( diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f6cc8a5..ffc863b 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "144d2110eaca2537f49f5e86e5a6c78acf502f94", - "version" : "1.0.0-BETA32.0" + "branch" : "crudhasmore", + "revision" : "ae49c22188af0b0086b69b14787809b96f82899e" } }, { diff --git a/Package.swift b/Package.swift index 54cef31..2a7ac00 100644 --- a/Package.swift +++ b/Package.swift @@ -17,9 +17,9 @@ let package = Package( targets: ["PowerSync"]), ], dependencies: [ + // TODO // .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA32.0"), - // .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), - .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", .branch("crudhasmore")), + .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.12"..<"0.4.0") ], targets: [ From 819f9191c9fbca1377961eebdc1f500677af88fa Mon Sep 17 00:00:00 2001 From: stevensJourney <51082125+stevensJourney@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:57:03 +0200 Subject: [PATCH 13/24] Update Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 3033068..fd30617 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -379,11 +379,15 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { 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: [ - String(data: pagesData, encoding: .utf8) - ] + pagesString ) { try $0.getString(index: 0) } return Set(tableRows) From 85a5fbadf59e6f4ff572728e6a82af1cefb1fb8c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 16:59:44 +0200 Subject: [PATCH 14/24] cleanup --- Package.resolved | 9 --------- .../PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift | 3 +++ .../{ => kotlin}/PowerSyncBackendConnectorAdapter.swift | 0 3 files changed, 3 insertions(+), 9 deletions(-) rename Sources/PowerSync/{ => kotlin}/PowerSyncBackendConnectorAdapter.swift (100%) diff --git a/Package.resolved b/Package.resolved index 667d218..32c002c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "powersync-kotlin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/powersync-ja/powersync-kotlin.git", - "state" : { - "branch" : "crudhasmore", - "revision" : "ae49c22188af0b0086b69b14787809b96f82899e" - } - }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index fd30617..e9ad292 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -379,15 +379,18 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { 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) diff --git a/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift similarity index 100% rename from Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift rename to Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift From 1383c4b50cc96b4b3cfe0bbddadd0793d4ab0d59 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 17:02:37 +0200 Subject: [PATCH 15/24] cleanup --- Sources/PowerSync/Protocol/db/CrudEntry.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/PowerSync/Protocol/db/CrudEntry.swift b/Sources/PowerSync/Protocol/db/CrudEntry.swift index 4e4a3c5..58fd037 100644 --- a/Sources/PowerSync/Protocol/db/CrudEntry.swift +++ b/Sources/PowerSync/Protocol/db/CrudEntry.swift @@ -1,12 +1,12 @@ /// Represents the type of CRUD update operation that can be performed on a row. public enum UpdateType: String, Codable { - /// Insert or replace a row. All non-null columns are included in the data. + /// A row has been inserted or replaced case put = "PUT" - /// Update a row if it exists. All updated columns are included in the data. + /// A row has been updated case patch = "PATCH" - /// Delete a row if it exists. + /// A row has been deleted case delete = "DELETE" /// Errors related to invalid `UpdateType` states. From 4e9c2a8e98b9a1af67ba4a3532c58d3407620833 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 17:29:13 +0200 Subject: [PATCH 16/24] deprecate userId --- CHANGELOG.md | 2 ++ Sources/PowerSync/PowerSyncCredentials.swift | 25 ++++++++++++++----- .../attachments/AttachmentContext.swift | 8 +++--- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc1d1d..590a2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - 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: 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/attachments/AttachmentContext.swift b/Sources/PowerSync/attachments/AttachmentContext.swift index cf836da..394d028 100644 --- a/Sources/PowerSync/attachments/AttachmentContext.swift +++ b/Sources/PowerSync/attachments/AttachmentContext.swift @@ -206,12 +206,12 @@ open 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 ] ) From 52ed16d8cab84a3a64ea7ee6e415779ba47339ca Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 29 Apr 2025 17:54:48 +0200 Subject: [PATCH 17/24] update read and write lock docs --- Sources/PowerSync/Protocol/QueriesProtocol.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/PowerSync/Protocol/QueriesProtocol.swift b/Sources/PowerSync/Protocol/QueriesProtocol.swift index ebca0a6..2cf9add 100644 --- a/Sources/PowerSync/Protocol/QueriesProtocol.swift +++ b/Sources/PowerSync/Protocol/QueriesProtocol.swift @@ -62,12 +62,17 @@ public protocol Queries { options: WatchOptions ) throws -> AsyncThrowingStream<[RowType], Error> - /// Execute a write transaction with the given callback + /// 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 - /// Execute a read transaction with the given callback + /// 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 From 223c004be120b71382a89c211a162ae647538569 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 30 Apr 2025 13:09:50 +0200 Subject: [PATCH 18/24] propagate connector upload errors --- .../PowerSync/Kotlin/wrapQueryCursor.swift | 23 +++++++++++++++++++ .../PowerSyncBackendConnectorAdapter.swift | 12 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift index fa20003..05a99ca 100644 --- a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift +++ b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift @@ -60,3 +60,26 @@ func wrapQueryCursorTyped( 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/kotlin/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift index b41c2b3..61212b5 100644 --- a/Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift +++ b/Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift @@ -12,13 +12,16 @@ internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector self.swiftBackendConnector = swiftBackendConnector self.db = db } - + override func __fetchCredentials() async throws -> KotlinPowerSyncCredentials? { do { let result = try await swiftBackendConnector.fetchCredentials() 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, + ) } } } From b6f62a7e08b5f975b83850a3dd34d450d865c973 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 30 Apr 2025 13:39:29 +0200 Subject: [PATCH 19/24] move --- Sources/PowerSync/{Kotlin => ktest}/DatabaseLogger.swift | 0 Sources/PowerSync/{Kotlin => ktest}/KotlinAdapter.swift | 0 .../PowerSync/{Kotlin => ktest}/KotlinPowerSyncDatabaseImpl.swift | 0 Sources/PowerSync/{Kotlin => ktest}/KotlinTypes.swift | 0 .../{kotlin => ktest}/PowerSyncBackendConnectorAdapter.swift | 0 Sources/PowerSync/{Kotlin => ktest}/SafeCastError.swift | 0 Sources/PowerSync/{Kotlin => ktest}/TransactionCallback.swift | 0 .../PowerSync/{Kotlin => ktest}/db/KotlinConnectionContext.swift | 0 Sources/PowerSync/{Kotlin => ktest}/db/KotlinCrudBatch.swift | 0 Sources/PowerSync/{Kotlin => ktest}/db/KotlinCrudEntry.swift | 0 .../PowerSync/{Kotlin => ktest}/db/KotlinCrudTransaction.swift | 0 Sources/PowerSync/{Kotlin => ktest}/db/KotlinJsonParam.swift | 0 Sources/PowerSync/{Kotlin => ktest}/db/KotlinSqlCursor.swift | 0 Sources/PowerSync/{Kotlin => ktest}/sync/KotlinSyncStatus.swift | 0 .../PowerSync/{Kotlin => ktest}/sync/KotlinSyncStatusData.swift | 0 Sources/PowerSync/{Kotlin => ktest}/wrapQueryCursor.swift | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename Sources/PowerSync/{Kotlin => ktest}/DatabaseLogger.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/KotlinAdapter.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/KotlinPowerSyncDatabaseImpl.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/KotlinTypes.swift (100%) rename Sources/PowerSync/{kotlin => ktest}/PowerSyncBackendConnectorAdapter.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/SafeCastError.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/TransactionCallback.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/db/KotlinConnectionContext.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/db/KotlinCrudBatch.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/db/KotlinCrudEntry.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/db/KotlinCrudTransaction.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/db/KotlinJsonParam.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/db/KotlinSqlCursor.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/sync/KotlinSyncStatus.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/sync/KotlinSyncStatusData.swift (100%) rename Sources/PowerSync/{Kotlin => ktest}/wrapQueryCursor.swift (100%) diff --git a/Sources/PowerSync/Kotlin/DatabaseLogger.swift b/Sources/PowerSync/ktest/DatabaseLogger.swift similarity index 100% rename from Sources/PowerSync/Kotlin/DatabaseLogger.swift rename to Sources/PowerSync/ktest/DatabaseLogger.swift diff --git a/Sources/PowerSync/Kotlin/KotlinAdapter.swift b/Sources/PowerSync/ktest/KotlinAdapter.swift similarity index 100% rename from Sources/PowerSync/Kotlin/KotlinAdapter.swift rename to Sources/PowerSync/ktest/KotlinAdapter.swift diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/ktest/KotlinPowerSyncDatabaseImpl.swift similarity index 100% rename from Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift rename to Sources/PowerSync/ktest/KotlinPowerSyncDatabaseImpl.swift diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/ktest/KotlinTypes.swift similarity index 100% rename from Sources/PowerSync/Kotlin/KotlinTypes.swift rename to Sources/PowerSync/ktest/KotlinTypes.swift diff --git a/Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/ktest/PowerSyncBackendConnectorAdapter.swift similarity index 100% rename from Sources/PowerSync/kotlin/PowerSyncBackendConnectorAdapter.swift rename to Sources/PowerSync/ktest/PowerSyncBackendConnectorAdapter.swift diff --git a/Sources/PowerSync/Kotlin/SafeCastError.swift b/Sources/PowerSync/ktest/SafeCastError.swift similarity index 100% rename from Sources/PowerSync/Kotlin/SafeCastError.swift rename to Sources/PowerSync/ktest/SafeCastError.swift diff --git a/Sources/PowerSync/Kotlin/TransactionCallback.swift b/Sources/PowerSync/ktest/TransactionCallback.swift similarity index 100% rename from Sources/PowerSync/Kotlin/TransactionCallback.swift rename to Sources/PowerSync/ktest/TransactionCallback.swift diff --git a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift b/Sources/PowerSync/ktest/db/KotlinConnectionContext.swift similarity index 100% rename from Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift rename to Sources/PowerSync/ktest/db/KotlinConnectionContext.swift diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift b/Sources/PowerSync/ktest/db/KotlinCrudBatch.swift similarity index 100% rename from Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift rename to Sources/PowerSync/ktest/db/KotlinCrudBatch.swift diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift b/Sources/PowerSync/ktest/db/KotlinCrudEntry.swift similarity index 100% rename from Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift rename to Sources/PowerSync/ktest/db/KotlinCrudEntry.swift diff --git a/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift b/Sources/PowerSync/ktest/db/KotlinCrudTransaction.swift similarity index 100% rename from Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift rename to Sources/PowerSync/ktest/db/KotlinCrudTransaction.swift diff --git a/Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift b/Sources/PowerSync/ktest/db/KotlinJsonParam.swift similarity index 100% rename from Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift rename to Sources/PowerSync/ktest/db/KotlinJsonParam.swift diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/ktest/db/KotlinSqlCursor.swift similarity index 100% rename from Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift rename to Sources/PowerSync/ktest/db/KotlinSqlCursor.swift diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift b/Sources/PowerSync/ktest/sync/KotlinSyncStatus.swift similarity index 100% rename from Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift rename to Sources/PowerSync/ktest/sync/KotlinSyncStatus.swift diff --git a/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift b/Sources/PowerSync/ktest/sync/KotlinSyncStatusData.swift similarity index 100% rename from Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift rename to Sources/PowerSync/ktest/sync/KotlinSyncStatusData.swift diff --git a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift b/Sources/PowerSync/ktest/wrapQueryCursor.swift similarity index 100% rename from Sources/PowerSync/Kotlin/wrapQueryCursor.swift rename to Sources/PowerSync/ktest/wrapQueryCursor.swift From 70894b4dc67f157b3bae1a2c6695e8a5011daad4 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 30 Apr 2025 13:39:40 +0200 Subject: [PATCH 20/24] move again --- Sources/PowerSync/{ktest => Kotlin}/DatabaseLogger.swift | 0 Sources/PowerSync/{ktest => Kotlin}/KotlinAdapter.swift | 0 .../PowerSync/{ktest => Kotlin}/KotlinPowerSyncDatabaseImpl.swift | 0 Sources/PowerSync/{ktest => Kotlin}/KotlinTypes.swift | 0 .../{ktest => Kotlin}/PowerSyncBackendConnectorAdapter.swift | 0 Sources/PowerSync/{ktest => Kotlin}/SafeCastError.swift | 0 Sources/PowerSync/{ktest => Kotlin}/TransactionCallback.swift | 0 .../PowerSync/{ktest => Kotlin}/db/KotlinConnectionContext.swift | 0 Sources/PowerSync/{ktest => Kotlin}/db/KotlinCrudBatch.swift | 0 Sources/PowerSync/{ktest => Kotlin}/db/KotlinCrudEntry.swift | 0 .../PowerSync/{ktest => Kotlin}/db/KotlinCrudTransaction.swift | 0 Sources/PowerSync/{ktest => Kotlin}/db/KotlinJsonParam.swift | 0 Sources/PowerSync/{ktest => Kotlin}/db/KotlinSqlCursor.swift | 0 Sources/PowerSync/{ktest => Kotlin}/sync/KotlinSyncStatus.swift | 0 .../PowerSync/{ktest => Kotlin}/sync/KotlinSyncStatusData.swift | 0 Sources/PowerSync/{ktest => Kotlin}/wrapQueryCursor.swift | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename Sources/PowerSync/{ktest => Kotlin}/DatabaseLogger.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/KotlinAdapter.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/KotlinPowerSyncDatabaseImpl.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/KotlinTypes.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/PowerSyncBackendConnectorAdapter.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/SafeCastError.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/TransactionCallback.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/db/KotlinConnectionContext.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/db/KotlinCrudBatch.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/db/KotlinCrudEntry.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/db/KotlinCrudTransaction.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/db/KotlinJsonParam.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/db/KotlinSqlCursor.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/sync/KotlinSyncStatus.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/sync/KotlinSyncStatusData.swift (100%) rename Sources/PowerSync/{ktest => Kotlin}/wrapQueryCursor.swift (100%) diff --git a/Sources/PowerSync/ktest/DatabaseLogger.swift b/Sources/PowerSync/Kotlin/DatabaseLogger.swift similarity index 100% rename from Sources/PowerSync/ktest/DatabaseLogger.swift rename to Sources/PowerSync/Kotlin/DatabaseLogger.swift diff --git a/Sources/PowerSync/ktest/KotlinAdapter.swift b/Sources/PowerSync/Kotlin/KotlinAdapter.swift similarity index 100% rename from Sources/PowerSync/ktest/KotlinAdapter.swift rename to Sources/PowerSync/Kotlin/KotlinAdapter.swift diff --git a/Sources/PowerSync/ktest/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift similarity index 100% rename from Sources/PowerSync/ktest/KotlinPowerSyncDatabaseImpl.swift rename to Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift diff --git a/Sources/PowerSync/ktest/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift similarity index 100% rename from Sources/PowerSync/ktest/KotlinTypes.swift rename to Sources/PowerSync/Kotlin/KotlinTypes.swift diff --git a/Sources/PowerSync/ktest/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift similarity index 100% rename from Sources/PowerSync/ktest/PowerSyncBackendConnectorAdapter.swift rename to Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift diff --git a/Sources/PowerSync/ktest/SafeCastError.swift b/Sources/PowerSync/Kotlin/SafeCastError.swift similarity index 100% rename from Sources/PowerSync/ktest/SafeCastError.swift rename to Sources/PowerSync/Kotlin/SafeCastError.swift diff --git a/Sources/PowerSync/ktest/TransactionCallback.swift b/Sources/PowerSync/Kotlin/TransactionCallback.swift similarity index 100% rename from Sources/PowerSync/ktest/TransactionCallback.swift rename to Sources/PowerSync/Kotlin/TransactionCallback.swift diff --git a/Sources/PowerSync/ktest/db/KotlinConnectionContext.swift b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift similarity index 100% rename from Sources/PowerSync/ktest/db/KotlinConnectionContext.swift rename to Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift diff --git a/Sources/PowerSync/ktest/db/KotlinCrudBatch.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift similarity index 100% rename from Sources/PowerSync/ktest/db/KotlinCrudBatch.swift rename to Sources/PowerSync/Kotlin/db/KotlinCrudBatch.swift diff --git a/Sources/PowerSync/ktest/db/KotlinCrudEntry.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift similarity index 100% rename from Sources/PowerSync/ktest/db/KotlinCrudEntry.swift rename to Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift diff --git a/Sources/PowerSync/ktest/db/KotlinCrudTransaction.swift b/Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift similarity index 100% rename from Sources/PowerSync/ktest/db/KotlinCrudTransaction.swift rename to Sources/PowerSync/Kotlin/db/KotlinCrudTransaction.swift diff --git a/Sources/PowerSync/ktest/db/KotlinJsonParam.swift b/Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift similarity index 100% rename from Sources/PowerSync/ktest/db/KotlinJsonParam.swift rename to Sources/PowerSync/Kotlin/db/KotlinJsonParam.swift diff --git a/Sources/PowerSync/ktest/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift similarity index 100% rename from Sources/PowerSync/ktest/db/KotlinSqlCursor.swift rename to Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift diff --git a/Sources/PowerSync/ktest/sync/KotlinSyncStatus.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift similarity index 100% rename from Sources/PowerSync/ktest/sync/KotlinSyncStatus.swift rename to Sources/PowerSync/Kotlin/sync/KotlinSyncStatus.swift diff --git a/Sources/PowerSync/ktest/sync/KotlinSyncStatusData.swift b/Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift similarity index 100% rename from Sources/PowerSync/ktest/sync/KotlinSyncStatusData.swift rename to Sources/PowerSync/Kotlin/sync/KotlinSyncStatusData.swift diff --git a/Sources/PowerSync/ktest/wrapQueryCursor.swift b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift similarity index 100% rename from Sources/PowerSync/ktest/wrapQueryCursor.swift rename to Sources/PowerSync/Kotlin/wrapQueryCursor.swift From 5700b2380ad8305017c79e61f93f66773f166570 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 30 Apr 2025 16:33:18 +0200 Subject: [PATCH 21/24] improve sqlcursor getter implementation --- .../PowerSync/Kotlin/db/KotlinSqlCursor.swift | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift index 8ad885c..7b57c4a 100644 --- a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift +++ b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift @@ -35,8 +35,7 @@ class KotlinSqlCursor: SqlCursor { } func getBooleanOptional(name: String) throws -> Bool? { - try guardColumnName(name) - return try base.getBooleanOptional(name: name)?.boolValue + return getBooleanOptional(index: try guardColumnName(name)) } func getDouble(index: Int) throws -> Double { @@ -58,8 +57,7 @@ class KotlinSqlCursor: SqlCursor { } func getDoubleOptional(name: String) throws -> Double? { - try guardColumnName(name) - return try base.getDoubleOptional(name: name)?.doubleValue + return getDoubleOptional(index: try guardColumnName(name)) } func getInt(index: Int) throws -> Int { @@ -81,8 +79,7 @@ class KotlinSqlCursor: SqlCursor { } func getIntOptional(name: String) throws -> Int? { - try guardColumnName(name) - return try base.getLongOptional(name: name)?.intValue + return getIntOptional(index: try guardColumnName(name)) } func getInt64(index: Int) throws -> Int64 { @@ -104,8 +101,7 @@ class KotlinSqlCursor: SqlCursor { } func getInt64Optional(name: String) throws -> Int64? { - try guardColumnName(name) - return try base.getLongOptional(name: name)?.int64Value + return getInt64Optional(index: try guardColumnName(name)) } func getString(index: Int) throws -> String { @@ -127,11 +123,7 @@ class KotlinSqlCursor: SqlCursor { } func getStringOptional(name: String) throws -> String? { - /// For some reason this method is not exposed from the Kotlin side - guard let columnIndex = columnNames[name] else { - throw SqlCursorError.columnNotFound(name) - } - return getStringOptional(index: columnIndex) + return getStringOptional(index: try guardColumnName(name)) } @discardableResult From 8da17b5bb64946696a79e1606ba933cea9be7303 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 30 Apr 2025 16:57:36 +0200 Subject: [PATCH 22/24] update time durations from ms to TimeInterval --- CHANGELOG.md | 31 +++++++++++++- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 6 +-- .../Protocol/PowerSyncDatabaseProtocol.swift | 42 +++++++++++-------- .../PowerSync/Protocol/QueriesProtocol.swift | 10 ++--- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 590a2fb..79d31c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -# 1.0.0-Beta.14 +# 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. @@ -86,6 +86,35 @@ try await database.connect( + 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/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index e9ad292..bdc5893 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -55,8 +55,8 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { try await kotlinDatabase.connect( connector: connectorAdapter, - crudThrottleMs: resolvedOptions.crudThrottleMs, - retryDelayMs: resolvedOptions.retryDelayMs, + crudThrottleMs: Int64(resolvedOptions.crudThrottle * 1000), + retryDelayMs: Int64(resolvedOptions.retryDelay * 1000), params: resolvedOptions.params.mapValues { $0.toKotlinMap() } ) } @@ -230,7 +230,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { // Watching for changes in the database for try await _ in try self.kotlinDatabase.onChange( tables: Set(watchedTables), - throttleMs: options.throttleMs, + 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 diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index 38497cc..6f26f9e 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -4,17 +4,23 @@ import Foundation /// /// Provides optional parameters to customize sync behavior such as throttling and retry policies. public struct ConnectOptions { - /// Time in milliseconds between CRUD (Create, Read, Update, Delete) operations. + /// 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 `1000` ms (1 second). + /// Default is ``ConnectOptions/DefaultCrudThrottle``. /// Increase this value to reduce load on the backend server. - public var crudThrottleMs: Int64 + public var crudThrottle: TimeInterval - /// Delay in milliseconds before retrying after a connection failure. + /// Delay TimeInterval (in seconds) before retrying after a connection failure. /// - /// Default is `5000` ms (5 seconds). + /// Default is ``ConnectOptions/DefaultRetryDelay``. /// Increase this value to wait longer before retrying connections in case of persistent failures. - public var retryDelayMs: Int64 + public var retryDelay: TimeInterval /// Additional sync parameters passed to the server during connection. /// @@ -32,16 +38,16 @@ public struct ConnectOptions { /// Initializes a `ConnectOptions` instance with optional values. /// /// - Parameters: - /// - crudThrottleMs: Time between CRUD operations in milliseconds. Defaults to `1000`. - /// - retryDelayMs: Delay between retry attempts in milliseconds. Defaults to `5000`. + /// - 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( - crudThrottleMs: Int64 = 1000, - retryDelayMs: Int64 = 5000, + crudThrottle: TimeInterval = 1, + retryDelay: TimeInterval = 5, params: JsonParam = [:] ) { - self.crudThrottleMs = crudThrottleMs - self.retryDelayMs = retryDelayMs + self.crudThrottle = crudThrottle + self.retryDelay = retryDelay self.params = params } } @@ -168,8 +174,8 @@ public extension PowerSyncDatabaseProtocol { /// /// - Parameters: /// - connector: The PowerSyncBackendConnector to use - /// - crudThrottleMs: Time between CRUD operations. Defaults to 1000ms. - /// - retryDelayMs: Delay between retries after failure. Defaults to 5000ms. + /// - 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: @@ -188,15 +194,15 @@ public extension PowerSyncDatabaseProtocol { /// ) func connect( connector: PowerSyncBackendConnector, - crudThrottleMs: Int64 = 1000, - retryDelayMs: Int64 = 5000, + crudThrottle: TimeInterval = 1, + retryDelay: TimeInterval = 5, params: JsonParam = [:] ) async throws { try await connect( connector: connector, options: ConnectOptions( - crudThrottleMs: crudThrottleMs, - retryDelayMs: retryDelayMs, + crudThrottle: crudThrottle, + retryDelay: retryDelay, params: params ) ) diff --git a/Sources/PowerSync/Protocol/QueriesProtocol.swift b/Sources/PowerSync/Protocol/QueriesProtocol.swift index 2cf9add..1e94702 100644 --- a/Sources/PowerSync/Protocol/QueriesProtocol.swift +++ b/Sources/PowerSync/Protocol/QueriesProtocol.swift @@ -1,22 +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 throttle: TimeInterval public var mapper: (SqlCursor) throws -> RowType public init( sql: String, parameters: [Any?]? = [], - throttleMs: Int64? = DEFAULT_WATCH_THROTTLE_MS, + 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 } } From 798c533fad19dd92d91a6e51c85630443693172a Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 2 May 2025 15:11:00 +0200 Subject: [PATCH 23/24] update package --- Package.resolved | 9 +++++++++ Package.swift | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Package.resolved b/Package.resolved index 877481c..43ac3aa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "powersync-kotlin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-kotlin.git", + "state" : { + "revision" : "ccd2e595195c59d570eb93a878ad6a5cfca72ada", + "version" : "1.0.1+SWIFT.0" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 23cef44..b8b5491 100644 --- a/Package.swift +++ b/Package.swift @@ -17,8 +17,7 @@ let package = Package( targets: ["PowerSync"]), ], dependencies: [ - .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), - // .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: [ From 42f9fa0713801d844a31eb8646a434f4e0da96aa Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 2 May 2025 15:15:13 +0200 Subject: [PATCH 24/24] cleanup --- .../PowerSyncBackendConnectorAdapter.swift | 10 +++++----- Tests/PowerSyncTests/AttachmentTests.swift | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/Kotlin/PowerSyncBackendConnectorAdapter.swift index 61212b5..8e8da4c 100644 --- a/Sources/PowerSync/Kotlin/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 @@ -12,14 +12,14 @@ internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector self.swiftBackendConnector = swiftBackendConnector self.db = db } - + override func __fetchCredentials() async throws -> KotlinPowerSyncCredentials? { do { let result = try await swiftBackendConnector.fetchCredentials() return result?.kotlinCredentials } catch { db.logger.error("Error while fetching credentials", tag: logTag) - /// We can't use throwKotlinPowerSyncError here since the Kotlin connector + /// 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 @@ -35,7 +35,7 @@ internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector // Relay the error to the Kotlin SDK try throwKotlinPowerSyncError( message: "Connector errored while uploading data: \(error.localizedDescription)", - cause: error.localizedDescription, + cause: error.localizedDescription ) } } diff --git a/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index cd0bbf7..b4427e4 100644 --- a/Tests/PowerSyncTests/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -208,10 +208,10 @@ public func waitForMatch( } } -internal func waitFor( +func waitFor( timeout: TimeInterval = 0.5, interval: TimeInterval = 0.1, - predicate: () async throws -> Void, + predicate: () async throws -> Void ) async throws { let intervalNanoseconds = UInt64(interval * 1_000_000_000) @@ -221,14 +221,14 @@ internal func waitFor( var lastError: Error? - while (Date() < timeoutDate) { - do { - try await predicate() - return - } catch { + while Date() < timeoutDate { + do { + try await predicate() + return + } catch { lastError = error - } - try await Task.sleep(nanoseconds: intervalNanoseconds) + } + try await Task.sleep(nanoseconds: intervalNanoseconds) } throw WaitForMatchError.timeout(