diff --git a/CHANGELOG.md b/CHANGELOG.md index 4244c41..97953a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## 1.14.0 (unreleased) +* Remove internal dependency on the PowerSync Kotlin SDK. Going forward, the Swift SDK is implemented in Swift! + __Important__: While these changes are tested, they are a full rewrite of the internal connection pool logic. + Please also test queries in your app after upgrading. * Add `opDataTyped` and `previousValuesTyped` to `CrudEntry`, providing typed values instead of strings. * Make `CrudBatch`, `CrudEntry` and `CrudTransaction` a concrete struct. Note that these can no longer be created in user code. +* Remove the internal `withSession` API. +* Breaking (for internal `SQLiteConnectionPoolProtocol` implementers): Make callbacks generic. +* Breaking (for internal `SQLiteConnectionLease` implementers): Add methods to run statements. ## 1.13.1 diff --git a/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.pbxproj b/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.pbxproj index 5f65cab..346c830 100644 --- a/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.pbxproj +++ b/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.pbxproj @@ -314,7 +314,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ZGT7463CVJ; + DEVELOPMENT_TEAM = N2FNDTNV98; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; @@ -368,7 +368,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ZGT7463CVJ; + DEVELOPMENT_TEAM = N2FNDTNV98; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; diff --git a/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e995dbb..6458b51 100644 --- a/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demos/GRDBDemo/GRDBDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "84d25347b5249e7ab78894935a203b13d9d55e5a01b7fe00745bdf028062df42", + "originHash" : "3de68e554d2ad75cf2c4dbdaf311828c8260cc6008b42e8738726e62912934ce", "pins" : [ { "identity" : "csqlite", "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/CSQLite.git", "state" : { - "revision" : "25f4a02fce2dcd588bad37ea6fc047c2bbe8ef5e", - "version" : "3.51.1" + "revision" : "9f10da4272e292c453db400342d9e16c15e59db5", + "version" : "3.51.2" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "9743740980cd488a11f50cffe62ed34a9739a135", - "version" : "0.4.10" + "revision" : "05c2af384558011f0915d757b6677f5dcbbc5c54", + "version" : "0.4.13" } }, { @@ -55,6 +55,15 @@ "version" : "1.4.0" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -64,6 +73,15 @@ "version" : "1.0.6" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", diff --git a/Demos/PowerSyncExample/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demos/PowerSyncExample/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 54d11b6..ce2aaa3 100644 --- a/Demos/PowerSyncExample/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demos/PowerSyncExample/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { diff --git a/Package.swift b/Package.swift index b067840..02d3d09 100644 --- a/Package.swift +++ b/Package.swift @@ -5,10 +5,6 @@ import PackageDescription let packageName = "PowerSync" -// Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin -// build. Also see docs/LocalBuild.md for details -let localKotlinSdkOverride: String? = nil - // Set this to the absolute path of your powersync-sqlite-core checkout if you want to use a // local build of the core extension. let localCoreExtension: String? = nil @@ -20,24 +16,6 @@ let localCoreExtension: String? = nil // towards a local framework build var conditionalDependencies: [Package.Dependency] = [] var conditionalTargets: [Target] = [] -var kotlinTargetDependency = Target.Dependency.target(name: "PowerSyncKotlin") - -if let kotlinSdkPath = localKotlinSdkOverride { - // We can't depend on local XCFrameworks outside of this project's root, so there's a Package.swift - // in the PowerSyncKotlin project pointing towards a local build. - conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/internal/PowerSyncKotlin")) - - kotlinTargetDependency = .product(name: "PowerSyncKotlin", package: "PowerSyncKotlin") -} else { - // Not using a local build, so download from releases - conditionalTargets.append( - .binaryTarget( - name: "PowerSyncKotlin", - url: - "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.11.2/PowersyncKotlinRelease.zip", - checksum: "bd4f9a4411a10a30bd67c3231d1d0d0dde42f0ec19161ccbd26d4e58b31efdfd" - )) -} var corePackageName = "powersync-sqlite-core-swift" if let corePath = localCoreExtension { @@ -84,7 +62,8 @@ let package = Package( dependencies: conditionalDependencies + [ .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.9.0"), .package(url: "https://github.com/powersync-ja/CSQLite.git", exact: "3.51.2"), - .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.0") + .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.4.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -92,12 +71,19 @@ let package = Package( .target( name: packageName, dependencies: [ - kotlinTargetDependency, - .product(name: "PowerSyncSQLiteCore", package: corePackageName), + .target(name: "PowerSyncCoreShim"), .product(name: "CSQLite", package: "CSQLite"), - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "BasicContainers", package: "swift-collections"), + .product(name: "DequeModule", package: "swift-collections") ] ), + .target( + name: "PowerSyncCoreShim", + dependencies: [ + .product(name: "PowerSyncSQLiteCore", package: corePackageName), + ], + ), .target( name: "PowerSyncGRDB", dependencies: [ diff --git a/README.md b/README.md index ad10fdf..35333ac 100644 --- a/README.md +++ b/README.md @@ -118,15 +118,6 @@ Feel free to use the `DatabasePool` for view logic and the `PowerSyncDatabase` f - Updating the PowerSync schema, with `updateSchema`, is not currently fully supported with GRDB connections. - This integration currently requires statically linking PowerSync and GRDB. - This implementation requires defining the PowerSync Schema and GRDB data types. This results in some duplication. -- This implementation uses the SQLite session API to track updates made by the PowerSync SDK. This could use more memory compared to the Standard PowerSync SQLite implementation. -- This implementation can cause warnings such as "Thread Performance Checker: Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions". This will be addressed in a future release. - -## Underlying Kotlin Dependency - -The PowerSync Swift SDK makes use of the [PowerSync Kotlin SDK](https://github.com/powersync-ja/powersync-kotlin) and the API tool [SKIE](https://skie.touchlab.co/) under the hood to implement the Swift package. -However, this dependency is resolved internally and all public APIs are written entirely in Swift. - -For more details, see the [Swift SDK reference](https://docs.powersync.com/client-sdk-references/swift) and generated [API references](https://powersync-ja.github.io/powersync-swift/documentation/powersync/). ## Attachments diff --git a/Sources/PowerSync/Implementation/ActiveInstanceStore.swift b/Sources/PowerSync/Implementation/ActiveInstanceStore.swift new file mode 100644 index 0000000..5358f8f --- /dev/null +++ b/Sources/PowerSync/Implementation/ActiveInstanceStore.swift @@ -0,0 +1,65 @@ +final class DatabaseGroupCollection: Sendable { + private let groups: Mutex<[ActiveDatabaseGroupData]> = Mutex([]) + + fileprivate func closeGroup(identifier: String) { + groups.withLock { $0.removeAll { group in group.identifier == identifier } } + } + + func referenceGroup(identifier: String, logger: LoggerProtocol) -> ActiveDatabaseGroup { + groups.withLock { activeDatabases in + let existingGroup = activeDatabases.first { $0.identifier == identifier } + let data: ActiveDatabaseGroupData + if let existingGroup { + logger.warning(""" +Multiple PowerSync instances for the same database have been detected. +This can cause unexpected results. +Please check your PowerSync client instantiation logic if this is not intentional. +""", tag: "DatabaseGroupCollection") + data = existingGroup + } else { + data = ActiveDatabaseGroupData(identifier: identifier) + activeDatabases.append(data) + } + + return ActiveDatabaseGroup(data: data, collection: self) + } + } + + static let shared = DatabaseGroupCollection() +} + +private final class ActiveDatabaseGroupData: Sendable { + let identifier: String + let syncCoordinator = SyncCoordinator() + + init(identifier: String) { + self.identifier = identifier + } +} + +/// A collection of PowerSync databases with the same path / identifier. +/// +/// We expect that each group will only ever have one database because we encourage users to write their databases as +/// singletons. We print a warning when two databases are part of the same group. +/// Additionally, we want to avoid two databases in the same group having a sync stream open at the same time to avoid +/// duplicate resources being used. For this reason, each active database group has a single sync coordinator actor +/// responsible for initializing the sync process for all databases in the group. +final class ActiveDatabaseGroup: Sendable { + fileprivate let data: ActiveDatabaseGroupData + private weak let collection: DatabaseGroupCollection? + + fileprivate init(data: ActiveDatabaseGroupData, collection: DatabaseGroupCollection) { + self.data = data + self.collection = collection + } + + var syncCoordinator: SyncCoordinator { + data.syncCoordinator + } + + deinit { + if let collection { + collection.closeGroup(identifier: self.data.identifier) + } + } +} diff --git a/Sources/PowerSync/Implementation/AsyncConnectionPool.swift b/Sources/PowerSync/Implementation/AsyncConnectionPool.swift new file mode 100644 index 0000000..a52767f --- /dev/null +++ b/Sources/PowerSync/Implementation/AsyncConnectionPool.swift @@ -0,0 +1,194 @@ +import CSQLite +import DequeModule +import Foundation + +enum DatabaseLocation { + case inMemory + case inDefaultDirectory(name: String) + + func openConnection(writer: Bool) throws -> RawSqliteConnection { + var db: OpaquePointer? + let rc: Int32 + let path: String + + switch self { + case .inMemory: + path = ":memory:" + rc = sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE, nil) + case .inDefaultDirectory(let name): + let fileManager = FileManager.default + let databaseDirectory = (try DatabaseLocation.appleDefaultDatabaseDirectory()).path + + if !fileManager.fileExists(atPath: databaseDirectory) { + try fileManager.createDirectory(atPath: databaseDirectory, withIntermediateDirectories: true) + } + + path = "\(databaseDirectory)/\(name)" + let flags = if writer { + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE + } else { + SQLITE_OPEN_READONLY + } + rc = sqlite3_open_v2(path, &db, flags, nil) + } + + if rc != 0 { + throw PowerSyncError.sqliteError(extendedResultCode: rc, offset: nil, message: "Could not open database \(path)", errorString: nil, sql: nil) + } + + return RawSqliteConnection(connection: db!) + } + + /// This returns the default directory in which we store SQLite database files. + static func appleDefaultDatabaseDirectory() throws -> URL { + let fileManager = FileManager.default + + // Get the application support directory + guard let documentsDirectory = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw PowerSyncError.operationFailed(message: "Unable to find application support directory") + } + + return documentsDirectory.appendingPathComponent("databases") + } +} + +/// Wraps an ``NativeConnectionPool`` to handle opening connections and to dispatch database tasks in a suitable queue. +final class AsyncConnectionPool: SQLiteConnectionPoolProtocol { + private let location: DatabaseLocation + private let initialStatements: [String] + private let logger: any LoggerProtocol + private let tableUpdatesStream = BroadcastStream>() + private let opener = PoolOpener() + + init(location: DatabaseLocation, logger: any LoggerProtocol, initialStatements: [String] = []) { + self.location = location + self.logger = logger + self.initialStatements = initialStatements + } + + var tableUpdates: AsyncStream> { + tableUpdatesStream.subscribe() + } + + /// Asyncifies a synchronous unit of work on by running it on a suitable background thread. + private func runBlocking(action: @escaping @Sendable () throws -> T, qos: DispatchQoS.QoSClass = .userInitiated) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: qos).async { + continuation.resume(with: Result(catching: { try action() })) + } + } + } + + private func configureConnection(connection: borrowing RawSqliteConnection, isWriter: Bool) throws { + let context = connection.asLease() + for stmt in initialStatements { + let _ = try context.execute(sql: stmt, parameters: []) + } + + if isWriter { + let _ = try context.execute(sql: "pragma journal_mode = WAL", parameters: []) + } else { + // This is mainly an additional safety element, we also open read connections SQLITE_READONLY. + let _ = try context.execute(sql: "pragma query_only = TRUE", parameters: []) + } + + let _ = try context.execute(sql: "pragma journal_size_limit = \(6 * 1024 * 1024)", parameters: []) + let _ = try context.execute(sql: "pragma busy_timeout = 30000", parameters: []) + let _ = try context.execute(sql: "pragma cache_size = -\(50 * 1024)", parameters: []) + + if isWriter { + // Older versions of the SDK used to set up an empty schema and raise the user version to 1. + // Keep doing that for consistency. + let version = try context.withIterator(sql: "pragma user_version", parameters: []) { rows in + try rows.next { try $0.getInt(index: 0) } + } + if let version, version < 1 { + let _ = try context.execute(sql: "pragma user_version = 1", parameters: []) + } + + let _ = try context.execute(sql: "select powersync_update_hooks('install')", parameters: []) + } + } + + /// Opens connections on a background thread to obtain the native connection pool. + private func obtainInner() async throws -> NativeConnectionPool { + try await opener.obtainPool(pool: self) + } + + func read(onConnection: @escaping @Sendable (any SQLiteConnectionLease) throws -> T) async throws -> T { + let pool = try await obtainInner() + return try await pool.read { connection in + return try await runBlocking { try onConnection(connection) } + } + } + + func write(onConnection: @escaping @Sendable (any SQLiteConnectionLease) throws -> T) async throws -> T { + let pool = try await obtainInner() + return try await pool.write { connection in + try await runBlocking { try onConnection(connection) } + } + } + + func withAllConnections(onConnection: @escaping @Sendable (any SQLiteConnectionLease, [any SQLiteConnectionLease]) throws -> T) async throws -> T { + let pool = try await obtainInner() + return try await pool.withAllConnections { writer, readers in + try await runBlocking { try onConnection(writer, readers) } + } + } + + func close() async throws { + try await self.opener.close() + } + + private actor PoolOpener { + private var pool: NativeConnectionPool? = nil + private var isClosed = false + + func obtainPool(pool context: AsyncConnectionPool) async throws -> NativeConnectionPool { + if let pool { + return pool + } + + try registerPowerSyncCoreExtension() + let handleUpdates: @Sendable (_: Set) -> () = { [weak context] updates in + context?.tableUpdatesStream.dispatch(event: updates) + } + + let pool = try await context.runBlocking { + let writer = try context.location.openConnection(writer: true) + try context.configureConnection(connection: writer, isWriter: true) + + if case .inMemory = context.location { + return NativeConnectionPool(singleConnection: writer, logger: context.logger, handleUpdates: handleUpdates) + } else { + let numReaders = 4 + var readers = RigidDeque(capacity: numReaders) + while !readers.isFull { + let connection = try context.location.openConnection(writer: false) + try context.configureConnection(connection: connection, isWriter: false) + readers.append(connection) + } + + return NativeConnectionPool(writer: writer, readers: readers, logger: context.logger, handleUpdates: handleUpdates) + } + } + + self.pool = pool + return pool + } + + func close() async throws { + if isClosed { + return + } + + isClosed = true + if let pool { + try await pool.close() + } + } + } +} diff --git a/Sources/PowerSync/Implementation/PowerSyncDatabaseImpl.swift b/Sources/PowerSync/Implementation/PowerSyncDatabaseImpl.swift new file mode 100644 index 0000000..45307a0 --- /dev/null +++ b/Sources/PowerSync/Implementation/PowerSyncDatabaseImpl.swift @@ -0,0 +1,226 @@ +import AsyncAlgorithms +import Foundation + +final class PowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { + let logger: any LoggerProtocol + let group: ActiveDatabaseGroup + let syncStatus = SwiftSyncStatus() + private let dbFilename: String? + private let httpClient: HttpClient + private let initializer = DatabaseInitializationAction() + fileprivate let queries: ConnectionPoolQueries + let schema: AsyncMutex + + init( + dbFilename: String? = nil, + identifier: String, + activeInstanceStore: DatabaseGroupCollection = .shared, + logger: any LoggerProtocol, + pool: any SQLiteConnectionPoolProtocol, + httpClient: HttpClient, + schema: Schema + ) { + self.dbFilename = dbFilename + self.logger = logger + self.schema = AsyncMutex(schema) + self.httpClient = httpClient + self.queries = ConnectionPoolQueries(pool: pool) + self.group = activeInstanceStore.referenceGroup(identifier: identifier, logger: logger) + } + + var currentStatus: any SyncStatus { + syncStatus + } + + func resolveOfflineSyncStatusIfNotConnected() async throws { + try await group.syncCoordinator.guardNotConnected(inner: { + try await resolveOfflineSyncStatus() + }, ifConnected: {}) + } + + private func initialize() async throws { + try await initializer.ensureInitialized(db: self) + } + + fileprivate func resolveOfflineSyncStatus() async throws { + // We can't use get() here because it runs as part of the initialization step. + let offlineSyncStatus = try await queries.readLock { connection in + try connection.get(sql: "SELECT powersync_offline_sync_status()", parameters: []) { cursor in + let raw = try cursor.getString(index: 0) + guard let data = raw.data(using: .utf8) else { + throw PowerSyncError.operationFailed(message: "Could not encode offline sync status") + } + return try StreamingSyncClient.jsonDecoder.decode(CoreDownloadSyncStatus.self, from: data) + } + } + + syncStatus.mutateStatus { $0 = MutableSyncStatus(core: offlineSyncStatus) } + } + + func updateSchema(schema: any SchemaProtocol) async throws { + try await initializer.ensureInitialized(db: self) + try await group.syncCoordinator.guardNotConnected( + inner: { + let schema = Schema(other: schema) + await self.schema.withMutex { $0 = schema } + try await applySchema(schema: schema) + }, + ifConnected: { throw PowerSyncError.operationFailed(message: "Cannot update schema while connected") } + ) + } + + fileprivate func applySchema(schema: Schema) async throws { + try await queries.withAll { writer, readers in + let encoded = try StreamingSyncClient.jsonEncoder.encode(schema) + guard let asString = String(data: encoded, encoding: .utf8) else { + throw PowerSyncError.operationFailed(message: "Could not serialize schema") + } + try writer.execute(sql: "SELECT powersync_replace_schema(?)", parameters: [asString]) + + for reader in readers { + // Update the schema on all read connections + try reader.execute(sql: "pragma table_info('sqlite_master')", parameters: []) + } + } + } + + func waitForFirstSync() async throws { + try await initialize() + await syncStatus.waitFor { $0.hasSynced == true } + } + + func waitForFirstSync(priority: Int32) async throws { + try await initialize() + let priority = BucketPriority(priority) + await syncStatus.waitFor { $0.statusForPriority(priority).hasSynced == true } + } + + func getPowerSyncVersion() async throws -> String { + try await initialize() + // Set during initialization + return await initializer.powerSyncVersion! + } + + func disconnect() async throws { + await group.syncCoordinator.disconnect() + } + + func syncStream(name: String, params: JsonParam?) -> any SyncStream { + PendingSyncStream(db: self, name: name, parameters: params) + } + + func close() async throws { + try await initialize() + try await initializer.close { + await group.syncCoordinator.disconnect() + try await queries.pool.close() + } + } + + func close(deleteDatabase: Bool) async throws { + try await close() + if deleteDatabase, let dbFilename { + // We can use the supplied dbLocation when we support that in future + let directory = try DatabaseLocation.appleDefaultDatabaseDirectory() + try deleteSQLiteFiles(dbFilename: dbFilename, in: directory) + } + } + + func connect(connector: any PowerSyncBackendConnectorProtocol, options: ConnectOptions?) async throws { + await group.syncCoordinator.connect(db: self, connector: connector, options: options ?? ConnectOptions(), client: httpClient) + } + + func disconnectAndClear(clearLocal: Bool, soft: Bool) async throws { + try await initialize() + try await group.syncCoordinator.disconnectAndThen { + var flags = 0 + if clearLocal { + flags |= 1 + } + if soft { + flags |= 2 + } + + do { + let flags = flags + let _ = try await queries.writeLock { ctx in try ctx.execute(sql: "SELECT powersync_clear(?)", parameters: [flags]) } + } + } + } + + func writeLock(callback: @escaping @Sendable (any ConnectionContext) throws -> R) async throws -> R { + try await initialize() + return try await queries.writeLock(callback: callback) + } + + func readLock(callback: @escaping @Sendable (any ConnectionContext) throws -> R) async throws -> R { + try await initialize() + return try await queries.readLock(callback: callback) + } + + func watch(options: WatchOptions) throws -> AsyncThrowingStream<[RowType], any Error> { + return try queries.watch(options: options) + } + + static let maxOpId = Int64.max +} + +private actor DatabaseInitializationAction { + private var isInitialized = false + var powerSyncVersion: String? + private var closed = false + + func ensureInitialized(db: PowerSyncDatabaseImpl) async throws { + if closed { + throw PowerSyncError.operationFailed(message: "Attempted to use closed PowerSync database") + } + if isInitialized { + return + } + + powerSyncVersion = try await db.queries.writeLock { conn in + let sqliteVersion = try conn.get(sql: "SELECT sqlite_version()", parameters: []) { try $0.getString(index: 0) } + let powerSyncVersion = try conn.get(sql: "SELECT powersync_rs_version()", parameters: []) { try $0.getString(index: 0) } + + db.logger.debug("Opened connection. SQLite version \(sqliteVersion), PowerSync SQLite core extension \(powerSyncVersion)", tag: "PowerSyncDatabase") + + try conn.execute(sql: "SELECT powersync_init()", parameters: []) + return powerSyncVersion + } + + try await db.applySchema(schema: db.schema.withMutex { $0 }) + try await db.resolveOfflineSyncStatus() + isInitialized = true + } + + func close(action: () async throws -> ()) async rethrows { + if !closed { + closed = true + try await action() + } + } +} + +func deleteSQLiteFiles(dbFilename: String, in directory: URL) throws { + let fileManager = FileManager.default + + // SQLite files to delete: + // 1. Main database file: dbFilename + // 2. WAL file: dbFilename-wal + // 3. SHM file: dbFilename-shm + // 4. Journal file: dbFilename-journal (for rollback journal mode, though WAL mode typically doesn't use it) + + let filesToDelete = [ + dbFilename, + "\(dbFilename)-wal", + "\(dbFilename)-shm", + "\(dbFilename)-journal" + ] + + for filename in filesToDelete { + let fileUrl = directory.appendingPathComponent(filename) + if fileManager.fileExists(atPath: fileUrl.path) { + try fileManager.removeItem(at: fileUrl) + } + } +} diff --git a/Sources/PowerSync/Implementation/SyncStreams.swift b/Sources/PowerSync/Implementation/SyncStreams.swift index 92d63c0..2897909 100644 --- a/Sources/PowerSync/Implementation/SyncStreams.swift +++ b/Sources/PowerSync/Implementation/SyncStreams.swift @@ -13,7 +13,7 @@ final class StreamTracker: Sendable { streamsChanged.dispatch(event: currentStreams) } - fileprivate func subscriptionsCommand(db: KotlinPowerSyncDatabaseImpl, request: RustSubscriptionChangeRequest) async throws { + fileprivate func subscriptionsCommand(db: PowerSyncDatabaseImpl, request: RustSubscriptionChangeRequest) async throws { let _ = try await db.writeTransaction { tx in let payload = String(data: try StreamingSyncClient.jsonEncoder.encode(request), encoding: .utf8) try tx.execute(sql: "SELECT powersync_control(?, ?)", parameters: [ @@ -25,7 +25,7 @@ final class StreamTracker: Sendable { try await db.resolveOfflineSyncStatusIfNotConnected() } - fileprivate func subscribe(db: KotlinPowerSyncDatabaseImpl, stream: PendingSyncStream, ttl: TimeInterval?, priority: BucketPriority?) async throws -> SyncSubscriptionImplementation { + fileprivate func subscribe(db: PowerSyncDatabaseImpl, stream: PendingSyncStream, ttl: TimeInterval?, priority: BucketPriority?) async throws -> SyncSubscriptionImplementation { let key = stream.key try await subscriptionsCommand( db: db, @@ -79,7 +79,7 @@ final class StreamTracker: Sendable { /// A Sync Stream that can be subscribed to. struct PendingSyncStream: SyncStream { - let db: KotlinPowerSyncDatabaseImpl + let db: PowerSyncDatabaseImpl let name: String let parameters: JsonParam? @@ -88,11 +88,11 @@ struct PendingSyncStream: SyncStream { } func subscribe(ttl: TimeInterval?, priority: BucketPriority?) async throws -> any SyncStreamSubscription { - return try await db.syncCoordinator.streams.subscribe(db: db, stream: self, ttl: ttl, priority: priority) + return try await db.group.syncCoordinator.streams.subscribe(db: db, stream: self, ttl: ttl, priority: priority) } func unsubscribeAll() async throws { - let tracker = db.syncCoordinator.streams + let tracker = db.group.syncCoordinator.streams let key = self.key tracker.removeStreamGroup(key: key) try await tracker.subscriptionsCommand(db: db, request: .unsubscribe(key)) @@ -100,10 +100,10 @@ struct PendingSyncStream: SyncStream { } final class SyncSubscriptionImplementation: SyncStreamSubscription { - private let db: KotlinPowerSyncDatabaseImpl + private let db: PowerSyncDatabaseImpl private let key: StreamKey - init(db: KotlinPowerSyncDatabaseImpl, key: StreamKey) { + init(db: PowerSyncDatabaseImpl, key: StreamKey) { self.db = db self.key = key } @@ -125,7 +125,7 @@ final class SyncSubscriptionImplementation: SyncStreamSubscription { } deinit { - db.syncCoordinator.streams.decrementRefCount(key: key) + db.group.syncCoordinator.streams.decrementRefCount(key: key) } } diff --git a/Sources/PowerSync/Implementation/queries/ConnectionPoolQueries.swift b/Sources/PowerSync/Implementation/queries/ConnectionPoolQueries.swift new file mode 100644 index 0000000..6c507e0 --- /dev/null +++ b/Sources/PowerSync/Implementation/queries/ConnectionPoolQueries.swift @@ -0,0 +1,132 @@ +import AsyncAlgorithms + +/// Implements ``Queries`` by delegating to a ``SQLiteConnectionPoolProtocol``. +struct ConnectionPoolQueries: Sendable, Queries { + let pool: SQLiteConnectionPoolProtocol + + func writeLock(callback: @escaping @Sendable (any ConnectionContext) throws -> R) async throws -> R { + try await pool.write { connection in + try callback(ConnectionLeaseContext(lease: connection)) + } + } + + func readLock(callback: @escaping @Sendable (any ConnectionContext) throws -> R) async throws -> R { + try await pool.read { connection in + try callback(ConnectionLeaseContext(lease: connection)) + } + } + + func withAll(callback: @escaping @Sendable (_ writer: any ConnectionContext, _ readers: [any ConnectionContext]) throws -> R) async throws -> R { + try await pool.withAllConnections { writer, readers in + let writer = ConnectionLeaseContext(lease: writer) + let readers = readers.map { ConnectionLeaseContext(lease: $0) } + + return try callback(writer, readers) + } + } + + func watch(options: WatchOptions) throws -> AsyncThrowingStream<[RowType], any Error> { + AsyncThrowingStream { continuation in + // Create an outer task to monitor cancellation + let task = Task { + do { + let watchedTables = try await self.getQuerySourceTables( + sql: options.sql, + parameters: options.parameters + ) + + let updateNotifications = pool.tableUpdates.filter { changedTables in + changedTables.contains(where: watchedTables.contains) + }.map { _ in () } + // Allows emitting the first result even if there aren't changes + let withInitial = AsyncAlgorithms.merge([()].async, updateNotifications) + let merged = MergeItemSequence(inner: withInitial) + + for try await _ in merged { + // Check if the outer task is cancelled + try Task.checkCancellation() + + try continuation.yield(await self.getAll( + sql: options.sql, + parameters: options.parameters, + mapper: options.mapper + )) + try await sleepForSeconds(seconds: options.throttle) + } + + continuation.finish() + } catch { + if error is CancellationError { + continuation.finish() + } else { + continuation.finish(throwing: error) + } + } + } + + // 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 + } + } + } + + private func getQuerySourceTables( + sql: String, + parameters: [Sendable?] + ) 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 { row in + if (row.opcode == "OpenRead" || row.opcode == "OpenWrite") && + row.p3 == 0 && row.p2 != 0 + { + return row.p2 + } + return nil + } + + do { + let pagesData = try StreamingSyncClient.jsonEncoder.encode(rootPages) + guard let pagesString = String(data: pagesData, encoding: .utf8) else { + throw PowerSyncError.operationFailed( + message: "Failed to convert pages data to UTF-8 string" + ) + } + + let tableRows = try await getAll( + sql: "SELECT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))", + parameters: [ + pagesString, + ] + ) { try $0.getString(index: 0) } + + return Set(tableRows) + } catch { + throw PowerSyncError.operationFailed( + message: "Could not determine watched query tables", + underlyingError: error + ) + } + } +} + +private struct ExplainQueryResult { + let addr: String + let opcode: String + let p1: Int64 + let p2: Int64 + let p3: Int64 +} diff --git a/Sources/PowerSync/Implementation/queries/LeaseContext.swift b/Sources/PowerSync/Implementation/queries/LeaseContext.swift new file mode 100644 index 0000000..7947943 --- /dev/null +++ b/Sources/PowerSync/Implementation/queries/LeaseContext.swift @@ -0,0 +1,62 @@ +/// An implementation of ``ConnectionContext`` based on a raw ``SQLiteConnectionLease``. +final class ConnectionLeaseContext: ConnectionContext { + private let lease: Mutex + + init(lease: consuming SQLiteConnectionLease) { + self.lease = Mutex(lease) + } + + /// Maps any parameter array to typed SQLite values. + private func mapParameters(_ parameters: [(any Sendable)?]?) throws -> [PowerSyncDataType?] { + guard let parameters else { + return [] + } + + return try parameters.map { parameter in + if let convertible = parameter as? PowerSyncDataTypeConvertible { + return convertible.psDataType + } else if let parameter { + return try PowerSyncDataType(from: parameter) + } else { + return nil + } + } + } + + func execute(sql: String, parameters: [(any Sendable)?]?) throws -> Int64 { + try lease.withLock { lease in + return try lease.execute(sql: sql, parameters: mapParameters(parameters)) + } + } + + func getOptional(sql: String, parameters: [(any Sendable)?]?, mapper: @escaping @Sendable (any SqlCursor) throws -> RowType) throws -> RowType? { + try lease.withLock { lease in + try lease.withIterator(sql: sql, parameters: mapParameters(parameters)) { rows in + return try rows.next(callback: mapper) + } + } + } + + func getAll(sql: String, parameters: [(any Sendable)?]?, mapper: @escaping @Sendable (any SqlCursor) throws -> RowType) throws -> [RowType] { + try lease.withLock { lease in + try lease.withIterator(sql: sql, parameters: mapParameters(parameters)) { rows in + var result: [RowType] = [] + while let row = try rows.next(callback: mapper) { + result.append(row) + } + return result + } + } + } + + func get(sql: String, parameters: [(any Sendable)?]?, mapper: @escaping @Sendable (any SqlCursor) throws -> RowType) throws -> RowType { + try lease.withLock { lease in + try lease.withIterator(sql: sql, parameters: mapParameters(parameters)) { rows in + guard let cursor = try rows.next(callback: mapper) else { + throw PowerSyncError.operationFailed(message: "Expected \(sql) to return a row, but got an empty result set.") + } + return cursor + } + } + } +} diff --git a/Sources/PowerSync/Implementation/queries/TransactionImpl.swift b/Sources/PowerSync/Implementation/queries/TransactionImpl.swift new file mode 100644 index 0000000..0da486b --- /dev/null +++ b/Sources/PowerSync/Implementation/queries/TransactionImpl.swift @@ -0,0 +1,37 @@ +struct TransactionImpl: Transaction { + let inner: ConnectionContext + + func execute(sql: String, parameters: [(any Sendable)?]?) throws -> Int64 { + return try self.inner.execute(sql: sql, parameters: parameters) + } + + func getOptional(sql: String, parameters: [(any Sendable)?]?, mapper: @escaping @Sendable (any SqlCursor) throws -> RowType) throws -> RowType? { + return try self.inner.getOptional(sql: sql, parameters: parameters, mapper: mapper) + } + + func getAll(sql: String, parameters: [(any Sendable)?]?, mapper: @escaping @Sendable (any SqlCursor) throws -> RowType) throws -> [RowType] { + return try self.inner.getAll(sql: sql, parameters: parameters, mapper: mapper) + } + + func get(sql: String, parameters: [(any Sendable)?]?, mapper: @escaping @Sendable (any SqlCursor) throws -> RowType) throws -> RowType { + return try self.inner.get(sql: sql, parameters: parameters, mapper: mapper) + } + + static func run(conn: any ConnectionContext, callback: @Sendable (any Transaction) throws -> R) throws -> R { + let _ = try conn.execute(sql: "BEGIN IMMEDIATE", parameters: nil) + + do { + let result = try callback(TransactionImpl(inner: conn)) + let _ = try conn.execute(sql: "COMMIT", parameters: nil) + return result + } catch { + do { + let _ = try conn.execute(sql: "ROLLBACK", parameters: nil) + } catch { + // Failed rollback, probably an INSERT OR ROLLBACK statement that rolled the transaction back already. Ignore. + } + + throw error + } + } +} diff --git a/Sources/PowerSync/Implementation/sqlite3/NativeConnectionPool.swift b/Sources/PowerSync/Implementation/sqlite3/NativeConnectionPool.swift new file mode 100644 index 0000000..f6defaf --- /dev/null +++ b/Sources/PowerSync/Implementation/sqlite3/NativeConnectionPool.swift @@ -0,0 +1,141 @@ +import CSQLite +import Foundation +import DequeModule + +/// A helper implementing a SQLite connection pool from opened and configured connections. +/// +/// This class does not configure or open connections (that is the responsibility of ``AsyncConnectionPool``). +final class NativeConnectionPool: Sendable { + // This could be an async mutex, but AsyncSemaphore has better cancellation support. + private let writer: AsyncSemaphore + private let readers: AsyncSemaphore? + private let handleUpdates: @Sendable (_: Set) -> () + private let logger: any LoggerProtocol + + init( + writer: consuming RawSqliteConnection, + readers: consuming RigidDeque, + logger: any LoggerProtocol, + handleUpdates: @escaping @Sendable (_: Set) -> (), + ) { + self.writer = AsyncSemaphore(singleElement: writer) + self.readers = AsyncSemaphore(readers) + self.handleUpdates = handleUpdates + self.logger = logger + } + + init( + singleConnection: consuming RawSqliteConnection, + logger: any LoggerProtocol, + handleUpdates: @escaping @Sendable (_: Set) -> (), + ) { + self.writer = AsyncSemaphore(singleElement: singleConnection) + self.readers = nil + self.handleUpdates = handleUpdates + self.logger = logger + } + + private func dispatchWrites(lease: NativeConnectionLease) { + do { + try lease.withIterator(sql: "SELECT powersync_update_hooks('get')", parameters: []) { rows in + let affectedTables = try rows.next { + let decoder = JSONDecoder() + return try decoder.decode(Set.self, from: try $0.getString(index: 0).data(using: .utf8)!) + } + + if let affectedTables, !affectedTables.isEmpty { + self.handleUpdates(affectedTables) + } + } + } catch { + logger.warning("Could not read affected tables", tag: "NativeConnectionPool") + } + } + + func read(onConnection: (NativeConnectionLease) async throws -> T) async throws -> T { + // No dedicated readers? Acquire write connection for this then + let semaphore = readers ?? writer + let connection = try await semaphore.acquire(count: 1) + let lease = connection.acquiredItems[0].asLease() + return try await onConnection(lease) + } + + func write(onConnection: (NativeConnectionLease) async throws -> T) async throws -> T { + let connection = try await writer.acquire(count: 1) + let lease = connection.acquiredItems[0].asLease() + defer { dispatchWrites(lease: lease) } + let result = try await onConnection(lease) + return result + } + + func withAllConnections(onConnection: (NativeConnectionLease, [NativeConnectionLease]) async throws -> T) async throws -> T { + let write = try await writer.acquire(count: 1) + let writeLease = write.acquiredItems[0].asLease() + defer { dispatchWrites(lease: writeLease) } + + let result: T + if let readers { + let acquiredReaders = try await readers.acquire(count: readers.count) + var readerLeases: [NativeConnectionLease] = [] + + let span = acquiredReaders.acquiredItems.span + for idx in span.indices { + readerLeases.append(span[idx].asLease()) + } + result = try await onConnection(writeLease, readerLeases) + } else { + result = try await onConnection(writeLease, []) + } + return result + } + + func close() async throws { + // First, lock all connections + var write = try await writer.acquire(count: 1) + var acquiredReaders: SemaphoreGrant? = nil + if let readers { + acquiredReaders = try await readers.acquire(count: readers.count) + } + + // Close the write connection first + write.acquiredItems[0].close() + if var span = acquiredReaders?.acquiredItems.mutableSpan { + for idx in span.indices { + span[idx].close() + } + } + } +} + +struct RawSqliteConnection: ~Copyable { + let connection: OpaquePointer + var closed = false + + deinit { + if !closed { + closeInner() + } + } + + mutating func close() { + if !closed { + closeInner() + closed = true + } + } + + private func closeInner() { + sqlite3_close_v2(connection) + } + + func asLease() -> NativeConnectionLease { + precondition(!closed) + return NativeConnectionLease(pointer: self.connection) + } +} + +// We mark this as Sendable because it's only used in a mutex from `ConnectionLeaseContext`. +// We can't generally assume SQLite connections to be thread-safe. +struct NativeConnectionLease: SQLiteConnectionLease, @unchecked Sendable { + let pointer: OpaquePointer +} diff --git a/Sources/PowerSync/Implementation/sqlite3/NativeStatement.swift b/Sources/PowerSync/Implementation/sqlite3/NativeStatement.swift new file mode 100644 index 0000000..db7007e --- /dev/null +++ b/Sources/PowerSync/Implementation/sqlite3/NativeStatement.swift @@ -0,0 +1,142 @@ +import CSQLite +import Darwin + +struct NativeSqliteStatement: ~Copyable { + private var resolvedColumnNames: [String : Int]? = nil + private let db: OpaquePointer + let stmt: OpaquePointer + private let sql: String + + init(db: OpaquePointer, sql: String) throws(PowerSyncError) { + self.db = db + var stmt: OpaquePointer? + var sql = sql + let rc = sql.withUTF8 { sqlBytes in + return sqlite3_prepare_v2( + db, + sqlBytes.baseAddress, + Int32(sqlBytes.count), + &stmt, + nil + ) + } + if (rc != 0) { + try throwDatabaseError(db: db, sql: sql) + } + + self.stmt = stmt! + self.sql = sql + } + + deinit { + sqlite3_finalize(stmt) + } + + var columnCount: Int { + return Int(sqlite3_column_count(self.stmt)) + } + + var columnNames: [String : Int] { + guard let resolvedColumnNames else { + fatalError("columnNames is only available after step()") + } + return resolvedColumnNames + } + + borrowing func bindValues(_ parameters: [PowerSyncDataType?]) throws(PowerSyncError) { + for (i, parameter) in parameters.enumerated() { + let index = Int32(i + 1) + + if let parameter { + try bindValue(index, parameter) + } else { + try bindValue(index, nil) + } + } + } + + borrowing func bindValue(_ index: Int32, _ parameter: PowerSyncDataType?) throws(PowerSyncError) { + let rc: Int32 + + switch parameter { + case nil: + rc = sqlite3_bind_null(self.stmt, index) + case .bool(let value): + rc = sqlite3_bind_int(self.stmt, index, value ? 1 : 0) + case .string(let value): + var str = value + rc = str.withUTF8 { buffer in + sqlite3_bind_text( + self.stmt, + index, + buffer.baseAddress, + Int32(buffer.count), + // SQLITE_TRANSIENT + unsafeBitCast(-1, to: (@convention(c) (UnsafeMutableRawPointer?) -> Void).self), + ) + } + case .int64(let value): + rc = sqlite3_bind_int64(self.stmt, index, value) + case .int32(let value): + rc = sqlite3_bind_int(self.stmt, index, value) + case .double(let value): + rc = sqlite3_bind_double(self.stmt, index, value) + case .data(let value): + if value.count == 0 { + rc = sqlite3_bind_zeroblob(self.stmt, index, 0) + } else { + // Data object can be made up of multiple memory regions, so copy once. + let buffer = malloc(value.count)! + value.copyBytes(to: buffer.assumingMemoryBound(to: UInt8.self), count: value.count) + + rc = sqlite3_bind_blob( + self.stmt, + index, + buffer, + Int32(value.count), + free, + ) + } + } + + if rc != 0 { + try throwDatabaseError(db: self.db, sql: self.sql) + } + } + + mutating func step() throws(PowerSyncError) -> Bool { + let rc = sqlite3_step(self.stmt) + if rc == SQLITE_DONE { + return false + } else if rc == SQLITE_ROW { + if resolvedColumnNames == nil { + let count = self.columnCount + var nameToIndex = Dictionary(minimumCapacity: count) + for i in 0..(callback: (any SqlCursor) throws -> T) throws -> T? { + if try step() { + // Turn the static ~Copyable lifetime into a dynamic lifetime with explicit + // invalidation. + return try withUnsafePointer(to: self) { ptr in + let cursor = StatementCursor(ptr) + defer { cursor.invalidate() } + return try callback(cursor) + } + } else { + return nil + } + } +} diff --git a/Sources/PowerSync/Implementation/sqlite3/StatementCursor.swift b/Sources/PowerSync/Implementation/sqlite3/StatementCursor.swift new file mode 100644 index 0000000..76c00b3 --- /dev/null +++ b/Sources/PowerSync/Implementation/sqlite3/StatementCursor.swift @@ -0,0 +1,122 @@ +import CSQLite + +/// A temporary lease of a SQLite statement used to implement cursors +class StatementCursor: SqlCursor { + private var stmtPtr: UnsafePointer? + + init(_ stmtPtr: UnsafePointer) { + self.stmtPtr = stmtPtr + } + + func invalidate() { + stmtPtr = nil + } + + private func withStatement(_ body: (borrowing NativeSqliteStatement) -> R) -> R { + if let stmtPtr { + return body(stmtPtr.pointee) + } + + fatalError("Cursor used outside of callback") + } + + private func checkColumnNotNull(stmt: borrowing NativeSqliteStatement, index: Int) throws(SqlCursorError) { + if index < 0 || index >= stmt.columnCount { + throw SqlCursorError.nullValueFound("invalid index \(index)") + } + + let type = sqlite3_column_type(stmt.stmt, Int32(index)) + if type == SQLITE_NULL { + throw SqlCursorError.nullValueFound("\(index)") + } + } + + private func withStatementCheckNotNull(_ index: Int, body: (borrowing NativeSqliteStatement) throws (SqlCursorError) -> R) throws (SqlCursorError) -> R { + if let stmtPtr { + try self.checkColumnNotNull(stmt: stmtPtr.pointee, index: index) + return try body(stmtPtr.pointee) + } + + fatalError("Cursor used outside of callback") + } + + var columnCount: Int { + return withStatement { stmt in stmt.columnCount } + } + + var columnNames: [String : Int] { + return withStatement { stmt in stmt.columnNames } + } + + func getBoolean(index: Int) throws(SqlCursorError) -> Bool { + return try getInt(index: index) == 0 ? false : true + } + + func getBooleanOptional(index: Int) -> Bool? { + do { + return try getBoolean(index: index) + } catch { + return nil + } + } + + func getDouble(index: Int) throws(SqlCursorError) -> Double { + return try withStatementCheckNotNull(index) { stmt in + return sqlite3_column_double(stmt.stmt, Int32(index)) + } + } + + func getDoubleOptional(index: Int) -> Double? { + do { + return try getDouble(index: index) + } catch { + return nil + } + } + + func getInt(index: Int) throws(SqlCursorError) -> Int { + return Int(try getInt64(index: index)) + } + + func getIntOptional(index: Int) -> Int? { + do { + return try getInt(index: index) + } catch { + return nil + } + } + + func getInt64(index: Int) throws(SqlCursorError) -> Int64 { + return try withStatementCheckNotNull(index) { stmt in + return sqlite3_column_int64(stmt.stmt, Int32(index)) + } + } + + func getInt64Optional(index: Int) -> Int64? { + do { + return try getInt64(index: index) + } catch { + return nil + } + } + + func getString(index: Int) throws(SqlCursorError) -> String { + return try withStatementCheckNotNull(index) { stmt in + let length = sqlite3_column_bytes(stmt.stmt, Int32(index)) + if length == 0 { + return "" + } + + let ptr = sqlite3_column_text(stmt.stmt, Int32(index)) + return String(decoding: UnsafeBufferPointer(start: ptr, count: Int(length)), as: UTF8.self) + } + } + + func getStringOptional(index: Int) -> String? { + do { + return try getString(index: index) + } catch { + return nil + } + } +} diff --git a/Sources/PowerSync/Implementation/sqlite3/registerPowerSyncCoreExtension.swift b/Sources/PowerSync/Implementation/sqlite3/registerPowerSyncCoreExtension.swift new file mode 100644 index 0000000..19ad259 --- /dev/null +++ b/Sources/PowerSync/Implementation/sqlite3/registerPowerSyncCoreExtension.swift @@ -0,0 +1,17 @@ +import CSQLite +import PowerSyncCoreShim + +func registerPowerSyncCoreExtension() throws(PowerSyncError) { + let rc = sqlite3_auto_extension(sqlite3_powersync_init) + if rc != 0 { + let errStr = String(cString: sqlite3_errstr(rc)) + + throw .sqliteError( + extendedResultCode: rc, + offset: nil, + message: "Could not load PowerSync SQLite core extension", + errorString: errStr, + sql: nil + ) + } +} diff --git a/Sources/PowerSync/Implementation/sqlite3/throwDatabaseError.swift b/Sources/PowerSync/Implementation/sqlite3/throwDatabaseError.swift new file mode 100644 index 0000000..d089aab --- /dev/null +++ b/Sources/PowerSync/Implementation/sqlite3/throwDatabaseError.swift @@ -0,0 +1,17 @@ +import CSQLite + +func throwDatabaseError(db: OpaquePointer, sql: String?) throws(PowerSyncError) -> Never { + let extended = sqlite3_extended_errcode(db) + let errStr = String(cString: sqlite3_errstr(extended)) + + let offset = sqlite3_error_offset(db) + let rawMessage = sqlite3_errmsg(db) + + throw PowerSyncError.sqliteError( + extendedResultCode: extended, + offset: offset >= 0 ? offset : nil, + message: rawMessage.map { ptr in String(cString: ptr) }, + errorString: errStr, + sql: sql, + ) +} diff --git a/Sources/PowerSync/Implementation/sync/StreamingSyncClient.swift b/Sources/PowerSync/Implementation/sync/StreamingSyncClient.swift index 784b6e4..d28b393 100644 --- a/Sources/PowerSync/Implementation/sync/StreamingSyncClient.swift +++ b/Sources/PowerSync/Implementation/sync/StreamingSyncClient.swift @@ -4,13 +4,13 @@ import Foundation fileprivate let tag = "StreamingSyncClient" final class StreamingSyncClient: Sendable { - let db: KotlinPowerSyncDatabaseImpl + let db: PowerSyncDatabaseImpl let options: ConnectOptions let connector: CachingCredentialsConnector let httpClient: any HttpClient init( - db: KotlinPowerSyncDatabaseImpl, + db: PowerSyncDatabaseImpl, connector: PowerSyncBackendConnectorProtocol, httpClient: any HttpClient, options: ConnectOptions, @@ -104,7 +104,7 @@ The next upload iteration will be delayed. private func uploadLocalTarget() async throws { guard let _ = try await db.getOptional( sql: "SELECT 1 FROM ps_buckets WHERE name = '$local' AND target_op = ?", - parameters: [KotlinPowerSyncDatabaseImpl.maxOpId], + parameters: [PowerSyncDatabaseImpl.maxOpId], mapper: { cursor in () } ) else { return // Nothing to update @@ -258,7 +258,7 @@ private struct ActiveSyncIteration: Sendable { parameters: syncClient.options.params, schema: await syncClient.db.schema.inner, includeDefaults: syncClient.options.includeDefaultStreams, - activeStreams: syncClient.db.syncCoordinator.streams.currentStreams, + activeStreams: syncClient.db.group.syncCoordinator.streams.currentStreams, appMetadata: syncClient.options.appMetadata, ))) @@ -372,7 +372,7 @@ private struct ActiveSyncIteration: Sendable { } private func watchSyncStreams() async throws { - let changes = syncClient.db.syncCoordinator.streams.streamsChanged.subscribe() + let changes = syncClient.db.group.syncCoordinator.streams.streamsChanged.subscribe() for await change in changes { self.localEvents.dispatch(event: .updateSubscriptions(streams: change)) } @@ -459,7 +459,3 @@ struct WriteCheckpointData: Codable { struct WriteCheckpointResponse: Codable { let data: WriteCheckpointData } - -private func sleepForSeconds(seconds: TimeInterval) async throws { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) -} diff --git a/Sources/PowerSync/Implementation/sync/SyncCoordinator.swift b/Sources/PowerSync/Implementation/sync/SyncCoordinator.swift index b6b3884..e2b2bbf 100644 --- a/Sources/PowerSync/Implementation/sync/SyncCoordinator.swift +++ b/Sources/PowerSync/Implementation/sync/SyncCoordinator.swift @@ -3,7 +3,7 @@ actor SyncCoordinator { nonisolated let streams = StreamTracker() private var activeSync: Task? - func connect(db: KotlinPowerSyncDatabaseImpl, connector: PowerSyncBackendConnectorProtocol, options: ConnectOptions, client: HttpClient) async { + func connect(db: PowerSyncDatabaseImpl, connector: PowerSyncBackendConnectorProtocol, options: ConnectOptions, client: HttpClient) async { if let task = activeSync { await self.finishSyncTask(task: task) } @@ -25,6 +25,12 @@ actor SyncCoordinator { await self.finishSyncTask(task: task) } + /// Runs ``Self/disconnect`` and `action` in a single actor message lock. + func disconnectAndThen(action: () async throws -> T) async rethrows -> T { + await disconnect() + return try await action() + } + /// Executes an inner function, but only if no connection is active or scheduled. func guardNotConnected(inner: () async throws -> T, ifConnected: () async throws -> T) async rethrows -> T { if activeSync == nil { diff --git a/Sources/PowerSync/Kotlin/AllLeaseCallback+Sendable.swift b/Sources/PowerSync/Kotlin/AllLeaseCallback+Sendable.swift deleted file mode 100644 index 5095d46..0000000 --- a/Sources/PowerSync/Kotlin/AllLeaseCallback+Sendable.swift +++ /dev/null @@ -1,18 +0,0 @@ -import PowerSyncKotlin - -// Since AllLeaseCallback is a protocol from PowerSyncKotlin, we need to use a wrapper class -// to make it Sendable since we can't extend the protocol directly with Sendable. -final class SendableAllLeaseCallback: @unchecked Sendable { - private let wrapped: any PowerSyncKotlin.AllLeaseCallback - - init(_ callback: any PowerSyncKotlin.AllLeaseCallback) { - wrapped = callback - } - - func execute( - writeLease: PowerSyncKotlin.SwiftLeaseAdapter, - readLeases: [PowerSyncKotlin.SwiftLeaseAdapter] - ) throws { - try wrapped.execute(writeLease: writeLease, readLeases: readLeases) - } -} diff --git a/Sources/PowerSync/Kotlin/DatabaseLogger.swift b/Sources/PowerSync/Kotlin/DatabaseLogger.swift deleted file mode 100644 index ed49d5a..0000000 --- a/Sources/PowerSync/Kotlin/DatabaseLogger.swift +++ /dev/null @@ -1,101 +0,0 @@ -import PowerSyncKotlin - -/// Adapts a Swift `LoggerProtocol` to Kermit's `LogWriter` interface. -/// -/// This allows Kotlin logging (via Kermit) to call into the Swift logging implementation. -private class KermitLogWriterAdapter: Kermit_coreLogWriter { - /// The underlying Swift log writer to forward log messages to. - let logger: any LoggerProtocol - - /// Initializes a new adapter. - /// - /// - Parameter logger: A Swift log writer that will handle log output. - init(logger: any LoggerProtocol) { - self.logger = logger - super.init() - } - - /// Called by Kermit to log a message. - /// - /// - Parameters: - /// - severity: The severity level of the log. - /// - message: The content of the log message. - /// - tag: A string categorizing the log. - /// - throwable: An optional Kotlin exception (ignored here). - override func log(severity: Kermit_coreSeverity, message: String, tag: String, throwable _: KotlinThrowable?) { - switch severity { - case PowerSyncKotlin.Kermit_coreSeverity.verbose: - return logger.debug(message, tag: tag) - case PowerSyncKotlin.Kermit_coreSeverity.debug: - return logger.debug(message, tag: tag) - case PowerSyncKotlin.Kermit_coreSeverity.info: - return logger.info(message, tag: tag) - case PowerSyncKotlin.Kermit_coreSeverity.warn: - return logger.warning(message, tag: tag) - case PowerSyncKotlin.Kermit_coreSeverity.error: - return logger.error(message, tag: tag) - case PowerSyncKotlin.Kermit_coreSeverity.assert: - return logger.fault(message, tag: tag) - } - } -} - -class KotlinKermitLoggerConfig: PowerSyncKotlin.Kermit_coreLoggerConfig { - var logWriterList: [Kermit_coreLogWriter] - var minSeverity: PowerSyncKotlin.Kermit_coreSeverity - - init(logWriterList: [Kermit_coreLogWriter], minSeverity: PowerSyncKotlin.Kermit_coreSeverity) { - self.logWriterList = logWriterList - self.minSeverity = minSeverity - } -} - -/// A logger implementation that integrates with PowerSync's Kotlin core using Kermit. -/// -/// This class bridges Swift log writers with the Kotlin logging system and supports -/// runtime configuration of severity levels and writer lists. -final class DatabaseLogger: LoggerProtocol { - /// The underlying Kermit logger instance provided by the PowerSyncKotlin SDK. - public let kLogger: PowerSyncKotlin.KermitLogger - public let logger: any LoggerProtocol - - /// Initializes a new logger with an optional list of writers. - /// - /// - Parameter logger: A logger which will be called for each internal log operation - init(_ logger: any LoggerProtocol) { - self.logger = logger - // Set to the lowest severity. The provided logger should filter by severity - kLogger = PowerSyncKotlin.KermitLogger( - config: KotlinKermitLoggerConfig( - logWriterList: [KermitLogWriterAdapter(logger: logger)], - minSeverity: Kermit_coreSeverity.verbose - ), - tag: "PowerSync" - ) - } - - /// Logs a debug-level message. - public func debug(_ message: String, tag: String?) { - logger.debug(message, tag: tag) - } - - /// Logs an info-level message. - public func info(_ message: String, tag: String?) { - logger.info(message, tag: tag) - } - - /// Logs a warning-level message. - public func warning(_ message: String, tag: String?) { - logger.warning(message, tag: tag) - } - - /// Logs an error-level message. - public func error(_ message: String, tag: String?) { - logger.error(message, tag: tag) - } - - /// Logs a fault (assert-level) message, typically used for critical issues. - public func fault(_ message: String, tag: String?) { - logger.fault(message, tag: tag) - } -} diff --git a/Sources/PowerSync/Kotlin/KotlinAdapter.swift b/Sources/PowerSync/Kotlin/KotlinAdapter.swift deleted file mode 100644 index fd88c78..0000000 --- a/Sources/PowerSync/Kotlin/KotlinAdapter.swift +++ /dev/null @@ -1,136 +0,0 @@ -import PowerSyncKotlin - -enum KotlinAdapter { - struct Index { - static func toKotlin(_ index: IndexProtocol) -> PowerSyncKotlin.Index { - PowerSyncKotlin.Index( - name: index.name, - columns: index.columns.map { IndexedColumn.toKotlin($0) } - ) - } - } - - struct IndexedColumn { - static func toKotlin(_ column: IndexedColumnProtocol) -> PowerSyncKotlin.IndexedColumn { - return PowerSyncKotlin.IndexedColumn( - column: column.column, - ascending: column.ascending, - columnDefinition: nil, - type: nil - ) - } - } - - struct Table { - static func toKotlin(_ table: TableProtocol) -> PowerSyncKotlin.Table { - return PowerSyncKotlin.Table( - name: table.name, - columns: table.columns.map { Column.toKotlin($0) }, - indexes: table.indexes.map { Index.toKotlin($0) }, - options: translateTableOptions(table), - viewNameOverride: table.viewNameOverride, - ) - } - - static func toKotlin(_ table: RawTable) -> PowerSyncKotlin.RawTable { - let translatedPut = table.put.map(translateStatement) - let translatedDelete = table.delete.map(translateStatement) - - if let schema = table.schema { - return PowerSyncKotlin.RawTable( - name: table.name, - schema: translateRawTableSchema(schema), - put: translatedPut, - delete: translatedDelete, - clear: table.clear, - ) - } - - // If we have no schema, put and delete are required. The constructor overloads on RawTable - // should ensure that, but it's better to be defensive here. - guard let put = translatedPut, let delete = translatedDelete else { - fatalError("RawTable '\(table.name)' has no schema and must provide both put and delete statements") - } - - return PowerSyncKotlin.RawTable( - name: table.name, - put: put, - delete: delete, - clear: table.clear - ); - } - - private static func translateTableOptions(_ options: TableOptionsProtocol) -> PowerSyncKotlin.TableOptions { - return PowerSyncKotlin.TableOptions( - localOnly: options.localOnly, - insertOnly: options.insertOnly, - trackMetadata: options.trackMetadata, - trackPreviousValues: options.trackPreviousValues.map { - PowerSyncKotlin.TrackPreviousValuesOptions( - columnFilter: $0.columnFilter, - onlyWhenChanged: $0.onlyWhenChanged - ) - }, - ignoreEmptyUpdates: options.ignoreEmptyUpdates, - ) - } - - private static func translateRawTableSchema(_ schema: RawTableSchema) -> PowerSyncKotlin.RawTableSchema { - return PowerSyncKotlin.RawTableSchema.init( - tableName: schema.tableName, - syncedColumns: schema.syncedColumns, - options: translateTableOptions(schema.options) - ) - } - - private static func translateStatement(_ stmt: PendingStatement) -> PowerSyncKotlin.PendingStatement { - return PowerSyncKotlin.PendingStatement( - sql: stmt.sql, - parameters: stmt.parameters.map(translateParameter) - ) - } - - private static func translateParameter(_ param: PendingStatementParameter) -> PowerSyncKotlin.PendingStatementParameter { - switch param { - case .id: - return PowerSyncKotlin.PendingStatementParameterId.shared - case .column(let name): - return PowerSyncKotlin.PendingStatementParameterColumn(name: name) - case .rest: - return PowerSyncKotlin.PendingStatementParameterRest.shared - } - } - } - - struct Column { - static func toKotlin(_ column: any ColumnProtocol) -> PowerSyncKotlin.Column { - PowerSyncKotlin.Column( - name: column.name, - type: columnType(from: column.type) - ) - } - - private static func columnType(from swiftType: ColumnData) -> PowerSyncKotlin.ColumnType { - switch swiftType { - case .text: - return PowerSyncKotlin.ColumnType.text - case .integer: - return PowerSyncKotlin.ColumnType.integer - case .real: - return PowerSyncKotlin.ColumnType.real - } - } - } - - struct Schema { - static func toKotlin(_ schema: SchemaProtocol) -> PowerSyncKotlin.Schema { - var mappedTables: [PowerSyncKotlin.BaseTable] = [] - mappedTables.append(contentsOf: schema.tables.map(Table.toKotlin)) - mappedTables.append(contentsOf: schema.rawTables.map(Table.toKotlin)) - - return PowerSyncKotlin.Schema( - tables: mappedTables - ) - } - } -} diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift deleted file mode 100644 index d26784d..0000000 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ /dev/null @@ -1,588 +0,0 @@ -import CSQLite -import Foundation -import PowerSyncKotlin - -final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, - // `PowerSyncKotlin.PowerSyncDatabase` cannot be marked as Sendable - @unchecked Sendable -{ - let logger: any LoggerProtocol - private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase - private let encoder = JSONEncoder() - let syncCoordinator = SyncCoordinator() - internal let syncStatus = SwiftSyncStatus() - private let dbFilename: String - private let httpClient: HttpClient - let schema: AsyncMutex - - init( - kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase, - dbFilename: String, - logger: DatabaseLogger, - httpClient: HttpClient, - schema: Schema - ) { - self.logger = logger - self.kotlinDatabase = kotlinDatabase - /// We currently use the dbFilename to delete the database files when the database is closed - /// The kotlin PowerSyncDatabase.identifier currently prepends `null` to the dbFilename (for the directory). - /// FIXME. Update this once we support database directory configuration. - self.dbFilename = dbFilename - self.httpClient = httpClient - self.schema = AsyncMutex(schema) - } - - var currentStatus: any SyncStatus { - syncStatus - } - - func waitForFirstSync() async { - await syncStatus.waitFor { $0.hasSynced == true } - } - - func updateSchema(schema: any SchemaProtocol) async throws { - try await syncCoordinator.guardNotConnected( - inner: { - await self.schema.withMutex { $0 = Schema(other: schema) } - - try await kotlinDatabase.updateSchema( - schema: KotlinAdapter.Schema.toKotlin(schema) - ) - }, - ifConnected: { throw PowerSyncError.operationFailed(message: "Cannot update schema while connected") } - ) - } - - func resolveOfflineSyncStatusIfNotConnected() async throws { - try await syncCoordinator.guardNotConnected(inner: { - try await resolveOfflineSyncStatus() - }, ifConnected: {}) - } - - private func resolveOfflineSyncStatus() async throws { - let offlineSyncStatus = try await get("SELECT powersync_offline_sync_status()") { cursor in - let raw = try cursor.getString(index: 0) - guard let data = raw.data(using: .utf8) else { - throw PowerSyncError.operationFailed(message: "Could not encode offline sync status") - } - return try StreamingSyncClient.jsonDecoder.decode(CoreDownloadSyncStatus.self, from: data) - } - - syncStatus.mutateStatus { $0 = MutableSyncStatus(core: offlineSyncStatus) } - } - - func waitForFirstSync(priority: Int32) async { - let priority = BucketPriority(priority) - await syncStatus.waitFor { $0.statusForPriority(priority).hasSynced == true } - } - - func syncStream(name: String, params: JsonParam?) -> any SyncStream { - PendingSyncStream(db: self, name: name, parameters: params) - } - - func connect( - connector: PowerSyncBackendConnectorProtocol, - options: ConnectOptions? - ) async { - await syncCoordinator.connect( - db: self, - connector: connector, - options: options ?? ConnectOptions(), - client: httpClient - ) - } - - func getPowerSyncVersion() async throws -> String { - try await kotlinDatabase.getPowerSyncVersion() - } - - func disconnect() async { - await syncCoordinator.disconnect() - } - - func disconnectAndClear(clearLocal: Bool, soft: Bool) async throws { - await disconnect() - - try await kotlinDatabase.disconnectAndClear( - clearLocal: clearLocal, - soft: soft - ) - } - - @discardableResult - func execute(sql: String, parameters: [Sendable?]?) async throws -> Int64 { - try await writeTransaction { ctx in - try ctx.execute( - sql: sql, - parameters: parameters - ) - } - } - - func get( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) -> RowType - ) async throws -> RowType { - try await readLock { ctx in - try ctx.get( - sql: sql, - parameters: parameters, - mapper: mapper - ) - } - } - - func get( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) async throws -> RowType { - try await readLock { ctx in - try ctx.get( - sql: sql, - parameters: parameters, - mapper: mapper - ) - } - } - - func getAll( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) -> RowType - ) async throws -> [RowType] { - try await readLock { ctx in - try ctx.getAll( - sql: sql, - parameters: parameters, - mapper: mapper - ) - } - } - - func getAll( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) async throws -> [RowType] { - try await readLock { ctx in - try ctx.getAll( - sql: sql, - parameters: parameters, - mapper: mapper - ) - } - } - - func getOptional( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) -> RowType - ) async throws -> RowType? { - try await readLock { ctx in - try ctx.getOptional( - sql: sql, - parameters: parameters, - mapper: mapper - ) - } - } - - func getOptional( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) async throws -> RowType? { - try await readLock { ctx in - try ctx.getOptional( - sql: sql, - parameters: parameters, - mapper: mapper - ) - } - } - - func watch( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) -> RowType - ) throws -> AsyncThrowingStream<[RowType], any Error> { - try watch( - options: WatchOptions( - sql: sql, - parameters: parameters, - mapper: mapper - ) - ) - } - - func watch( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) throws -> AsyncThrowingStream<[RowType], any Error> { - try watch( - options: WatchOptions( - sql: sql, - parameters: parameters, - mapper: mapper - ) - ) - } - - func watch( - options: WatchOptions - ) throws -> AsyncThrowingStream<[RowType], Error> { - AsyncThrowingStream { continuation in - // Create an outer task to monitor cancellation - let task = Task { - do { - let watchedTables = try await self.getQuerySourceTables( - sql: options.sql, - parameters: options.parameters - ) - - // Watching for changes in the database - for try await _ in try self.kotlinDatabase.onChange( - tables: Set(watchedTables), - throttleMs: Int64(options.throttle * 1000), - triggerImmediately: true // Allows emitting the first result even if there aren't changes - ) { - // Check if the outer task is cancelled - try Task.checkCancellation() - - try continuation.yield( - safeCast( - await self.getAll( - sql: options.sql, - parameters: options.parameters, - mapper: options.mapper - ), - to: [RowType].self - ) - ) - } - - continuation.finish() - } catch { - if error is CancellationError { - continuation.finish() - } else { - continuation.finish(throwing: error) - } - } - } - - // 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 - } - } - } - - func writeLock( - callback: @Sendable @escaping (any ConnectionContext) throws -> R - ) async throws -> R { - return try await wrapPowerSyncException { - try safeCast( - await kotlinDatabase.writeLock( - callback: wrapLockContext(callback: callback) - ), - to: R.self - ) - } - } - - func writeTransaction( - callback: @Sendable @escaping (any Transaction) throws -> R - ) async throws -> R { - return try await wrapPowerSyncException { - try safeCast( - await kotlinDatabase.writeTransaction( - callback: wrapTransactionContext(callback: callback) - ), - to: R.self - ) - } - } - - func readLock( - callback: @Sendable @escaping (any ConnectionContext) throws -> R - ) - async throws -> R - { - return try await wrapPowerSyncException { - try safeCast( - await kotlinDatabase.readLock( - callback: wrapLockContext(callback: callback) - ), - to: R.self - ) - } - } - - func readTransaction( - callback: @Sendable @escaping (any Transaction) throws -> R - ) async throws -> R { - return try await wrapPowerSyncException { - try safeCast( - await kotlinDatabase.readTransaction( - callback: wrapTransactionContext(callback: callback) - ), - to: R.self - ) - } - } - - func close() async throws { - await disconnect() - try await kotlinDatabase.close() - } - - func close(deleteDatabase: Bool = false) async throws { - // Close the SQLite connections - try await close() - - if deleteDatabase { - try await self.deleteDatabase() - } - } - - private func deleteDatabase() async throws { - // We can use the supplied dbLocation when we support that in future - let directory = try appleDefaultDatabaseDirectory() - try deleteSQLiteFiles(dbFilename: dbFilename, in: directory) - } - - /// Tries to convert Kotlin PowerSyncExceptions to Swift Exceptions - private func wrapPowerSyncException( - handler: () async throws -> R) - async throws -> R - { - do { - return try await handler() - } catch { - if error is CancellationError { - throw error - } - // Try and parse errors back from the Kotlin side - if let mapperError = SqlCursorError.fromDescription(error.localizedDescription) { - throw mapperError - } - - throw PowerSyncError.operationFailed( - underlyingError: error - ) - } - } - - private func getQuerySourceTables( - sql: String, - parameters: [Sendable?] - ) 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 { row in - if (row.opcode == "OpenRead" || row.opcode == "OpenWrite") && - row.p3 == 0 && row.p2 != 0 - { - return row.p2 - } - return nil - } - - do { - let pagesData = try encoder.encode(rootPages) - - guard let pagesString = String(data: pagesData, encoding: .utf8) else { - throw PowerSyncError.operationFailed( - message: "Failed to convert pages data to UTF-8 string" - ) - } - - let tableRows = try await getAll( - sql: "SELECT tbl_name FROM sqlite_master WHERE rootpage IN (SELECT json_each.value FROM json_each(?))", - parameters: [ - pagesString, - ] - ) { try $0.getString(index: 0) } - - return Set(tableRows) - } catch { - throw PowerSyncError.operationFailed( - message: "Could not determine watched query tables", - underlyingError: error - ) - } - } - - static let maxOpId = Int64.max -} - -func openKotlinDBDefault( - schema: Schema, - dbFilename: String, - logger: DatabaseLogger, - initialStatements: [String] = [], - httpClient: HttpClient = PlatformHttpClient.shared -) -> PowerSyncDatabaseProtocol { - let rc = sqlite3_initialize() - if rc != 0 { - fatalError("Call to sqlite3_initialize() failed with \(rc)") - } - - let factory = sqlite3DatabaseFactory(initialStatements: initialStatements) - let kotlinDatabase = if dbFilename == ":memory:" { - openPowerSyncInMemory( - factory: factory, - schema: KotlinAdapter.Schema.toKotlin(schema), - logger: logger.kLogger - ) - } else { - PowerSyncDatabase( - factory: factory, - schema: KotlinAdapter.Schema.toKotlin(schema), - dbFilename: dbFilename, - logger: logger.kLogger - ) - } - - return KotlinPowerSyncDatabaseImpl( - kotlinDatabase: kotlinDatabase, - dbFilename: dbFilename, - logger: logger, - httpClient: httpClient, - schema: schema - ) -} - -func openKotlinDBWithPool( - schema: Schema, - pool: SQLiteConnectionPoolProtocol, - identifier: String, - logger: DatabaseLogger -) -> PowerSyncDatabaseProtocol { - return KotlinPowerSyncDatabaseImpl( - kotlinDatabase: openPowerSyncWithPool( - pool: pool.toKotlin(), - identifier: identifier, - schema: KotlinAdapter.Schema.toKotlin(schema), - logger: logger.kLogger - ), - dbFilename: identifier, - logger: logger, - httpClient: PlatformHttpClient.shared, - schema: schema - ) -} - -private struct ExplainQueryResult { - let addr: String - let opcode: String - let p1: Int64 - let p2: Int64 - let p3: Int64 -} - -extension Error { - func toPowerSyncError() -> PowerSyncKotlin.PowerSyncException { - return PowerSyncKotlin.PowerSyncException( - message: localizedDescription, - cause: PowerSyncKotlin.KotlinThrowable(message: localizedDescription) - ) - } -} - -func wrapLockContext( - callback: @Sendable @escaping (any ConnectionContext) throws -> Any -) throws -> PowerSyncKotlin.ThrowableLockCallback { - PowerSyncKotlin.wrapContextHandler { kotlinContext in - do { - return try PowerSyncKotlin.PowerSyncResult.Success( - value: callback( - KotlinConnectionContext( - ctx: kotlinContext - ) - )) - } catch { - return PowerSyncKotlin.PowerSyncResult.Failure( - exception: error.toPowerSyncError() - ) - } - } -} - -func wrapTransactionContext( - callback: @Sendable @escaping (any Transaction) throws -> Any -) throws -> PowerSyncKotlin.ThrowableTransactionCallback { - PowerSyncKotlin.wrapTransactionContextHandler { kotlinContext in - do { - return try PowerSyncKotlin.PowerSyncResult.Success( - value: callback( - KotlinTransactionContext( - ctx: kotlinContext - ) - )) - } catch { - return PowerSyncKotlin.PowerSyncResult.Failure( - exception: error.toPowerSyncError() - ) - } - } -} - -/// This returns the default directory in which we store SQLite database files. -func appleDefaultDatabaseDirectory() throws -> URL { - let fileManager = FileManager.default - - // Get the application support directory - guard let documentsDirectory = fileManager.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first else { - throw PowerSyncError.operationFailed(message: "Unable to find application support directory") - } - - return documentsDirectory.appendingPathComponent("databases") -} - -/// Deletes all SQLite files for a given database filename in the specified directory. -/// This includes the main database file and WAL mode files (.wal, .shm, and .journal if present). -/// Throws an error if a file exists but could not be deleted. Files that don't exist are ignored. -func deleteSQLiteFiles(dbFilename: String, in directory: URL) throws { - let fileManager = FileManager.default - - // SQLite files to delete: - // 1. Main database file: dbFilename - // 2. WAL file: dbFilename-wal - // 3. SHM file: dbFilename-shm - // 4. Journal file: dbFilename-journal (for rollback journal mode, though WAL mode typically doesn't use it) - - let filesToDelete = [ - dbFilename, - "\(dbFilename)-wal", - "\(dbFilename)-shm", - "\(dbFilename)-journal" - ] - - for filename in filesToDelete { - let fileURL = directory.appendingPathComponent(filename) - if fileManager.fileExists(atPath: fileURL.path) { - try fileManager.removeItem(at: fileURL) - } - // If file doesn't exist, we ignore it and continue - } -} diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift deleted file mode 100644 index c113659..0000000 --- a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift +++ /dev/null @@ -1,109 +0,0 @@ -import PowerSyncKotlin - -class KotlinLeaseAdapter: PowerSyncKotlin.SwiftLeaseAdapter { - let pointer: UnsafeMutableRawPointer - - init( - lease: SQLiteConnectionLease - ) { - pointer = UnsafeMutableRawPointer(lease.pointer) - } -} - -final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { - let pool: SQLiteConnectionPoolProtocol - var updateTrackingTask: Task? - - init( - pool: SQLiteConnectionPoolProtocol - ) { - self.pool = pool - } - - func linkExternalUpdates(callback: any KotlinSuspendFunction1) { - let sendableCallback = SendableSuspendFunction1(callback) - updateTrackingTask = Task { [pool] in - do { - for try await updates in pool.tableUpdates { - _ = try await sendableCallback.invoke(p1: updates) - } - } catch { - // none of these calls should actually throw - } - } - } - - func __dispose() async throws { - return try await wrapExceptions { - updateTrackingTask?.cancel() - updateTrackingTask = nil - } - } - - func __leaseRead(callback: any LeaseCallback) async throws { - return try await wrapExceptions { - let sendableCallback = SendableLeaseCallback(callback) - try await pool.read { lease in - try sendableCallback.execute( - lease: KotlinLeaseAdapter( - lease: lease - ) - ) - } - } - } - - func __leaseWrite(callback: any LeaseCallback) async throws { - return try await wrapExceptions { - let sendableCallback = SendableLeaseCallback(callback) - try await pool.write { lease in - try sendableCallback.execute( - lease: KotlinLeaseAdapter( - lease: lease - ) - ) - } - } - } - - func __leaseAll(callback: any AllLeaseCallback) async throws { - // FIXME, actually use all connections - // We currently only use this for schema updates - return try await wrapExceptions { - let sendableCallback = SendableAllLeaseCallback(callback) - try await pool.withAllConnections { writer, readers in - try sendableCallback.execute( - writeLease: KotlinLeaseAdapter( - lease: writer - ), - readLeases: readers.map { KotlinLeaseAdapter(lease: $0) } - ) - } - } - } - - private func wrapExceptions( - _ callback: () async throws -> Result - ) async throws -> Result { - do { - return try await callback() - } catch { - try? PowerSyncKotlin.throwPowerSyncException( - exception: PowerSyncException( - message: error.localizedDescription, - cause: nil - ) - ) - // Won't reach here - throw error - } - } -} - -extension SQLiteConnectionPoolProtocol { - func toKotlin() -> PowerSyncKotlin.SwiftSQLiteConnectionPool { - return PowerSyncKotlin.SwiftSQLiteConnectionPool( - adapter: SwiftSQLiteConnectionPoolAdapter(pool: self) - ) - } -} diff --git a/Sources/PowerSync/Kotlin/KotlinSuspendFunction1+Sendable.swift b/Sources/PowerSync/Kotlin/KotlinSuspendFunction1+Sendable.swift deleted file mode 100644 index d02d3cf..0000000 --- a/Sources/PowerSync/Kotlin/KotlinSuspendFunction1+Sendable.swift +++ /dev/null @@ -1,15 +0,0 @@ -import PowerSyncKotlin - -// Since SendableSuspendFunction1 is a protocol from PowerSyncKotlin, we need to use a wrapper class -// to make it Sendable since we can't extend the protocol directly with Sendable. -final class SendableSuspendFunction1: @unchecked Sendable { - private let wrapped: any PowerSyncKotlin.KotlinSuspendFunction1 - - init(_ function: any PowerSyncKotlin.KotlinSuspendFunction1) { - wrapped = function - } - - func invoke(p1: Any?) async throws -> Any? { - return try await wrapped.invoke(p1: p1) - } -} diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift deleted file mode 100644 index 9c256a4..0000000 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ /dev/null @@ -1,15 +0,0 @@ -import PowerSyncKotlin - -typealias KotlinSwiftBackendConnector = PowerSyncKotlin.SwiftBackendConnector -typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector -typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials -typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase - -extension KotlinPowerSyncBackendConnector: @retroactive @unchecked Sendable {} -extension KotlinPowerSyncCredentials: @retroactive @unchecked Sendable {} -extension PowerSyncKotlin.KermitLogger: @retroactive @unchecked Sendable {} -extension PowerSyncKotlin.SyncStatus: @retroactive @unchecked Sendable {} - -extension PowerSyncKotlin.CrudEntry: @retroactive @unchecked Sendable {} -extension PowerSyncKotlin.CrudBatch: @retroactive @unchecked Sendable {} -extension PowerSyncKotlin.CrudTransaction: @retroactive @unchecked Sendable {} diff --git a/Sources/PowerSync/Kotlin/LeaseCallback+Sendable.swift b/Sources/PowerSync/Kotlin/LeaseCallback+Sendable.swift deleted file mode 100644 index db2f51f..0000000 --- a/Sources/PowerSync/Kotlin/LeaseCallback+Sendable.swift +++ /dev/null @@ -1,15 +0,0 @@ -import PowerSyncKotlin - -// Since LeaseCallback is a protocol from PowerSyncKotlin, we need to use a wrapper class -// to make it Sendable since we can't extend the protocol directly with Sendable. -final class SendableLeaseCallback: @unchecked Sendable { - private let wrapped: any PowerSyncKotlin.LeaseCallback - - init(_ callback: any PowerSyncKotlin.LeaseCallback) { - self.wrapped = callback - } - - func execute(lease: PowerSyncKotlin.SwiftLeaseAdapter) throws { - try wrapped.execute(lease: lease) - } -} \ No newline at end of file diff --git a/Sources/PowerSync/Kotlin/SafeCastError.swift b/Sources/PowerSync/Kotlin/SafeCastError.swift deleted file mode 100644 index bd18664..0000000 --- a/Sources/PowerSync/Kotlin/SafeCastError.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -enum SafeCastError: Error, CustomStringConvertible { - case typeMismatch(expected: String, actual: String?) - - var description: String { - switch self { - case let .typeMismatch(expected, actual): - let actualType = actual.map { String(describing: type(of: $0)) } ?? "nil" - return "Type mismatch: Expected \(expected), but got \(actualType)." - } - } -} - -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 { - throw SafeCastError.typeMismatch(expected: "\(type)", actual: "\(value ?? "nil")") - } -} diff --git a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift b/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift deleted file mode 100644 index 668b3a1..0000000 --- a/Sources/PowerSync/Kotlin/db/KotlinConnectionContext.swift +++ /dev/null @@ -1,107 +0,0 @@ -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: [Sendable?]?) throws -> Int64 { - try ctx.execute( - sql: sql, - parameters: mapParameters(parameters) - ) - } - - func getOptional( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (any SqlCursor) throws -> RowType - ) throws -> RowType? { - return try wrapQueryCursorTyped( - mapper: mapper, - executor: { wrappedMapper in - try self.ctx.getOptional( - sql: sql, - parameters: mapParameters(parameters), - mapper: wrappedMapper - ) - }, - resultType: RowType?.self - ) - } - - func getAll( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (any SqlCursor) throws -> RowType - ) throws -> [RowType] { - return try wrapQueryCursorTyped( - mapper: mapper, - executor: { wrappedMapper in - try self.ctx.getAll( - sql: sql, - parameters: mapParameters(parameters), - mapper: wrappedMapper - ) - }, - resultType: [RowType].self - ) - } - - func get( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (any SqlCursor) throws -> RowType - ) throws -> RowType { - return try wrapQueryCursorTyped( - mapper: mapper, - executor: { wrappedMapper in - try self.ctx.get( - sql: sql, - parameters: mapParameters(parameters), - mapper: wrappedMapper - ) - }, - resultType: RowType.self - ) - } -} - -final class KotlinConnectionContext: KotlinConnectionContextProtocol, - // The Kotlin ConnectionContext is technically sendable, but we cannot annotate that - @unchecked Sendable -{ - let ctx: PowerSyncKotlin.ConnectionContext - - init(ctx: PowerSyncKotlin.ConnectionContext) { - self.ctx = ctx - } -} - -final class KotlinTransactionContext: Transaction, KotlinConnectionContextProtocol, - // The Kotlin ConnectionContext is technically sendable, but we cannot annotate that - @unchecked Sendable -{ - let ctx: PowerSyncKotlin.ConnectionContext - - init(ctx: PowerSyncKotlin.PowerSyncTransaction) { - self.ctx = ctx - } -} - -// Allows nil values to be passed to the Kotlin [Any] params -func mapParameters(_ parameters: [Any?]?) -> [Any] { - parameters?.map { item in - switch item { - case .none: NSNull() - case let item as PowerSyncDataTypeConvertible: - item.psDataType?.unwrap() ?? NSNull() - default: item as Any - } - } ?? [] -} diff --git a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift b/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift deleted file mode 100644 index 7b57c4a..0000000 --- a/Sources/PowerSync/Kotlin/db/KotlinSqlCursor.swift +++ /dev/null @@ -1,136 +0,0 @@ -import PowerSyncKotlin - -/// Implements `SqlCursor` using the Kotlin SDK -class KotlinSqlCursor: SqlCursor { - let base: PowerSyncKotlin.SqlCursor - - var columnCount: Int - - var columnNames: [String: Int] - - init(base: PowerSyncKotlin.SqlCursor) { - self.base = base - self.columnCount = Int(base.columnCount) - self.columnNames = base.columnNames.mapValues { input in input.intValue } - } - - func getBoolean(index: Int) throws -> Bool { - guard let result = getBooleanOptional(index: index) else { - throw SqlCursorError.nullValueFound(String(index)) - } - return result - } - - func getBooleanOptional(index: Int) -> Bool? { - base.getBoolean( - index: Int32(index) - )?.boolValue - } - - func getBoolean(name: String) throws -> Bool { - guard let result = try getBooleanOptional(name: name) else { - throw SqlCursorError.nullValueFound(name) - } - return result - } - - func getBooleanOptional(name: String) throws -> Bool? { - return getBooleanOptional(index: try guardColumnName(name)) - } - - func getDouble(index: Int) throws -> Double { - guard let result = getDoubleOptional(index: index) else { - throw SqlCursorError.nullValueFound(String(index)) - } - return result - } - - func getDoubleOptional(index: Int) -> Double? { - base.getDouble(index: Int32(index))?.doubleValue - } - - func getDouble(name: String) throws -> Double { - guard let result = try getDoubleOptional(name: name) else { - throw SqlCursorError.nullValueFound(name) - } - return result - } - - func getDoubleOptional(name: String) throws -> Double? { - return getDoubleOptional(index: try guardColumnName(name)) - } - - func getInt(index: Int) throws -> Int { - guard let result = getIntOptional(index: index) else { - throw SqlCursorError.nullValueFound(String(index)) - } - return result - } - - func getIntOptional(index: Int) -> Int? { - base.getLong(index: Int32(index))?.intValue - } - - func getInt(name: String) throws -> Int { - guard let result = try getIntOptional(name: name) else { - throw SqlCursorError.nullValueFound(name) - } - return result - } - - func getIntOptional(name: String) throws -> Int? { - return getIntOptional(index: try guardColumnName(name)) - } - - func getInt64(index: Int) throws -> Int64 { - guard let result = getInt64Optional(index: index) else { - throw SqlCursorError.nullValueFound(String(index)) - } - return result - } - - func getInt64Optional(index: Int) -> Int64? { - base.getLong(index: Int32(index))?.int64Value - } - - func getInt64(name: String) throws -> Int64 { - guard let result = try getInt64Optional(name: name) else { - throw SqlCursorError.nullValueFound(name) - } - return result - } - - func getInt64Optional(name: String) throws -> Int64? { - return getInt64Optional(index: try guardColumnName(name)) - } - - func getString(index: Int) throws -> String { - guard let result = getStringOptional(index: index) else { - throw SqlCursorError.nullValueFound(String(index)) - } - return result - } - - func getStringOptional(index: Int) -> String? { - base.getString(index: Int32(index)) - } - - func getString(name: String) throws -> String { - guard let result = try getStringOptional(name: name) else { - throw SqlCursorError.nullValueFound(name) - } - return result - } - - func getStringOptional(name: String) throws -> String? { - return getStringOptional(index: try guardColumnName(name)) - } - - @discardableResult - private func guardColumnName(_ name: String) throws -> Int { - guard let index = columnNames[name] else { - throw SqlCursorError.columnNotFound(name) - } - return index - } -} diff --git a/Sources/PowerSync/Kotlin/db/PowerSyncDataTypeConvertible.swift b/Sources/PowerSync/Kotlin/db/PowerSyncDataTypeConvertible.swift deleted file mode 100644 index bfe6d05..0000000 --- a/Sources/PowerSync/Kotlin/db/PowerSyncDataTypeConvertible.swift +++ /dev/null @@ -1,32 +0,0 @@ -import struct Foundation.Data - -/// Represents the set of types that are supported -/// by the PowerSync Kotlin Multiplatform SDK -public enum PowerSyncDataType { - case bool(Bool) - case string(String) - case int64(Int64) - case int32(Int32) - case double(Double) - case data(Data) -} - -/// Types conforming to this protocol will be -/// mapped to the specified ``PowerSyncDataType`` -/// before use by SQLite -public protocol PowerSyncDataTypeConvertible { - var psDataType: PowerSyncDataType? { get } -} - -extension PowerSyncDataType { - func unwrap() -> Any { - switch self { - case let .bool(bool): bool - case let .string(string): string - case let .int32(int32): int32 - case let .int64(int64): int64 - case let .double(double): double - case let .data(data): data - } - } -} \ No newline at end of file diff --git a/Sources/PowerSync/Kotlin/kotlinResolvePowerSyncLoadableExtensionPath.swift b/Sources/PowerSync/Kotlin/kotlinResolvePowerSyncLoadableExtensionPath.swift deleted file mode 100644 index 12af0ef..0000000 --- a/Sources/PowerSync/Kotlin/kotlinResolvePowerSyncLoadableExtensionPath.swift +++ /dev/null @@ -1,9 +0,0 @@ -import PowerSyncKotlin - -func kotlinResolvePowerSyncLoadableExtensionPath() throws -> String? { - do { - return try PowerSyncKotlin.resolvePowerSyncLoadableExtensionPath() - } catch { - throw PowerSyncError.operationFailed(message: "Failed to resolve PowerSync loadable extension path: \(error.localizedDescription)") - } -} \ No newline at end of file diff --git a/Sources/PowerSync/Kotlin/kotlinWithSession.swift b/Sources/PowerSync/Kotlin/kotlinWithSession.swift deleted file mode 100644 index 6ec03d0..0000000 --- a/Sources/PowerSync/Kotlin/kotlinWithSession.swift +++ /dev/null @@ -1,39 +0,0 @@ -import PowerSyncKotlin - -func kotlinWithSession( - db: OpaquePointer, - action: @escaping () throws -> ReturnType, -) throws -> WithSessionResult { - var innerResult: ReturnType? - let baseResult = try withSession( - db: UnsafeMutableRawPointer(db), - block: { - do { - innerResult = try action() - // We'll use the innerResult closure above to return the result - return PowerSyncResult.Success(value: nil) - } catch { - return PowerSyncResult.Failure(exception: error.toPowerSyncError()) - } - } - ) - - var outputResult: Result - if let failure = baseResult.blockResult as? PowerSyncResult.Failure { - outputResult = .failure(failure.exception.asError()) - } else if let result = innerResult { - outputResult = .success(result) - } else { - // The return type is not nullable, so we should have a result - outputResult = .failure( - PowerSyncError.operationFailed( - message: "Unknown error encountered when processing session", - ) - ) - } - - return WithSessionResult( - blockResult: outputResult, - affectedTables: baseResult.affectedTables - ) -} diff --git a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift b/Sources/PowerSync/Kotlin/wrapQueryCursor.swift deleted file mode 100644 index ebdd33a..0000000 --- a/Sources/PowerSync/Kotlin/wrapQueryCursor.swift +++ /dev/null @@ -1,83 +0,0 @@ -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. -func wrapQueryCursor( - mapper: @Sendable @escaping (SqlCursor) throws -> RowType, - // The Kotlin APIs return the results as Any, we can explicitly cast internally - executor: @Sendable @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 let mapperException { - // Allow propagating the error - throw mapperException - } - - return executionResult -} - -func wrapQueryCursorTyped( - mapper: @Sendable @escaping (SqlCursor) throws -> RowType, - // The Kotlin APIs return the results as Any, we can explicitly cast internally - executor: @Sendable @escaping (_ wrappedMapper: @escaping (PowerSyncKotlin.SqlCursor) -> Any?) throws -> Any?, - resultType: ReturnType.Type -) throws -> ReturnType { - return try safeCast( - wrapQueryCursor( - mapper: mapper, - executor: executor - ), to: - resultType - ) -} - -/// Throws a `PowerSyncException` using a helper provided by the Kotlin SDK. -/// We can't directly throw Kotlin `PowerSyncException`s from Swift, but we can delegate the throwing -/// to the Kotlin implementation. -/// Our Kotlin SDK methods handle thrown Kotlin `PowerSyncException` correctly. -/// The flow of events is as follows -/// Swift code calls `throwKotlinPowerSyncError` -/// This method calls the Kotlin helper `throwPowerSyncException` which is annotated as being able to throw `PowerSyncException` -/// The Kotlin helper throws the provided `PowerSyncException`. Since the method is annotated the exception propagates back to Swift, but in a form which can propagate back -/// to any calling Kotlin stack. -/// This only works for SKIEE methods which have an associated completion handler which handles annotated errors. -/// This seems to only apply for Kotlin suspending function bindings. -func throwKotlinPowerSyncError(message: String, cause: String? = nil) throws { - try throwPowerSyncException( - exception: PowerSyncKotlin.PowerSyncException( - message: message, - cause: PowerSyncKotlin.KotlinThrowable( - message: cause ?? message - ) - ) - ) -} diff --git a/Sources/PowerSync/PowerSyncCredentials.swift b/Sources/PowerSync/PowerSyncCredentials.swift index e1497e1..46a54c7 100644 --- a/Sources/PowerSync/PowerSyncCredentials.swift +++ b/Sources/PowerSync/PowerSyncCredentials.swift @@ -34,11 +34,6 @@ public struct PowerSyncCredentials: Codable, Sendable { self.token = token } - init(kotlin: KotlinPowerSyncCredentials) { - endpoint = kotlin.endpoint - token = kotlin.token - } - public func endpointUri(path: String) -> String { return "\(endpoint)/\(path)" } diff --git a/Sources/PowerSync/PowerSyncDatabase.swift b/Sources/PowerSync/PowerSyncDatabase.swift index c199cba..034b4e9 100644 --- a/Sources/PowerSync/PowerSyncDatabase.swift +++ b/Sources/PowerSync/PowerSyncDatabase.swift @@ -16,11 +16,20 @@ public func PowerSyncDatabase( logger: (any LoggerProtocol) = DefaultLogger(), initialStatements: [String] = [] ) -> PowerSyncDatabaseProtocol { - return openKotlinDBDefault( - schema: schema, + let (location, group) = if dbFilename == ":memory:" { + (DatabaseLocation.inMemory, DatabaseGroupCollection()) + } else { + (DatabaseLocation.inDefaultDirectory(name: dbFilename), .shared) + } + let pool = AsyncConnectionPool(location: location, logger: logger, initialStatements: initialStatements) + return PowerSyncDatabaseImpl( dbFilename: dbFilename, - logger: DatabaseLogger(logger), - initialStatements: initialStatements + identifier: dbFilename, + activeInstanceStore: group, + logger: logger, + pool: pool, + httpClient: PlatformHttpClient.shared, + schema: schema ) } @@ -45,10 +54,11 @@ public func OpenedPowerSyncDatabase( identifier: String, logger: (any LoggerProtocol) = DefaultLogger() ) -> PowerSyncDatabaseProtocol { - return openKotlinDBWithPool( - schema: schema, - pool: pool, + return PowerSyncDatabaseImpl( identifier: identifier, - logger: DatabaseLogger(logger) + logger: logger, + pool: pool, + httpClient: PlatformHttpClient.shared, + schema: schema ) } diff --git a/Sources/PowerSync/Protocol/PowerSyncError.swift b/Sources/PowerSync/Protocol/PowerSyncError.swift index a33c26e..757c2be 100644 --- a/Sources/PowerSync/Protocol/PowerSyncError.swift +++ b/Sources/PowerSync/Protocol/PowerSyncError.swift @@ -6,6 +6,15 @@ 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) + /// An error reported by SQLite. + case sqliteError( + extendedResultCode: Int32, + offset: Int32? = nil, + message: String? = nil, + errorString: String? = nil, + sql: String? = nil, + ) + /// A localized description of the error, providing details about the failure. public var errorDescription: String? { switch self { @@ -23,6 +32,21 @@ public enum PowerSyncError: Error, LocalizedError { // Fallback to a generic error description if neither message nor underlying error is provided return "An unknown error occurred." } + case let .sqliteError(extendedResultCode, offset, message, errorString, sql): + var msg = "SQLite failure (code \(extendedResultCode))" + if let errorString { + msg += ": \(errorString)" + } + if let offset { + msg += " at offset \(offset)" + } + if let message { + msg += ", \(message)" + } + if let sql { + msg += " for SQL: \(sql)" + } + return msg } } } diff --git a/Sources/PowerSync/Protocol/QueriesProtocol.swift b/Sources/PowerSync/Protocol/QueriesProtocol.swift index 0f315f4..9c5e746 100644 --- a/Sources/PowerSync/Protocol/QueriesProtocol.swift +++ b/Sources/PowerSync/Protocol/QueriesProtocol.swift @@ -22,42 +22,6 @@ public struct WatchOptions: Sendable { } public protocol Queries { - /// Execute a write query (INSERT, UPDATE, DELETE) - /// Using `RETURNING *` will result in an error. - @discardableResult - func execute(sql: String, parameters: [Sendable?]?) 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: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) async throws -> RowType - - /// Execute a read-only (SELECT) query and return the results. - func getAll( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) async throws -> [RowType] - - /// Execute a read-only (SELECT) query and return a single optional result. - func getOptional( - sql: String, - parameters: [Sendable?]?, - mapper: @Sendable @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: [Sendable?]?, - mapper: @Sendable @escaping (SqlCursor) throws -> RowType - ) throws -> AsyncThrowingStream<[RowType], Error> - func watch( options: WatchOptions ) throws -> AsyncThrowingStream<[RowType], Error> @@ -76,19 +40,59 @@ public protocol Queries { func readLock( callback: @Sendable @escaping (any ConnectionContext) throws -> R ) async throws -> R +} +public extension Queries { /// Execute a write transaction with the given callback func writeTransaction( callback: @Sendable @escaping (any Transaction) throws -> R - ) async throws -> R + ) async throws -> R { + try await writeLock { ctx in try TransactionImpl.run(conn: ctx, callback: callback) } + } /// Execute a read transaction with the given callback - func readTransaction( + func readTransaction( callback: @Sendable @escaping (any Transaction) throws -> R - ) async throws -> R -} + ) async throws -> R { + try await readLock { ctx in try TransactionImpl.run(conn: ctx, callback: callback) } + } + + /// Execute a write query (INSERT, UPDATE, DELETE) + /// Using `RETURNING *` will result in an error. + @discardableResult + func execute(sql: String, parameters: [Sendable?]?) async throws -> Int64 { + return try await self.writeLock { ctx in try ctx.execute(sql: sql, parameters: parameters) } + } -public extension Queries { + /// 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: [Sendable?]?, + mapper: @Sendable @escaping (SqlCursor) throws -> RowType + ) async throws -> RowType { + return try await self.readLock { ctx in try ctx.get(sql: sql, parameters: parameters, mapper: mapper) } + } + + /// Execute a read-only (SELECT) query and return the results. + func getAll( + sql: String, + parameters: [Sendable?]?, + mapper: @Sendable @escaping (SqlCursor) throws -> RowType + ) async throws -> [RowType] { + return try await self.readLock { ctx in try ctx.getAll(sql: sql, parameters: parameters, mapper: mapper) } + } + + /// Execute a read-only (SELECT) query and return a single optional result. + func getOptional( + sql: String, + parameters: [Sendable?]?, + mapper: @Sendable @escaping (SqlCursor) throws -> RowType + ) async throws -> RowType? { + return try await self.readLock { ctx in try ctx.getOptional(sql: sql, parameters: parameters, mapper: mapper) } + } + @discardableResult func execute(_ sql: String) async throws -> Int64 { return try await execute(sql: sql, parameters: []) @@ -115,6 +119,16 @@ public extension Queries { return try await getOptional(sql: sql, parameters: [], mapper: mapper) } + /// 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: [Sendable?]?, + mapper: @Sendable @escaping (SqlCursor) throws -> RowType + ) throws -> AsyncThrowingStream<[RowType], Error> { + return try watch(options: WatchOptions(sql: sql, parameters: parameters, mapper: mapper)) + } + func watch( _ sql: String, mapper: @Sendable @escaping (SqlCursor) throws -> RowType diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift index 7257064..8edb486 100644 --- a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -1,34 +1,82 @@ +import CSQLite import Foundation /// A lease representing a temporarily borrowed SQLite connection from the pool. +/// +/// This is an internal protocol and should not be implemented outside of the PowerSync SDK. public protocol SQLiteConnectionLease { /// Pointer to the underlying SQLite connection. /// This pointer should not be used outside of the closure which provided the lease. - var pointer: OpaquePointer { get } + var pointer: OpaquePointer { borrowing get } + + /// Executes a SQL statement, returning the amount of affected rows. + func execute(sql: String, parameters: [PowerSyncDataType?]) throws -> Int64 + + /// Prepares a statement from the SQL text and parameters, then invokes callback with a cursor. + func withIterator(sql: String, parameters: [PowerSyncDataType?], callback: (SQLiteStatementIteratorProtocol) throws -> T) throws -> T +} + +extension SQLiteConnectionLease { + /// Default implementation of ``execute(sql:parameters:)`` based on raw sqlite3 APIs. + public func execute(sql: String, parameters: [PowerSyncDataType?]) throws -> Int64 { + do { + var stmt = try NativeSqliteStatement(db: pointer, sql: sql) + try stmt.bindValues(parameters) + while try stmt.step() { + // Iterate through the statement. + } + } + + return sqlite3_changes64(pointer) + } + + /// Default implementation of ``withIterator(sql:parameters:callback:)`` based on raw sqlite3 APIs. + public func withIterator(sql: String, parameters: [PowerSyncDataType?], callback: (SQLiteStatementIteratorProtocol) throws -> T) throws -> T { + var stmt = try NativeSqliteStatement(db: pointer, sql: sql) + try stmt.bindValues(parameters) + return try withUnsafeMutablePointer(to: &stmt) { ptr in + let iterator = NativeStatementIterator(stmt: ptr) + return try callback(iterator) + } + } +} + +private struct NativeStatementIterator: SQLiteStatementIteratorProtocol { + var stmt: UnsafeMutablePointer + + func next(callback: (any SqlCursor) throws -> T) throws -> T? { + return try stmt.pointee.stepWithCursor(callback: callback) + } +} + +public protocol SQLiteStatementIteratorProtocol { + func next(callback: (_ cursor: SqlCursor) throws -> T) throws -> T? } /// An implementation of a connection pool providing asynchronous access to a single writer and multiple readers. /// This is the underlying pool implementation on which the higher-level PowerSync Swift SDK is built on. +/// +/// This is an internal protocol and should not be implemented outside of the PowerSync SDK. public protocol SQLiteConnectionPoolProtocol: Sendable { var tableUpdates: AsyncStream> { get } /// Calls the callback with a read-only connection temporarily leased from the pool. - func read( - onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void, - ) async throws + func read( + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> T, + ) async throws -> T /// Calls the callback with a read-write connection temporarily leased from the pool. - func write( - onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void, - ) async throws + func write( + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> T, + ) async throws -> T /// Invokes the callback with all connections leased from the pool. - func withAllConnections( + func withAllConnections( onConnection: @Sendable @escaping ( _ writer: SQLiteConnectionLease, _ readers: [SQLiteConnectionLease] - ) throws -> Void, - ) async throws + ) throws -> T, + ) async throws -> T /// Closes the connection pool and associated resources. func close() async throws diff --git a/Sources/PowerSync/Protocol/Schema/Column.swift b/Sources/PowerSync/Protocol/Schema/Column.swift index feb6a32..aaf2528 100644 --- a/Sources/PowerSync/Protocol/Schema/Column.swift +++ b/Sources/PowerSync/Protocol/Schema/Column.swift @@ -1,5 +1,4 @@ import Foundation -import PowerSyncKotlin public protocol ColumnProtocol: Equatable, Sendable { /// Name of the column. diff --git a/Sources/PowerSync/Protocol/Schema/Index.swift b/Sources/PowerSync/Protocol/Schema/Index.swift index 688ccf5..f57d205 100644 --- a/Sources/PowerSync/Protocol/Schema/Index.swift +++ b/Sources/PowerSync/Protocol/Schema/Index.swift @@ -1,5 +1,4 @@ import Foundation -import PowerSyncKotlin public protocol IndexProtocol: Sendable { /// diff --git a/Sources/PowerSync/Protocol/db/ConnectionContext.swift b/Sources/PowerSync/Protocol/db/ConnectionContext.swift index 4f904d1..ed54a14 100644 --- a/Sources/PowerSync/Protocol/db/ConnectionContext.swift +++ b/Sources/PowerSync/Protocol/db/ConnectionContext.swift @@ -27,7 +27,7 @@ public protocol ConnectionContext: Sendable { - Throws: PowerSyncError if the query fails */ - func getOptional( + func getOptional( sql: String, parameters: [Sendable?]?, mapper: @Sendable @escaping (SqlCursor) throws -> RowType @@ -45,7 +45,7 @@ public protocol ConnectionContext: Sendable { - Throws: PowerSyncError if the query fails */ - func getAll( + func getAll( sql: String, parameters: [Sendable?]?, mapper: @Sendable @escaping (SqlCursor) throws -> RowType @@ -63,7 +63,7 @@ public protocol ConnectionContext: Sendable { - Throws: PowerSyncError if the query fails or no result is found */ - func get( + func get( sql: String, parameters: [Sendable?]?, mapper: @Sendable @escaping (SqlCursor) throws -> RowType diff --git a/Sources/PowerSync/Protocol/db/CrudBatch.swift b/Sources/PowerSync/Protocol/db/CrudBatch.swift index 1147d58..8d68754 100644 --- a/Sources/PowerSync/Protocol/db/CrudBatch.swift +++ b/Sources/PowerSync/Protocol/db/CrudBatch.swift @@ -35,6 +35,6 @@ internal func completeCrudItems(_ db: any PowerSyncDatabaseProtocol, _ lastItemI return } } - try tx.execute(sql: "UPDATE ps_buckets SET target_op = 9223372036854775807 WHERE name = '$local'", parameters: nil) + try tx.execute(sql: "UPDATE ps_buckets SET target_op = ? WHERE name = '$local'", parameters: [PowerSyncDatabaseImpl.maxOpId]) } } diff --git a/Sources/PowerSync/Protocol/db/DataConvertible.swift b/Sources/PowerSync/Protocol/db/DataConvertible.swift new file mode 100644 index 0000000..9a963d9 --- /dev/null +++ b/Sources/PowerSync/Protocol/db/DataConvertible.swift @@ -0,0 +1,53 @@ +import Foundation + +// Represents the set of types that are supported as parameters for SQlite statements. +public enum PowerSyncDataType { + case bool(Bool) + case string(String) + case int64(Int64) + case int32(Int32) + case double(Double) + case data(Data) +} + +extension PowerSyncDataType { + init(from: Any) throws (PowerSyncError) { + if let bool = from as? Bool { + self = .bool(bool) + return + } + if let string = from as? String { + self = .string(string) + return + } + if let int = from as? Int64 { + self = .int64(int) + return + } + if let int = from as? Int { + self = .int64(Int64(int)) + return + } + if let int = from as? Int32 { + self = .int32(int) + return + } + if let double = from as? Double { + self = .double(double) + return + } + if let data = from as? Data { + self = .data(data) + return + } + + throw .operationFailed(message: "Invalid parameter, expected Bool, String, Int64, Int32, Double or Data") + } +} + +/// Types conforming to this protocol will be +/// mapped to the specified ``PowerSyncDataType`` +/// before use by SQLite +public protocol PowerSyncDataTypeConvertible { + var psDataType: PowerSyncDataType? { get } +} diff --git a/Sources/PowerSync/Protocol/db/SqlCursor.swift b/Sources/PowerSync/Protocol/db/SqlCursor.swift index 46d85d2..42f8012 100644 --- a/Sources/PowerSync/Protocol/db/SqlCursor.swift +++ b/Sources/PowerSync/Protocol/db/SqlCursor.swift @@ -6,122 +6,162 @@ public protocol SqlCursor { /// - 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 + func getBoolean(index: Int) throws(SqlCursorError) -> Bool /// Retrieves a `Bool` value from the specified column index. /// - Parameter index: The zero-based index of the column. /// - Returns: The `Bool` value if present, or `nil` if the value is null. func getBooleanOptional(index: Int) -> Bool? - /// Retrieves a `Bool` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. - /// - Returns: The `Bool` value. - func getBoolean(name: String) throws -> Bool - - /// Retrieves an optional `Bool` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. - /// - Returns: The `Bool` value if present, or `nil` if the value is null. - func getBooleanOptional(name: String) throws -> Bool? - /// Retrieves a `Double` value from the specified column name. /// - Parameter name: The name of the column. /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. /// - Returns: The `Double` value. - func getDouble(index: Int) throws -> Double + func getDouble(index: Int) throws(SqlCursorError) -> Double /// Retrieves a `Double` value from the specified column index. /// - Parameter index: The zero-based index of the column. /// - Returns: The `Double` value if present, or `nil` if the value is null. func getDoubleOptional(index: Int) -> Double? - /// Retrieves a `Double` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. - /// - Returns: The `Double` value. - func getDouble(name: String) throws -> Double - - /// Retrieves an optional `Double` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. - /// - Returns: The `Double` value if present, or `nil` if the value is null. - func getDoubleOptional(name: String) throws -> Double? - /// Retrieves an `Int` value from the specified column name. /// - Parameter name: The name of the column. /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. /// - Returns: The `Int` value. - func getInt(index: Int) throws -> Int + func getInt(index: Int) throws(SqlCursorError) -> Int /// Retrieves an `Int` value from the specified column index. /// - Parameter index: The zero-based index of the column. /// - Returns: The `Int` value if present, or `nil` if the value is null. func getIntOptional(index: Int) -> Int? - /// Retrieves an `Int` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. - /// - Returns: The `Int` value. - func getInt(name: String) throws -> Int - - /// Retrieves an optional `Int` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. - /// - Returns: The `Int` value if present, or `nil` if the value is null. - func getIntOptional(name: String) throws -> Int? - /// Retrieves an `Int64` value from the specified column name. /// - Parameter name: The name of the column. /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. /// - Returns: The `Int64` value. - func getInt64(index: Int) throws -> Int64 + func getInt64(index: Int) throws(SqlCursorError) -> Int64 /// Retrieves an `Int64` value from the specified column index. /// - Parameter index: The zero-based index of the column. /// - Returns: The `Int64` value if present, or `nil` if the value is null. func getInt64Optional(index: Int) -> Int64? - /// Retrieves an `Int64` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. - /// - Returns: The `Int64` value. - func getInt64(name: String) throws -> Int64 - - /// Retrieves an optional `Int64` value from the specified column name. - /// - Parameter name: The name of the column. - /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. - /// - Returns: The `Int64` value if present, or `nil` if the value is null. - func getInt64Optional(name: String) throws -> Int64? - /// Retrieves a `String` value from the specified column name. /// - Parameter name: The name of the column. /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist, or `SqlCursorError.nullValueFound` if the value is null. /// - Returns: The `String` value. - func getString(index: Int) throws -> String + func getString(index: Int) throws(SqlCursorError) -> String /// Retrieves a `String` value from the specified column index. /// - Parameter index: The zero-based index of the column. /// - Returns: The `String` value if present, or `nil` if the value is null. func getStringOptional(index: Int) -> String? + /// The number of columns in the result set. + var columnCount: Int { get } + + /// A dictionary mapping column names to their zero-based indices. + var columnNames: [String: Int] { get } +} + +public extension SqlCursor { + private func withResolvedIndex(name: String, read: (_ index: Int) throws(SqlCursorError) -> T) throws(SqlCursorError) -> T { + if let index = self.columnNames[name] { + do { + return try read(index) + } catch { + // Add correct column name instead of index + switch error { + case .columnNotFound(_): + throw .columnNotFound(name) + case .nullValueFound(_): + throw .nullValueFound(name) + } + } + } else { + throw .columnNotFound(name) + } + } + + /// 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 { + return try withResolvedIndex(name: name, read: self.getBoolean) + } + + /// 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? { + return try withResolvedIndex(name: name, read: self.getBooleanOptional) + } + + /// 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 { + return try withResolvedIndex(name: name, read: self.getDouble) + } + + /// 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? { + return try withResolvedIndex(name: name, read: self.getDoubleOptional) + } + + /// 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 { + return try withResolvedIndex(name: name, read: self.getInt) + } + + /// 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? { + return try withResolvedIndex(name: name, read: self.getIntOptional) + } + + /// 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 { + return try withResolvedIndex(name: name, read: self.getInt64) + } + + /// 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? { + return try withResolvedIndex(name: name, read: self.getInt64Optional) + } + /// 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 + func getString(name: String) throws -> String { + return try withResolvedIndex(name: name, read: self.getString) + } /// Retrieves an optional `String` value from the specified column name. /// - Parameter name: The name of the column. /// - Throws: `SqlCursorError.columnNotFound` if the column does not exist. /// - Returns: The `String` value if present, or `nil` if the value is null. - func getStringOptional(name: String) throws -> String? - - /// The number of columns in the result set. - var columnCount: Int { get } - - /// A dictionary mapping column names to their zero-based indices. - var columnNames: [String: Int] { get } + func getStringOptional(name: String) throws -> String? { + return try withResolvedIndex(name: name, read: self.getStringOptional) + } } /// An error type representing issues encountered while working with a `SqlCursor`. diff --git a/Sources/PowerSync/Utils/AsyncMutex.swift b/Sources/PowerSync/Utils/AsyncMutex.swift index 7867957..9804c48 100644 --- a/Sources/PowerSync/Utils/AsyncMutex.swift +++ b/Sources/PowerSync/Utils/AsyncMutex.swift @@ -1,3 +1,7 @@ +import BasicContainers +import DequeModule +import Foundation + /// An asynchronous mutex implemented as a simple actor. actor AsyncMutex { var inner: T @@ -10,3 +14,310 @@ actor AsyncMutex { try callback(&inner) } } + +/// A serialized asynchronous semaphore with associated items. +/// +/// Heavily inspired from https://github.com/powersync-ja/powersync-js/blob/main/packages/common/src/utils/mutex.ts. +final class AsyncSemaphore: Sendable { + let count: Int + fileprivate let state: Mutex> + + /// Creates a semaphore by consuming a queue of items. + init (_ values: consuming RigidDeque) { + self.count = values.count + state = Mutex(SemaphoreState( + available: values + )) + } + + /// Creates a semaphore from a single owned item. + convenience init(singleElement: consuming T) { + var queue = RigidDeque(capacity: 1) + queue.append(singleElement) + self.init(queue) + } + + fileprivate func returnItems(items: consuming RigidArray) { + state.withLock { state in + while !items.isEmpty { + state.returnItem(item: items.removeLast()) + } + } + } + + /// Acquires a flexible amount of items from this semaphore. + func acquire(count: Int) async throws(CancellationError) -> SemaphoreGrant { + precondition(count > 0 && count <= self.count) + do { + try Task.checkCancellation() + } catch { + // As per checkCancellation() docs, the method exclusively throws cancellation errors. + throw CancellationError() + } + + let node = TypedWaitNode(semaphore: self) + return try await node.acquire(count) + } +} + +extension AsyncSemaphore where T: Copyable { + convenience init(from: [T]) { + var queue = RigidDeque(capacity: from.count) + for item in from { + let _ = queue.pushLast(item) + } + self.init(queue) + } +} + +/// A grant to a resource in an ``AsyncSemaphore``. +/// +/// The grant is automatically returned when this struct goes out of scope. +struct SemaphoreGrant: ~Copyable { + private let semaphore: AsyncSemaphore + var acquiredItems: RigidArray + + fileprivate init(semaphore: AsyncSemaphore, items: consuming RigidArray) { + self.semaphore = semaphore + self.acquiredItems = items + } + + deinit { + semaphore.returnItems(items: acquiredItems) + } +} + +private struct SemaphoreState: ~Copyable { + // Available items that are not currently assigned to a waiter. + var available: RigidDeque + // Wait nodes are guaranteed to outlive these references because we call deactiveWaiter before + // it gets freed. + unowned var firstWaiter: SemaphoreWaitNode? + unowned var lastWaiter: SemaphoreWaitNode? + + var size: Int { + available.capacity + } + + deinit { + // This being called implies that the AsyncSemaphore is deallocated, which in turn implies + // that no pending waiters reference it. So the list of waiters must be empty. + assert(firstWaiter == nil) + assert(lastWaiter == nil) + } + + private mutating func deactivateWaiter(waiter: SemaphoreWaitNode) { + if !waiter.isActive { + return + } + + let prev = waiter.prev + let next = waiter.next + waiter.prev = nil + waiter.next = nil + + if let prev { + prev.next = next + } + if let next { + next.prev = prev + } + + if waiter === firstWaiter { + firstWaiter = next + } + if waiter === lastWaiter { + lastWaiter = prev + } + + waiter.isActive = false + waiter.continuation.resume(returning: ()) + } + + mutating func returnItem(item: consuming T) { + // Give it to the next waiter, if possible. + if let firstWaiter { + firstWaiter.pushItem(item: item) + if firstWaiter.isFull { + self.deactivateWaiter(waiter: firstWaiter) + } + } else { + // No pending waiter, return lease into pool. + available.append(item) + } + } + + mutating func returnItems(items: consuming RigidArray) { + while !items.isEmpty { + returnItem(item: items.removeLast()) + } + } + + mutating func abortWaiter(waiter: SemaphoreWaitNode) { + let items: RigidArray? = waiter.consumeItems() + deactivateWaiter(waiter: waiter) + if let items { + returnItems(items: items) + } + } + + mutating func addWaiter(requestedItems: Int, continuation: CheckedContinuation<(), Never>) -> SemaphoreWaitNode { + let node = SemaphoreWaitNode(requestedItems: requestedItems, continuation: continuation) + if let lastWaiter { + lastWaiter.next = node + node.prev = lastWaiter + self.lastWaiter = node + } else { + // First waiter + firstWaiter = node + lastWaiter = node + } + + // If there are items in the pool that haven't been assigned, we can pull them into this waiter. Note that this is + // only the case if we're the first waiter (otherwise, items would have been assigned to an earlier waiter). + while !available.isEmpty && !node.isFull { + node.pushItem(item: available.removeFirst()) + } + + if node.isFull { + self.deactivateWaiter(waiter: node) + } + return node + } +} + +// This isn't actually sendable, but we don't use it concurrently: While waiting, it's only mutated with a lock on SemaphoreState. +// Afterwards, it's sent to acquire() where it's only used in a single async context. +// +// This class is not generic: Making it generic (with `T: ~Copyable`) causes compiler errors because older runtimes can't compare +// objects with non-copyable type parameters by identity. +private final class SemaphoreWaitNode: @unchecked Sendable { + let requestedItems: Int + var acquiredItems: Int + // pointer to [T; requestedItems]. Note that the region from acquiredItems..requestItems is uninitialized + var itemsBuffer: UnsafeMutableRawPointer? + var continuation: CheckedContinuation<(), Never> + var isActive = true + + // Wait nodes are owned by a waiter. The only way for these to get dropped is by removing them + // from the linked list, which also unsets prev/next on adjacent nodes. + // This invariant is checked by also setting isActive = false when a wait node is disposed properly. + // In deinit, we crash if isActive is still set. + unowned var prev: SemaphoreWaitNode? + unowned var next: SemaphoreWaitNode? + + init(requestedItems: Int, continuation: CheckedContinuation<(), Never>) { + self.requestedItems = requestedItems + self.continuation = continuation + self.acquiredItems = 0 + } + + var isFull: Bool { + acquiredItems == requestedItems + } + + func pushItem(item: consuming T) { + precondition(!isFull && isActive) + + if let items = itemsBuffer { + items.assumingMemoryBound(to: T.self).advanced(by: acquiredItems).initialize(to: item) + } else { + let buffer: UnsafeMutablePointer = .allocate(capacity: requestedItems) + buffer.initialize(to: item) + + itemsBuffer = UnsafeMutableRawPointer(buffer) + } + + acquiredItems += 1 + } + + func consumeItems() -> RigidArray? { + if let itemsBuffer { + var array = RigidArray(capacity: acquiredItems) + let ptr = UnsafeBufferPointer(start: itemsBuffer.assumingMemoryBound(to: T.self), count: acquiredItems) + array.insert(moving: UnsafeMutableBufferPointer(mutating: ptr), at: 0) + // We don't have to deinitialize, array.insert moves elements out of the buffer. + itemsBuffer.deallocate() + self.itemsBuffer = nil + return array + } else { + return nil + } + } + + deinit { + assert(!isActive, "Wait node was dropped while active") + assert(itemsBuffer == nil, "Wait node leaked items") + assert(prev == nil && next == nil, "Wait node should be unlinked") + } +} + +private struct TypedWaitNode: Sendable, ~Copyable { + private enum TypedWaitNodeState: ~Copyable { + case hasWaiter(SemaphoreWaitNode) + case cancelled + } + + private let inner: Mutex = Mutex(nil) + private let semaphore: AsyncSemaphore + + init(semaphore: AsyncSemaphore) { + self.semaphore = semaphore + } + + /// Adds a wait node to the semaphore and waits for a grant or that node to be aborted. + private func acquireInternal(count: Int) async { + await withTaskCancellationHandler(operation: { + await withCheckedContinuation { continuation in + inner.withLock { state in + if state != nil { + continuation.resume() + return + } + + let waiter = semaphore.state.withLock { state in + state.addWaiter(requestedItems: count, continuation: continuation) + } + state = .hasWaiter(waiter) + } + } + }, onCancel: { + inner.withLock { state in + if case let .hasWaiter(waiter) = state { + semaphore.state.withLock { state in + state.abortWaiter(waiter: waiter) + } + } + state = .cancelled + } + }) + } + + consuming func acquire(_ count: Int) async throws(CancellationError) -> SemaphoreGrant { + await self.acquireInternal(count: count) + + var didComplete = false + let items = inner.withLock { state in + switch state! { + case .hasWaiter(let node): + assert(!node.isActive) + let items: RigidArray? = node.consumeItems() + didComplete = node.isFull + return items + case .cancelled: + return nil + } + } + + if let items { + if didComplete { + return SemaphoreGrant(semaphore: semaphore, items: items) + } + + // We were able to obtain some items before aborting the read. Return those now. + semaphore.returnItems(items: items) + } + + throw CancellationError() + } +} diff --git a/Sources/PowerSync/Utils/MergeItemSequence.swift b/Sources/PowerSync/Utils/MergeItemSequence.swift new file mode 100644 index 0000000..257e931 --- /dev/null +++ b/Sources/PowerSync/Utils/MergeItemSequence.swift @@ -0,0 +1,109 @@ +/// An ``AsyncSequence`` merging all items emitted between calls to ``AsyncIteratorProtocol/next``. +/// +/// This is useful for sequences where we just want to know that an event has occurred, without needing +/// to know about the exact event. We use this internally to implement `watch()` queries with a throttle: +/// If any amount of events have occurred between throttled calls to `next()`, we want to dispatch a single +/// event. +struct MergeItemSequence: AsyncSequence where Base.Element == () { + typealias AsyncIterator = IteratorImpl + typealias Element = () + + private let inner: Base + + init(inner: Base) { + self.inner = inner + } + + func makeAsyncIterator() -> IteratorImpl { + IteratorImpl(inner: self.inner) + } + + private final class IteratorState: Sendable { + let inner = Mutex(MergeSequenceState.idle) + } + + final class IteratorImpl: AsyncIteratorProtocol, Sendable { + private let state: IteratorState + let pollTask: Task<(), any Error> + + init(inner: Base) { + let state = IteratorState() + self.pollTask = Task { + defer { state.inner.withLock { $0.transitionToDone() } } + + do { + for try await event in inner { + state.inner.withLock { $0.markHasEvent(event: .success(event)) } + } + } catch { + state.inner.withLock { $0.markHasEvent(event: .failure(error)) } + } + } + + self.state = state + } + + func next() async throws -> ()? { + try await withTaskCancellationHandler( + operation: { + try await withCheckedThrowingContinuation { continuation in + state.inner.withLock { $0.registerListener(continuation) } + } + }, + onCancel: { + pollTask.cancel() + state.inner.withLock { + if case .waitingForUpstream(let continuation) = $0 { + continuation.resume(returning: nil) + } + $0 = .done + } + } + ) + } + + deinit { + self.pollTask.cancel() + } + } +} + +private enum MergeSequenceState { + /// No one waiting on next(), no pending emit either. + case idle + /// We're waiting in next() for an upstream emission. + case waitingForUpstream(CheckedContinuation<()?, any Error>) + /// We have an upstream emission that has not yet been sent (due to backpressure or throttle). + case hasPendingEvent(Result<(), any Error>) + case done + + mutating func registerListener(_ continuation: CheckedContinuation<()?, any Error>) { + switch self { + case .idle: + self = .waitingForUpstream(continuation) + case .waitingForUpstream(_): + fatalError("Async throttle sequence has two concurrent listeners?!") + case .hasPendingEvent(let pending): + continuation.resume(with: pending.map { _ in () }) + self = .idle + case .done: + continuation.resume(returning: nil) + } + } + + mutating func markHasEvent(event: Result<(), any Error>) { + if case let .waitingForUpstream(continuation) = self { + continuation.resume(with: event.map { _ in () }) + self = .idle + } else { + self = .hasPendingEvent(event) + } + } + + mutating func transitionToDone() { + if case let .waitingForUpstream(continuation) = self { + continuation.resume(returning: nil) + } + self = .done + } +} diff --git a/Sources/PowerSync/Utils/Mutex.swift b/Sources/PowerSync/Utils/Mutex.swift index d311317..80ff293 100644 --- a/Sources/PowerSync/Utils/Mutex.swift +++ b/Sources/PowerSync/Utils/Mutex.swift @@ -19,7 +19,7 @@ struct Mutex: @unchecked Sendable, ~Copyable { self.value.deallocate() } - func withLock(_ action: (_ value: inout T) throws -> R) rethrows -> R { + func withLock(_ action: (_ value: inout T) throws -> R) rethrows -> R { os_unfair_lock_lock(self.osLock) defer { os_unfair_lock_unlock(self.osLock) } diff --git a/Sources/PowerSync/Utils/sleepForSeconds.swift b/Sources/PowerSync/Utils/sleepForSeconds.swift new file mode 100644 index 0000000..5b8d39c --- /dev/null +++ b/Sources/PowerSync/Utils/sleepForSeconds.swift @@ -0,0 +1,9 @@ +import Foundation + +func sleepForSeconds(seconds: TimeInterval) async throws { + if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { + try await Task.sleep(for: .seconds(seconds)) + } else { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } +} diff --git a/Sources/PowerSync/Utils/withSession.swift b/Sources/PowerSync/Utils/withSession.swift deleted file mode 100644 index f80de29..0000000 --- a/Sources/PowerSync/Utils/withSession.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -public struct WithSessionResult: Sendable { - public let blockResult: Result - public let affectedTables: Set -} - -/// Executes an action within a SQLite database connection session and -/// returns a `WithSessionResult` containing the action result and the set of -/// tables that were affected during the session. -/// -/// The raw SQLite connection is only available in some niche scenarios. This helper is -/// intended for internal use. -/// -/// - The provided `action` is executed inside a database session. -/// - Any success or failure from the `action` is captured in -/// `WithSessionResult.blockResult`. -/// - The set of updated table names is returned in -/// `WithSessionResult.affectedTables`. -/// -/// Example usage: -/// ```swift -/// let result = try withSession(db: database) { -/// // perform database work and return a value -/// try someOperation() -/// } -/// -/// switch result.blockResult { -/// case .success(let value): -/// print("Operation succeeded with: \(value)") -/// case .failure(let error): -/// print("Operation failed: \(error)") -/// } -/// -/// print("Updated tables: \(result.affectedTables)") -/// ``` -/// -/// - Parameters: -/// - db: The raw SQLite connection pointer used to open the session. -/// - action: The operation to execute within the session. Its return value is -/// propagated into `WithSessionResult.blockResult` on success. -/// - Returns: A `WithSessionResult` containing the action's result and the set -/// of affected table names. -/// - Throws: Any error thrown while establishing the session or executing the -/// provided `action`. -public func withSession( - db: OpaquePointer, - action: @escaping () throws -> ReturnType -) throws -> WithSessionResult { - return try kotlinWithSession( - db: db, - action: action - ) -} diff --git a/Sources/PowerSync/resolvePowerSyncLoadableExtensionPath.swift b/Sources/PowerSync/resolvePowerSyncLoadableExtensionPath.swift index 99a3fb1..dfe51a2 100644 --- a/Sources/PowerSync/resolvePowerSyncLoadableExtensionPath.swift +++ b/Sources/PowerSync/resolvePowerSyncLoadableExtensionPath.swift @@ -1,62 +1,12 @@ -/// Resolves the PowerSync SQLite extension path. -/// -/// This function returns the file system path to the PowerSync SQLite extension library. -/// For use with extension loading APIs or SQLite queries. -/// -/// ## Platform Behavior -/// -/// ### watchOS -/// On watchOS, the extension needs to be loaded statically. This function returns `nil` -/// on watchOS because the extension is statically linked and doesn't require a path. -/// Calling this function will auto-register the extension in watchOS. The static -/// initialization ensures the extension is available without requiring dynamic loading. -/// -/// ### Other Platforms -/// In other environments (iOS, macOS, tvOS, etc.), the extension needs to be loaded -/// dynamically using the path returned by this function. You'll need to: -/// 1. Enable extension loading on your SQLite connection -/// 2. Load the extension using the returned path -/// -/// ## Example Usage -/// -/// ### Loading with SQLite API -/// ```swift -/// guard let extensionPath = try resolvePowerSyncLoadableExtensionPath() else { -/// // On watchOS, extension is statically loaded, no path needed -/// return -/// } -/// -/// // Enable extension loading -/// sqlite3_enable_load_extension(db, 1) -/// -/// // Load the extension -/// var errorMsg: UnsafeMutablePointer? -/// let result = sqlite3_load_extension( -/// db, -/// extensionPath, -/// "sqlite3_powersync_init", -/// &errorMsg -/// ) -/// if result != SQLITE_OK { -/// // Handle error -/// } -/// ``` -/// -/// ### Loading with SQL Query -/// ```swift -/// guard let extensionPath = try resolvePowerSyncLoadableExtensionPath() else { -/// // On watchOS, extension is statically loaded, no path needed -/// return -/// } -/// let escapedPath = extensionPath.replacingOccurrences(of: "'", with: "''") -/// let query = "SELECT load_extension('\(escapedPath)', 'sqlite3_powersync_init')" -/// try db.execute(sql: query) -/// ``` -/// -/// - Returns: The file system path to the PowerSync SQLite extension, or `nil` on watchOS -/// (where the extension is statically loaded and doesn't require a path) -/// - Throws: An error if the extension path cannot be resolved on platforms that require it or -/// if the extension could not be registered on watchOS. -public func resolvePowerSyncLoadableExtensionPath() throws -> String? { - return try kotlinResolvePowerSyncLoadableExtensionPath() +/// Loads the PowerSync SQLite core extension. +/// +/// In older versions of the Swift SDK, this used to return a file path that would have to +/// be loaded with `sqlite3_load_extension`. This is no longer relevant: Calling this +/// function invokes `sqlite3_auto_extension` to load the core extension automatically. +/// +/// - Returns: `nil` +/// - Throws: An error if the extension could not be registered. +public func resolvePowerSyncLoadableExtensionPath() throws(PowerSyncError) -> String? { + try registerPowerSyncCoreExtension() + return nil } diff --git a/Sources/PowerSyncCoreShim/empty.c b/Sources/PowerSyncCoreShim/empty.c new file mode 100644 index 0000000..03f9cb9 --- /dev/null +++ b/Sources/PowerSyncCoreShim/empty.c @@ -0,0 +1 @@ +// Intentionally left empty to ensure this target generates an object file diff --git a/Sources/PowerSyncCoreShim/include/powersync_core.h b/Sources/PowerSyncCoreShim/include/powersync_core.h new file mode 100644 index 0000000..b24bbd5 --- /dev/null +++ b/Sources/PowerSyncCoreShim/include/powersync_core.h @@ -0,0 +1 @@ +void sqlite3_powersync_init(void); diff --git a/Sources/PowerSyncCoreShim/module.modulemap b/Sources/PowerSyncCoreShim/module.modulemap new file mode 100644 index 0000000..fc46926 --- /dev/null +++ b/Sources/PowerSyncCoreShim/module.modulemap @@ -0,0 +1,4 @@ +module PowerSyncCoreShim { + header "include/powersync_core.h" + export * +} diff --git a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift index 6f9d948..c08ee1a 100644 --- a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift +++ b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift @@ -26,33 +26,10 @@ public extension Configuration { mutating func configurePowerSync( schema: Schema ) throws { - // Handles the case on WatchOS where the extension is statically loaded. - // For WatchOS: We need to statically register the extension before SQLite connections are established. - // This should only throw on non-WatchOS platforms if the extension path cannot be resolved. + // This calls sqlite3_auto_extension and enables the PowerSync core extension for all + // new connections. let extensionPath = try resolvePowerSyncLoadableExtensionPath() - - // Register the PowerSync core extension - prepareDatabase { database in - if let extensionPath = extensionPath { - /// The extension is loaded as an automatic extension if resolvePowerSyncLoadableExtensionPath returns nil - /// We should dynamically load the extension if we received an extensionPath - let extensionLoadResult = sqlite3_enable_load_extension(database.sqliteConnection, 1) - if extensionLoadResult != SQLITE_OK { - throw PowerSyncGRDBError.extensionLoadFailed("Could not enable extension loading") - } - var errorMsg: UnsafeMutablePointer? - let loadResult = sqlite3_load_extension(database.sqliteConnection, extensionPath, "sqlite3_powersync_init", &errorMsg) - if loadResult != SQLITE_OK { - if let errorMsg = errorMsg { - let message = String(cString: errorMsg) - sqlite3_free(errorMsg) - throw PowerSyncGRDBError.extensionLoadFailed(message) - } else { - throw PowerSyncGRDBError.unknownExtensionLoadError - } - } - } - } + assert(extensionPath == nil) // Supply the PowerSync views as a SchemaSource let powerSyncSchemaSource = PowerSyncSchemaSource( diff --git a/Sources/PowerSyncGRDB/Connections/GRDBConnectionLease.swift b/Sources/PowerSyncGRDB/Connections/GRDBConnectionLease.swift index 7104f24..36b7be3 100644 --- a/Sources/PowerSyncGRDB/Connections/GRDBConnectionLease.swift +++ b/Sources/PowerSyncGRDB/Connections/GRDBConnectionLease.swift @@ -4,15 +4,17 @@ import PowerSync /// Internal lease object that exposes the raw GRDB SQLite connection pointer. /// -/// This is used to bridge GRDB's managed database connection with the Kotlin SDK, +/// This is used to bridge GRDB's managed database connection with the Swift SDK, /// allowing direct access to the underlying SQLite connection for PowerSync operations. final class GRDBConnectionLease: SQLiteConnectionLease { - var pointer: OpaquePointer + let pointer: OpaquePointer + var database: Database init(database: Database) throws { guard let connection = database.sqliteConnection else { throw PowerSyncGRDBError.connectionUnavailable } - pointer = connection + self.pointer = connection + self.database = database } -} +} \ No newline at end of file diff --git a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift index ea954d4..ff9ae93 100644 --- a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift +++ b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift @@ -37,60 +37,60 @@ actor GRDBConnectionPool: SQLiteConnectionPoolProtocol { tableUpdatesContinuation = tempContinuation } - func read( - onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void - ) async throws { - try await pool.read { database in + func read( + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> T + ) async throws -> T { + return try await pool.read { database in try onConnection( GRDBConnectionLease(database: database) ) } } - func write( - onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void - ) async throws { - // Don't start an explicit transaction, we do this internally - let result = try await pool.writeWithoutTransaction { database in - guard let pointer = database.sqliteConnection else { - throw PowerSyncGRDBError.connectionUnavailable + func write( + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> T + ) async throws -> T { + // Don't start an explicit transaction, we do this internally. + let (result, updates) = try await pool.writeWithoutTransaction { database in + // This installs a temporary update hook, which breaks if GRDB had its own. Currently, we rely on + // GRDB only installing update hooks for statements that need it (see https://github.com/groue/GRDB.swift/blob/36e30a6f1ef10e4194f6af0cff90888526f0c115/GRDB/Core/TransactionObserver.swift#L266-L275), + // note that `statementObservations` is set in `statementWillExecute` and cleared after a statement + // has completed or failed. + // So since we have exclusive access to the write connection here, no GRDB-active statement can run and we + // can safely install our own hooks. + // In the future, we would like to use high-level GRDB APIs for this instead. However, we're blocked + // by https://github.com/groue/GRDB.swift/issues/1863 on that. + try collectWrites(db: database.sqliteConnection!) { + try onConnection(GRDBConnectionLease(database: database)) } + } - let sessionResult = try withSession( - db: pointer, - ) { - try onConnection( - GRDBConnectionLease(database: database) - ) - } + if !updates.isEmpty { + tableUpdatesContinuation?.yield(updates) - return sessionResult - } - // Notify PowerSync of these changes - tableUpdatesContinuation?.yield(result.affectedTables) - // Notify GRDB, this needs to be a write (transaction) - try await pool.write { database in - // Notify GRDB about these changes - for table in result.affectedTables { - try database.notifyChanges(in: Table(table)) + // Notify GRDB, this needs to be a write (transaction) + try await pool.write { database in + // Notify GRDB about these changes + for table in updates { + try database.notifyChanges(in: Table(table)) + } } } - - if case let .failure(error) = result.blockResult { - throw error - } + return result } - func withAllConnections( - onConnection: @Sendable @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) throws -> Void - ) async throws { + func withAllConnections( + onConnection: @Sendable @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) throws -> T + ) async throws -> T { // FIXME, we currently don't support updating the schema - try await pool.writeWithoutTransaction { database in + let result = try await pool.writeWithoutTransaction { database in let lease = try GRDBConnectionLease(database: database) - try onConnection(lease, []) + let result = try onConnection(lease, []) database.clearSchemaCache() + return result } pool.invalidateReadOnlyConnections() + return result } func close() async throws { diff --git a/Sources/PowerSyncGRDB/Connections/collectWrites.swift b/Sources/PowerSyncGRDB/Connections/collectWrites.swift new file mode 100644 index 0000000..800887f --- /dev/null +++ b/Sources/PowerSyncGRDB/Connections/collectWrites.swift @@ -0,0 +1,30 @@ +import CSQLite + +/// Collect writes that a callback makes on a database connection. +/// +/// We can't install commit / rollback hooks since GRDB keeps those installed, so this may also +/// return writes for transactions that have been rolled back. This may cause more queries than +/// intended to update again, which doesn't impact correctness. +func collectWrites(db: OpaquePointer, callback: () throws -> T) rethrows -> (T, Set) { + var notifications = Set() + // Install temporary update/commit/rollback hooks. GRDB should doesn't install hooks outside of + // statements, so this doesn't interfere with GRDB (but we have an assert to be sure). + let result = try withUnsafeMutablePointer(to: ¬ifications) { ptr in + let prevUpdates = sqlite3_update_hook(db, { context, type, dbName, tableName, rowId in + if let tableName, let context { + let table = String(cString: tableName) + context.assumingMemoryBound(to: Set.self).pointee.insert(table) + } + }, ptr) + assert(prevUpdates == nil, "Unexpected existing update hook") + + defer { + // Uninstall our hooks + sqlite3_update_hook(db, nil, nil) + } + + return try callback() + } + + return (result, notifications) +} diff --git a/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift b/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift deleted file mode 100644 index 0830463..0000000 --- a/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift +++ /dev/null @@ -1,24 +0,0 @@ -// The system SQLite does not expose this, -// linking CSQLite (or another implementation of SQLite) provides them -// Declare the missing function manually -@_silgen_name("sqlite3_enable_load_extension") -func sqlite3_enable_load_extension( - _ db: OpaquePointer?, - _ onoff: Int32 -) -> Int32 - -@_silgen_name("sqlite3_powersync_init") -func sqlite3_powersync_init( - _ db: OpaquePointer?, - _: OpaquePointer?, - _: OpaquePointer? -) -> Int32 - -// Similarly for sqlite3_load_extension if needed: -@_silgen_name("sqlite3_load_extension") -func sqlite3_load_extension( - _ db: OpaquePointer?, - _ fileName: UnsafePointer?, - _ procName: UnsafePointer?, - _ errMsg: UnsafeMutablePointer?>? -) -> Int32 diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index db8bd18..57f522e 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -90,7 +90,7 @@ final class GRDBTests: XCTestCase { pool: pool, schema: schema, identifier: dbIdentifier, - logger: DatabaseLogger(logger) + logger: logger ) try await database.disconnectAndClear() @@ -352,6 +352,25 @@ final class GRDBTests: XCTestCase { watchTask.cancel() } + func testGRDBUpdatesFromIndirectPowerSyncFunction() async throws { + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["a user"] + ) + + var events = ValueObservation.tracking { + try User.order(User.Columns.name.asc).fetchAll($0) + }.values(in: pool).makeAsyncIterator() + let first = try await events.next() + XCTAssertEqual(first?.count, 1) + + // We want to assert that internal statements from the core extension still + // update GRDB value observations. + try await database.disconnectAndClear() + let second = try await events.next() + XCTAssertEqual(second?.count, 0) + } + func testShouldThrowErrorsFromPowerSync() async throws { do { try await database.execute( @@ -427,7 +446,7 @@ final class GRDBTests: XCTestCase { let warningIndex = logs.getLogs().firstIndex( where: { value in - value.contains("debug: PowerSyncVersion") + value.contains("debug: Opened connection. SQLite version") } ) diff --git a/Tests/PowerSyncTests/ConnectTests.swift b/Tests/PowerSyncTests/ConnectTests.swift index 6c7f2fe..b959c8b 100644 --- a/Tests/PowerSyncTests/ConnectTests.swift +++ b/Tests/PowerSyncTests/ConnectTests.swift @@ -18,10 +18,10 @@ final class ConnectTests: XCTestCase { ), ]) - database = openKotlinDBDefault( + database = PowerSyncDatabase( schema: schema, dbFilename: ":memory:", - logger: DatabaseLogger(DefaultLogger()) + logger: DefaultLogger() ) try await database.disconnectAndClear() } diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index 47ac61c..79121b8 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -19,10 +19,10 @@ final class CrudTests: XCTestCase { ), ]) - database = openKotlinDBDefault( + database = PowerSyncDatabase( schema: schema, dbFilename: ":memory:", - logger: DatabaseLogger(DefaultLogger()) + logger: DefaultLogger() ) try await database.disconnectAndClear() } diff --git a/Tests/PowerSyncTests/EncryptionTests.swift b/Tests/PowerSyncTests/EncryptionTests.swift index 4dbc74b..f0b3ec0 100644 --- a/Tests/PowerSyncTests/EncryptionTests.swift +++ b/Tests/PowerSyncTests/EncryptionTests.swift @@ -14,7 +14,7 @@ struct EncryptionTests { let database = PowerSyncDatabase( schema: Schema(), dbFilename: "linkSqlite3Mc", - logger: DatabaseLogger(DefaultLogger()) + logger: DefaultLogger() ) let cipher = try await database.get("pragma cipher", mapper: {cursor in @@ -37,7 +37,7 @@ struct EncryptionTests { ), ]), dbFilename: "encrypted.db", - logger: DatabaseLogger(DefaultLogger()), + logger: DefaultLogger(), initialStatements: [ "pragma key = 'foobar'" ], @@ -56,7 +56,7 @@ struct EncryptionTests { ), ]), dbFilename: "encrypted.db", - logger: DatabaseLogger(DefaultLogger()), + logger: DefaultLogger(), initialStatements: [ "pragma key = 'wrong password'", ], diff --git a/Tests/PowerSyncTests/Implementation/StatementTests.swift b/Tests/PowerSyncTests/Implementation/StatementTests.swift new file mode 100644 index 0000000..95e3293 --- /dev/null +++ b/Tests/PowerSyncTests/Implementation/StatementTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import PowerSync +import Testing + +struct StatementTests { + @Test func bindValues() throws { + let connection = try DatabaseLocation.inMemory.openConnection(writer: true) + let lease = connection.asLease() + try lease.withIterator( + sql: "SELECT ?, ?, ?, ?, ?, ?, typeof(?), typeof(?)", + parameters: [ + nil, + .bool(false), + .int32(32), + .int64(64), + .double(3.14), + .string("hello"), + .data(Data()), + .data(Data([1, 2, 3])) + ], + callback: { iterator in + var hadRow = false + try iterator.next { cursor in + hadRow = true + + try #require(cursor.getStringOptional(index: 0) == nil) + try #require(cursor.getBoolean(index: 1) == false) + try #require(cursor.getInt(index: 2) == 32) + try #require(cursor.getInt(index: 3) == 64) + try #require(cursor.getDouble(index: 4) == 3.14) + try #require(cursor.getString(index: 5) == "hello") + try #require(cursor.getString(index: 6) == "blob") + try #require(cursor.getString(index: 7) == "blob") + } + + try #require(hadRow) + } + ) + } +} diff --git a/Tests/PowerSyncTests/Kotlin/ActiveInstanceStoreTests.swift b/Tests/PowerSyncTests/Kotlin/ActiveInstanceStoreTests.swift new file mode 100644 index 0000000..30aedc3 --- /dev/null +++ b/Tests/PowerSyncTests/Kotlin/ActiveInstanceStoreTests.swift @@ -0,0 +1,37 @@ +@testable import PowerSync +import Testing + +@Suite +struct MultipleInstanceTest { + @Test func warnsAboutMultipleInstances() async throws { + let pool = AsyncConnectionPool(location: .inMemory, logger: DefaultLogger()) + let logWriter = TestLogWriterAdapter() + let logger = DefaultLogger(minSeverity: .warning, writers: [logWriter]) + let schema = Schema() + + let a = PowerSyncDatabaseImpl(identifier: "id", logger: logger, pool: pool, httpClient: PlatformHttpClient.shared, schema: schema) + try #require(logWriter.getLogs().isEmpty) + + let b = PowerSyncDatabaseImpl(identifier: "id", logger: logger, pool: pool, httpClient: PlatformHttpClient.shared, schema: schema) + let _ = try #require(logWriter.getLogs().first { $0.contains("Multiple PowerSync instances for the same database have been detected.") }) + + // Ensure databases are kept around until the end of the test (if a gets closed before, we would't see the warning). + let _ = consume a + let _ = consume b + } + + @Test func doesNotWarnForClosedInstances() async throws { + let pool = AsyncConnectionPool(location: .inMemory, logger: DefaultLogger()) + let logWriter = TestLogWriterAdapter() + let logger = DefaultLogger(minSeverity: .warning, writers: [logWriter]) + let schema = Schema() + + do { + let _ = PowerSyncDatabaseImpl(identifier: "id2", logger: logger, pool: pool, httpClient: PlatformHttpClient.shared, schema: schema) + } + + let b = PowerSyncDatabaseImpl(identifier: "id2", logger: logger, pool: pool, httpClient: PlatformHttpClient.shared, schema: schema) + try #require(logWriter.getLogs().isEmpty) + let _ = consume b + } +} diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 03f0131..6ee95f5 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -22,7 +22,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { database = PowerSyncDatabase( schema: schema, dbFilename: ":memory:", - logger: DatabaseLogger(DefaultLogger()) + logger: DefaultLogger() ) try await database.disconnectAndClear() } @@ -43,7 +43,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) """) } } @@ -85,7 +85,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? """) } } @@ -130,7 +130,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? """) } } @@ -214,7 +214,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: SELECT id, name, email FROM usersfail WHERE id = ? """) } } @@ -297,7 +297,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTFail("Expected an error to be thrown") } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: EXPLAIN SELECT name FROM usersfail ORDER BY id + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: EXPLAIN SELECT name FROM usersfail ORDER BY id """) } } @@ -386,7 +386,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) """) } } @@ -406,7 +406,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: INSERT INTO usersfail (id, name, email) VALUES (?, ?, ?) """) } @@ -419,6 +419,28 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTAssertEqual(result, 0) } + func testFailedWritesStillEmitUpdateNotifications() async throws { + var query = try database.watch("SELECT name FROM users") { try $0.getString(index: 0) }.makeAsyncIterator() + do { + let rows = try await query.next() + XCTAssertEqual(rows, []) + } + + do { + try await database.writeLock { ctx in + try ctx.execute(sql: "INSERT INTO users (id, name) VALUES (uuid(), ?)", parameters: ["test user"]) + throw PowerSyncError.operationFailed(message: "deliberate error from test") + } + } catch { + // Expected + } + + do { + let rows = try await query.next() + XCTAssertEqual(rows, ["test user"]) + } + } + func testReadTransaction() async throws { _ = try await database.execute( sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", @@ -449,7 +471,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { } } catch { XCTAssertEqual(error.localizedDescription, """ - SqliteException(1): SQL logic error, no such table: usersfail for SQL: SELECT COUNT(*) FROM usersfail + SQLite failure (code 1): SQL logic error, no such table: usersfail for SQL: SELECT COUNT(*) FROM usersfail """) } } @@ -525,10 +547,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.debug, writers: [testWriter]) - let db2 = openKotlinDBDefault( + let db2 = PowerSyncDatabase( schema: schema, dbFilename: ":memory:", - logger: DatabaseLogger(logger) + logger: logger ) try await db2.execute("SELECT 1") @@ -536,7 +558,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let warningIndex = testWriter.getLogs().firstIndex( where: { value in - value.contains("debug: PowerSyncVersion") + value.contains("debug: Opened connection. SQLite version") } ) @@ -547,10 +569,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.error, writers: [testWriter]) - let db2 = openKotlinDBDefault( + let db2 = PowerSyncDatabase( schema: schema, dbFilename: ":memory:", - logger: DatabaseLogger(logger) + logger: logger ) try await db2.close() @@ -651,13 +673,12 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testDbFilename = "test_delete_\(UUID().uuidString).db" // Get the database directory using the helper function - let databaseDirectory = try appleDefaultDatabaseDirectory() + let databaseDirectory = try DatabaseLocation.appleDefaultDatabaseDirectory() // Create a database with a real file let testDatabase = PowerSyncDatabase( schema: schema, dbFilename: testDbFilename, - logger: DatabaseLogger(DefaultLogger()) ) // Perform some operations to ensure the database file is created @@ -690,13 +711,12 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testDbFilename = "test_no_delete_\(UUID().uuidString).db" // Get the database directory using the helper function - let databaseDirectory = try appleDefaultDatabaseDirectory() + let databaseDirectory = try DatabaseLocation.appleDefaultDatabaseDirectory() // Create a database with a real file let testDatabase = PowerSyncDatabase( schema: schema, dbFilename: testDbFilename, - logger: DatabaseLogger(DefaultLogger()) ) // Perform some operations to ensure the database file is created diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index 501b522..b88dd50 100644 --- a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -65,10 +65,13 @@ final class SqlCursorTests: XCTestCase { ]) ]) - database = openKotlinDBDefault( + database = PowerSyncDatabaseImpl( + identifier: ":memory:", + activeInstanceStore: DatabaseGroupCollection(), + logger: DefaultLogger(), + pool: AsyncConnectionPool(location: .inMemory, logger: DefaultLogger()), + httpClient: PlatformHttpClient.shared, schema: schema, - dbFilename: ":memory:", - logger: DatabaseLogger(DefaultLogger()) ) try await database.disconnectAndClear() } diff --git a/Tests/PowerSyncTests/SyncTests.swift b/Tests/PowerSyncTests/SyncTests.swift index 742e144..ab62cb1 100644 --- a/Tests/PowerSyncTests/SyncTests.swift +++ b/Tests/PowerSyncTests/SyncTests.swift @@ -703,11 +703,13 @@ let defaultSchema = Schema(tables: [ ]) private func openDatabase(_ client: any HttpClient, schema: Schema = defaultSchema, logger: any LoggerProtocol = DefaultLogger()) -> PowerSyncDatabaseProtocol { - return openKotlinDBDefault( + return PowerSyncDatabaseImpl( + identifier: ":memory:", + activeInstanceStore: DatabaseGroupCollection(), + logger: logger, + pool: AsyncConnectionPool(location: .inMemory, logger: DefaultLogger()), + httpClient: client, schema: schema, - dbFilename: ":memory:", - logger: DatabaseLogger(logger), - httpClient: client ) } diff --git a/Tests/PowerSyncTests/Utils/AsyncMutexTests.swift b/Tests/PowerSyncTests/Utils/AsyncMutexTests.swift new file mode 100644 index 0000000..649dec9 --- /dev/null +++ b/Tests/PowerSyncTests/Utils/AsyncMutexTests.swift @@ -0,0 +1,123 @@ +@testable import PowerSync +import Testing + +@Suite +struct AsyncSemaphoreTests { + @Test func dispatchesItemsInOrder() async throws { + let semaphore = AsyncSemaphore(from: ["a", "b", "c"]) + + let grant1 = try await semaphore.acquire(count: 1) + let grant2 = try await semaphore.acquire(count: 1) + let grant3 = try await semaphore.acquire(count: 1) + + try #require(grant1.acquiredItems[0] == "a") + try #require(grant2.acquiredItems[0] == "b") + try #require(grant3.acquiredItems[0] == "c") + } + + @Test @MainActor func returnsReleasedItemsToWaiters() async throws { + let semaphore = AsyncSemaphore(from: ["x"]) + + let grant1 = try await semaphore.acquire(count: 1) + var hasSecond = false + + let grant2 = Task { + let grant = try await semaphore.acquire(count: 1) + hasSecond = true + return grant.acquiredItems[0] + } + + try #require(!hasSecond) + let _ = consume grant1 + try #require(try await grant2.value == "x") + try #require(hasSecond) + } + + @Test @MainActor func canAcquireMultiple() async throws { + let semaphore = AsyncSemaphore(from: ["a", "b", "c"]) + let grant1 = try await semaphore.acquire(count: 1) + let grant2 = try await semaphore.acquire(count: 1) + + var hasAll = false + let acquireAllTask = Task { + let _ = try await semaphore.acquire(count: 3) + hasAll = false + } + + await Task.yield() + try #require(!hasAll) + + let _ = consume grant1 + await Task.yield() + try #require(!hasAll) // Still waiting for item2 + + let _ = consume grant2 + let _ = await acquireAllTask.result + } + + @Test func canReturnMultiple() async throws { + let semaphore = AsyncSemaphore(from: ["a", "b"]) + + let grantAll = try await semaphore.acquire(count: 2) + + let hasOther = Task { + let grant = try await semaphore.acquire(count: 1) + try #require(grant.acquiredItems[0] == "b") // We return the last item first + + let anotherGrant = try await semaphore.acquire(count: 1) + try #require(anotherGrant.acquiredItems[0] == "a") + return true + } + + let _ = consume grantAll + let _ = try await hasOther.value + } + + @Test func canAbort() async throws { + let semaphore = AsyncSemaphore(from: ["a"]) + + let grant1 = try await semaphore.acquire(count: 1) + let second = Task { + await #expect(throws: CancellationError.self) { + let _ = try await semaphore.acquire(count: 1) + print("has items") + } + } + let third = Task { + let grant = try await semaphore.acquire(count: 1) + try #require(grant.acquiredItems[0] == "a") + return + } + + await Task.yield() + second.cancel() + let _ = await second.result + + let _ = consume grant1 + try (await third.result).get() + } + + @Test func canAbortPartial() async throws { + let semaphore = AsyncSemaphore(from: ["a", "b"]) + let grant1 = try await semaphore.acquire(count: 1) + let second = Task { + await #expect(throws: CancellationError.self) { + let _ = try await semaphore.acquire(count: 2) + } + } + let third = Task { + let grant = try await semaphore.acquire(count: 2) + try #require(grant.acquiredItems[0] == "b") + try #require(grant.acquiredItems[1] == "a") + return + } + + await Task.yield() + // At this point, second obtained value b from the semaphore, but it's still waiting + // for the second node. Aborting it should b into the semaphore. + second.cancel() + let _ = await second.result + let _ = consume grant1 + try (await third.result).get() + } +} diff --git a/Tests/PowerSyncTests/Utils/MergeItemSequence.swift b/Tests/PowerSyncTests/Utils/MergeItemSequence.swift new file mode 100644 index 0000000..3c7f005 --- /dev/null +++ b/Tests/PowerSyncTests/Utils/MergeItemSequence.swift @@ -0,0 +1,67 @@ +import AsyncAlgorithms +@testable import PowerSync +import Testing + +@Suite +struct MergeItemSequenceTest { + private let source = AsyncThrowingChannel<(), any Error>() + + private func generateMerged() -> MergeItemSequence> { + MergeItemSequence(inner: source) + } + + @Test func canReceiveItem() async throws { + let items = generateMerged().makeAsyncIterator() + async let didReceive = items.next() + await source.send(()) + try #require(await didReceive) + } + + @Test @MainActor func mergesItems() async throws { + let items = generateMerged().makeAsyncIterator() + await source.send(()) + await source.send(()) + await source.send(()) + + async let firstItem = items.next() + try #require(await firstItem) + + var hasSecondItem = false + let secondTask = Task { + try #require(await items.next()) + hasSecondItem = true + } + + try #require(!hasSecondItem) + await Task.yield() + try #require(!hasSecondItem) + + await source.send(()) + try await secondTask.value + } + + @Test func reportsErrors() async throws { + let items = generateMerged().makeAsyncIterator() + await source.fail(PowerSyncError.operationFailed(message: "error for test")) + await #expect(throws: PowerSyncError.self) { try await items.next() } + } + + @Test func forwardsClose() async throws { + let items = generateMerged().makeAsyncIterator() + await source.send(()) + try #require(try await items.next()) + source.finish() + try #require(try await items.next() == nil) + try await items.pollTask.value + } + + @Test func closesOnDrop() async throws { + let task: Task + do { + let items = generateMerged().makeAsyncIterator() + task = items.pollTask + } + + try await task.value + } +}