From 245e3d272ec38508447ef318a28b6228098c0d61 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 7 Sep 2025 09:27:13 -0600 Subject: [PATCH 01/15] wip: grdb connection pool --- Package.resolved | 9 ++ Package.swift | 21 ++++- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 44 +++++++-- .../Kotlin/KotlinSQLiteConnectionPool.swift | 62 +++++++++++++ Sources/PowerSync/PowerSyncDatabase.swift | 17 +++- .../Protocol/SQLiteConnectionPool.swift | 26 ++++++ Sources/PowerSyncGRDB/GRDBPool.swift | 89 +++++++++++++++++++ Tests/PowerSyncGRDBTests/BasicTest.swift | 57 ++++++++++++ Tests/PowerSyncTests/ConnectTests.swift | 2 +- Tests/PowerSyncTests/CrudTests.swift | 62 ++++++------- .../KotlinPowerSyncDatabaseImplTests.swift | 4 +- .../Kotlin/SqlCursorTests.swift | 56 ++++++------ 12 files changed, 374 insertions(+), 75 deletions(-) create mode 100644 Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift create mode 100644 Sources/PowerSync/Protocol/SQLiteConnectionPool.swift create mode 100644 Sources/PowerSyncGRDB/GRDBPool.swift create mode 100644 Tests/PowerSyncGRDBTests/BasicTest.swift diff --git a/Package.resolved b/Package.resolved index 8926049..03847e6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index aac1063..276ad3c 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let packageName = "PowerSync" // Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin // build. Also see docs/LocalBuild.md for details -let localKotlinSdkOverride: String? = nil +let localKotlinSdkOverride: String? = "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin" // Set this to the absolute path of your powersync-sqlite-core checkout if you want to use a // local build of the core extension. @@ -71,9 +71,15 @@ let package = Package( // Dynamic linking is particularly important for XCode previews. type: .dynamic, targets: ["PowerSync"] + ), + .library( + name: "PowerSyncGRDB", + targets: ["PowerSyncGRDB"] ) ], - dependencies: conditionalDependencies, + dependencies: conditionalDependencies + [ + .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.0.0") + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. @@ -84,9 +90,20 @@ let package = Package( .product(name: "PowerSyncSQLiteCore", package: corePackageName) ] ), + .target( + name: "PowerSyncGRDB", + dependencies: [ + .target(name: "PowerSync"), + .product(name: "GRDB", package: "GRDB.swift") + ] + ), .testTarget( name: "PowerSyncTests", dependencies: ["PowerSync"] + ), + .testTarget( + name: "PowerSyncGRDBTests", + dependencies: ["PowerSync", "PowerSyncGRDB"] ) ] + conditionalTargets ) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 83cdf6c..0943d42 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -11,18 +11,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, let currentStatus: SyncStatus init( - schema: Schema, - dbFilename: String, + kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase, logger: DatabaseLogger ) { - let factory = PowerSyncKotlin.DatabaseDriverFactory() - kotlinDatabase = PowerSyncDatabase( - factory: factory, - schema: KotlinAdapter.Schema.toKotlin(schema), - dbFilename: dbFilename, - logger: logger.kLogger - ) self.logger = logger + self.kotlinDatabase = kotlinDatabase currentStatus = KotlinSyncStatus( baseStatus: kotlinDatabase.currentStatus ) @@ -401,6 +394,39 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, } } +func openKotlinDBWithFactory( + schema: Schema, + dbFilename: String, + logger: DatabaseLogger +) -> PowerSyncDatabaseProtocol { + return KotlinPowerSyncDatabaseImpl( + kotlinDatabase: PowerSyncDatabase( + factory: PowerSyncKotlin.DatabaseDriverFactory(), + schema: KotlinAdapter.Schema.toKotlin(schema), + dbFilename: dbFilename, + logger: logger.kLogger + ), + logger: logger + ) +} + +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 + ), + logger: logger + ) +} + private struct ExplainQueryResult { let addr: String let opcode: String diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift new file mode 100644 index 0000000..c05ef0f --- /dev/null +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -0,0 +1,62 @@ +import PowerSyncKotlin + +final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { + let pool: SQLiteConnectionPoolProtocol + + init( + pool: SQLiteConnectionPoolProtocol + ) { + self.pool = pool + } + + func __closePool() async throws { + do { + try pool.close() + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } + + func __leaseRead(callback: @escaping (Any) -> Void) async throws { + do { + try await pool.read { pointer in + callback(pointer) + } + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } + + func __leaseWrite(callback: @escaping (Any) -> Void) async throws { + do { + try await pool.write { pointer in + callback(pointer) + } + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } +} + +extension SQLiteConnectionPoolProtocol { + func toKotlin() -> PowerSyncKotlin.SwiftSQLiteConnectionPool { + return PowerSyncKotlin.SwiftSQLiteConnectionPool( + adapter: SwiftSQLiteConnectionPoolAdapter(pool: self) + ) + } +} diff --git a/Sources/PowerSync/PowerSyncDatabase.swift b/Sources/PowerSync/PowerSyncDatabase.swift index dbabf92..0cc9995 100644 --- a/Sources/PowerSync/PowerSyncDatabase.swift +++ b/Sources/PowerSync/PowerSyncDatabase.swift @@ -14,10 +14,23 @@ public func PowerSyncDatabase( dbFilename: String = DEFAULT_DB_FILENAME, logger: (any LoggerProtocol) = DefaultLogger() ) -> PowerSyncDatabaseProtocol { - - return KotlinPowerSyncDatabaseImpl( + return openKotlinDBWithFactory( schema: schema, dbFilename: dbFilename, logger: DatabaseLogger(logger) ) } + +public func OpenedPowerSyncDatabase( + schema: Schema, + pool: any SQLiteConnectionPoolProtocol, + identifier: String, + logger: (any LoggerProtocol) = DefaultLogger() +) -> PowerSyncDatabaseProtocol { + return openKotlinDBWithPool( + schema: schema, + pool: pool, + identifier: identifier, + logger: DatabaseLogger(logger) + ) +} diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift new file mode 100644 index 0000000..88aca39 --- /dev/null +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -0,0 +1,26 @@ +import Foundation + +/// 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. +public protocol SQLiteConnectionPoolProtocol { + /// Calls the callback with a read-only connection temporarily leased from the pool. + func read( + onConnection: @Sendable @escaping (OpaquePointer) -> Void, + ) async throws + + /// Calls the callback with a read-write connection temporarily leased from the pool. + func write( + onConnection: @Sendable @escaping (OpaquePointer) -> Void, + ) async throws + + /// Invokes the callback with all connections leased from the pool. + func withAllConnections( + onConnection: @escaping ( + _ writer: OpaquePointer, + _ readers: [OpaquePointer] + ) -> Void, + ) async throws + + /// Closes the connection pool and associated resources. + func close() throws +} diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift new file mode 100644 index 0000000..96fb8b4 --- /dev/null +++ b/Sources/PowerSyncGRDB/GRDBPool.swift @@ -0,0 +1,89 @@ +import Foundation +import GRDB +import PowerSync +import SQLite3 + +// The system SQLite does not expose this, +// linking PowerSync provides them +// Declare the missing function manually +@_silgen_name("sqlite3_enable_load_extension") +func sqlite3_enable_load_extension(_ db: OpaquePointer?, _ onoff: Int32) -> 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 + +enum PowerSyncGRDBConfigError: Error { + case bundleNotFound + case extensionLoadFailed(String) + case unknownExtensionLoadError +} + +func configurePowerSync(_ config: inout Configuration) { + config.prepareDatabase { database in + guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { + throw PowerSyncGRDBConfigError.bundleNotFound + } + + // Construct the full path to the shared library inside the bundle + let fullPath = bundle.bundlePath + "/powersync-sqlite-core" + + let rc = sqlite3_enable_load_extension(database.sqliteConnection, 1) + if rc != SQLITE_OK { + throw PowerSyncGRDBConfigError.extensionLoadFailed("Could not enable extension loading") + } + var errorMsg: UnsafeMutablePointer? + let loadResult = sqlite3_load_extension(database.sqliteConnection, fullPath, "sqlite3_powersync_init", &errorMsg) + if loadResult != SQLITE_OK { + if let errorMsg = errorMsg { + let message = String(cString: errorMsg) + sqlite3_free(errorMsg) + throw PowerSyncGRDBConfigError.extensionLoadFailed(message) + } else { + throw PowerSyncGRDBConfigError.unknownExtensionLoadError + } + } + } +} + +class GRDBConnectionPool: SQLiteConnectionPoolProtocol { + let pool: DatabasePool + + init( + pool: DatabasePool + ) { + self.pool = pool + } + + func read( + onConnection: @Sendable @escaping (OpaquePointer) -> Void + ) async throws { + try await pool.read { database in + guard let connection = database.sqliteConnection else { + return + } + onConnection(connection) + } + } + + func write( + onConnection: @Sendable @escaping (OpaquePointer) -> Void + ) async throws { + try await pool.write { database in + guard let connection = database.sqliteConnection else { + return + } + onConnection(connection) + } + } + + func withAllConnections( + onConnection _: @escaping (OpaquePointer, [OpaquePointer]) -> Void + ) async throws { + // TODO: + } + + func close() throws { + try pool.close() + } +} diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift new file mode 100644 index 0000000..6cc82c8 --- /dev/null +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -0,0 +1,57 @@ +@testable import GRDB +@testable import PowerSync +@testable import PowerSyncGRDB + +import XCTest + +final class GRDBTests: XCTestCase { + private var database: PowerSyncDatabaseProtocol! + private var schema: Schema! + + override func setUp() async throws { + try await super.setUp() + schema = Schema(tables: [ + Table(name: "users", columns: [ + .text("count"), + .integer("is_active"), + .real("weight"), + .text("description") + ]) + ]) + + var config = Configuration() + configurePowerSync(&config) + + let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let dbURL = documentsDir.appendingPathComponent("test.sqlite") + let pool = try DatabasePool( + path: dbURL.path, + configuration: config + ) + + database = OpenedPowerSyncDatabase( + schema: schema, + pool: GRDBConnectionPool( + pool: pool + ), + identifier: "test" + ) + + try await database.disconnectAndClear() + } + + override func tearDown() async throws { + try await database.disconnectAndClear() + database = nil + try await super.tearDown() + } + + func testValidValues() async throws { + let result = try await database.get( + "SELECT powersync_rs_version as r" + ) { cursor in + try cursor.getString(index: 0) + } + print(result) + } +} diff --git a/Tests/PowerSyncTests/ConnectTests.swift b/Tests/PowerSyncTests/ConnectTests.swift index 9473d21..d4cbb92 100644 --- a/Tests/PowerSyncTests/ConnectTests.swift +++ b/Tests/PowerSyncTests/ConnectTests.swift @@ -18,7 +18,7 @@ final class ConnectTests: XCTestCase { ), ]) - database = KotlinPowerSyncDatabaseImpl( + database = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(DefaultLogger()) diff --git a/Tests/PowerSyncTests/CrudTests.swift b/Tests/PowerSyncTests/CrudTests.swift index 5b303d6..dda805a 100644 --- a/Tests/PowerSyncTests/CrudTests.swift +++ b/Tests/PowerSyncTests/CrudTests.swift @@ -19,7 +19,7 @@ final class CrudTests: XCTestCase { ), ]) - database = KotlinPowerSyncDatabaseImpl( + database = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(DefaultLogger()) @@ -33,7 +33,7 @@ final class CrudTests: XCTestCase { database = nil try await super.tearDown() } - + func testTrackMetadata() async throws { try await database.updateSchema(schema: Schema(tables: [ Table(name: "lists", columns: [.text("name")], trackMetadata: true) @@ -43,10 +43,10 @@ final class CrudTests: XCTestCase { guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after insert") } - + XCTAssertEqual(batch.crud[0].metadata, "so meta") } - + func testTrackPreviousValues() async throws { try await database.updateSchema(schema: Schema(tables: [ Table( @@ -55,18 +55,18 @@ final class CrudTests: XCTestCase { trackPreviousValues: TrackPreviousValuesOptions() ) ])) - + try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'new name'") - + guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after update") } - + XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry", "content": "content"]) } - + func testTrackPreviousValuesWithFilter() async throws { try await database.updateSchema(schema: Schema(tables: [ Table( @@ -77,18 +77,18 @@ final class CrudTests: XCTestCase { ) ) ])) - + try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'new name'") - + guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after update") } - + XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"]) } - + func testTrackPreviousValuesOnlyWhenChanged() async throws { try await database.updateSchema(schema: Schema(tables: [ Table( @@ -99,18 +99,18 @@ final class CrudTests: XCTestCase { ) ) ])) - + try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'new name'") - + guard let batch = try await database.getNextCrudTransaction() else { return XCTFail("Should have batch after update") } - + XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"]) } - + func testIgnoreEmptyUpdate() async throws { try await database.updateSchema(schema: Schema(tables: [ Table(name: "lists", columns: [.text("name")], ignoreEmptyUpdates: true) @@ -118,7 +118,7 @@ final class CrudTests: XCTestCase { try await database.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'test')") try await database.execute("DELETE FROM ps_crud") try await database.execute("UPDATE lists SET name = 'test'") // Same value! - + let batch = try await database.getNextCrudTransaction() XCTAssertNil(batch) } @@ -138,11 +138,11 @@ final class CrudTests: XCTestCase { guard let limitedBatch = try await database.getCrudBatch(limit: 50) else { return XCTFail("Failed to get crud batch") } - + guard let crudItem = limitedBatch.crud.first else { return XCTFail("Crud batch should contain crud entries") } - + // This should show as a string even though it's a number // This is what the typing conveys let opData = crudItem.opData?["favorite_number"] @@ -150,35 +150,35 @@ final class CrudTests: XCTestCase { XCTAssert(limitedBatch.hasMore == true) XCTAssert(limitedBatch.crud.count == 50) - + guard let fullBatch = try await database.getCrudBatch() else { return XCTFail("Failed to get crud batch") } - + XCTAssert(fullBatch.hasMore == false) XCTAssert(fullBatch.crud.count == 100) - + guard let nextTx = try await database.getNextCrudTransaction() else { return XCTFail("Failed to get transaction crud batch") } - + XCTAssert(nextTx.crud.count == 100) - + for r in nextTx.crud { print(r) } - + // Completing the transaction should clear the items try await nextTx.complete() - + let afterCompleteBatch = try await database.getNextCrudTransaction() - + for r in afterCompleteBatch?.crud ?? [] { print(r) } - + XCTAssertNil(afterCompleteBatch) - + try await database.writeTransaction { tx in for i in 0 ..< 100 { try tx.execute( @@ -187,7 +187,7 @@ final class CrudTests: XCTestCase { ) } } - + guard let finalBatch = try await database.getCrudBatch(limit: 100) else { return XCTFail("Failed to get crud batch") } @@ -195,7 +195,7 @@ final class CrudTests: XCTestCase { XCTAssert(finalBatch.hasMore == false) // Calling complete without a writeCheckpoint param should be possible try await finalBatch.complete() - + let finalValidationBatch = try await database.getCrudBatch(limit: 100) XCTAssertNil(finalValidationBatch) } diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index ebecd44..235910a 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -513,7 +513,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.debug, writers: [testWriter]) - let db2 = KotlinPowerSyncDatabaseImpl( + let db2 = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(logger) @@ -534,7 +534,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { let testWriter = TestLogWriterAdapter() let logger = DefaultLogger(minSeverity: LogSeverity.error, writers: [testWriter]) - let db2 = KotlinPowerSyncDatabaseImpl( + let db2 = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(logger) diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index 6fa5cf5..9eb83a9 100644 --- a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -14,7 +14,7 @@ struct UserOptional { let isActive: Bool? let weight: Double? let description: String? - + init( id: String, count: Int? = nil, @@ -51,9 +51,9 @@ func createTestUser( } final class SqlCursorTests: XCTestCase { - private var database: KotlinPowerSyncDatabaseImpl! + private var database: PowerSyncDatabaseProtocol! private var schema: Schema! - + override func setUp() async throws { try await super.setUp() schema = Schema(tables: [ @@ -64,26 +64,26 @@ final class SqlCursorTests: XCTestCase { .text("description") ]) ]) - - database = KotlinPowerSyncDatabaseImpl( + + database = openKotlinDBWithFactory( schema: schema, dbFilename: ":memory:", logger: DatabaseLogger(DefaultLogger()) ) try await database.disconnectAndClear() } - + override func tearDown() async throws { try await database.disconnectAndClear() database = nil try await super.tearDown() } - + func testValidValues() async throws { try await createTestUser( db: database ) - + let user: User = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] @@ -95,19 +95,19 @@ final class SqlCursorTests: XCTestCase { weight: cursor.getDouble(name: "weight") ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + /// Uses the indexed based cursor methods to obtain a required column value func testValidValuesWithIndex() async throws { try await createTestUser( db: database ) - + let user = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] @@ -119,37 +119,37 @@ final class SqlCursorTests: XCTestCase { weight: cursor.getDoubleOptional(index: 3) ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + /// Uses index based cursor methods which are optional and don't throw func testIndexNoThrow() async throws { try await createTestUser( db: database ) - + let user = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] ) { cursor in - UserOptional( + UserOptional( id: cursor.getStringOptional(index: 0) ?? "1", count: cursor.getIntOptional(index: 1), isActive: cursor.getBooleanOptional(index: 2), weight: cursor.getDoubleOptional(index: 3) ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + func testOptionalValues() async throws { try await createTestUser( db: database, @@ -161,7 +161,7 @@ final class SqlCursorTests: XCTestCase { description: nil ) ) - + let user: UserOptional = try await database.get( sql: "SELECT id, count, is_active, weight, description FROM users WHERE id = ?", parameters: ["1"] @@ -174,20 +174,20 @@ final class SqlCursorTests: XCTestCase { description: cursor.getStringOptional(name: "description") ) } - + XCTAssertEqual(user.id, "1") XCTAssertNil(user.count) XCTAssertNil(user.isActive) XCTAssertNil(user.weight) XCTAssertNil(user.description) } - + /// Tests that a `mapper` which does not throw is accepted by the protocol func testNoThrow() async throws { try await createTestUser( db: database ) - + let user = try await database.get( sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", parameters: ["1"] @@ -200,18 +200,18 @@ final class SqlCursorTests: XCTestCase { description: nil ) } - + XCTAssertEqual(user.id, "1") XCTAssertEqual(user.count, 110) XCTAssertEqual(user.isActive, false) XCTAssertEqual(user.weight, 1.1111) } - + func testThrowsForMissingColumn() async throws { try await createTestUser( db: database ) - + do { _ = try await database.get( sql: "SELECT id FROM users", @@ -227,7 +227,7 @@ final class SqlCursorTests: XCTestCase { XCTFail("Unexpected error type: \(error)") } } - + func testThrowsForNullValuedRequiredColumn() async throws { /// Create a test user with nil stored in columns try await createTestUser( @@ -240,7 +240,7 @@ final class SqlCursorTests: XCTestCase { description: nil ) ) - + do { _ = try await database.get( sql: "SELECT description FROM users", @@ -257,7 +257,7 @@ final class SqlCursorTests: XCTestCase { XCTFail("Unexpected error type: \(error)") } } - + /// Index based cursor methods should throw if null is returned for required values func testThrowsForNullValuedRequiredColumnIndex() async throws { /// Create a test user with nil stored in columns @@ -271,7 +271,7 @@ final class SqlCursorTests: XCTestCase { description: nil ) ) - + do { _ = try await database.get( sql: "SELECT description FROM users", From 84281355b8a00d2921a0be46fee06ffba06747e1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 7 Sep 2025 17:51:12 -0600 Subject: [PATCH 02/15] wip: grdb --- .../Kotlin/KotlinSQLiteConnectionPool.swift | 20 ++++++++- .../Protocol/SQLiteConnectionPool.swift | 2 +- Sources/PowerSyncGRDB/GRDBPool.swift | 3 +- Tests/PowerSyncGRDBTests/BasicTest.swift | 41 +++++++++++++++---- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift index c05ef0f..531ce71 100644 --- a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -25,7 +25,7 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { func __leaseRead(callback: @escaping (Any) -> Void) async throws { do { try await pool.read { pointer in - callback(pointer) + callback(UInt(bitPattern: pointer)) } } catch { try? PowerSyncKotlin.throwPowerSyncException( @@ -40,7 +40,23 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { func __leaseWrite(callback: @escaping (Any) -> Void) async throws { do { try await pool.write { pointer in - callback(pointer) + callback(UInt(bitPattern: pointer)) + } + } catch { + try? PowerSyncKotlin.throwPowerSyncException( + exception: PowerSyncException( + message: error.localizedDescription, + cause: nil + ) + ) + } + } + + func __leaseAll(callback: @escaping (Any, [Any]) -> Void) async throws { + // TODO, actually use all connections + do { + try await pool.write { pointer in + callback(UInt(bitPattern: pointer), []) } } catch { try? PowerSyncKotlin.throwPowerSyncException( diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift index 88aca39..e0dd719 100644 --- a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -15,7 +15,7 @@ public protocol SQLiteConnectionPoolProtocol { /// Invokes the callback with all connections leased from the pool. func withAllConnections( - onConnection: @escaping ( + onConnection: @Sendable @escaping ( _ writer: OpaquePointer, _ readers: [OpaquePointer] ) -> Void, diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift index 96fb8b4..fc55865 100644 --- a/Sources/PowerSyncGRDB/GRDBPool.swift +++ b/Sources/PowerSyncGRDB/GRDBPool.swift @@ -69,7 +69,8 @@ class GRDBConnectionPool: SQLiteConnectionPoolProtocol { func write( onConnection: @Sendable @escaping (OpaquePointer) -> Void ) async throws { - try await pool.write { database in + // Don't start an explicit transaction + try await pool.writeWithoutTransaction { database in guard let connection = database.sqliteConnection else { return } diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index 6cc82c8..1918f36 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -7,24 +7,22 @@ import XCTest final class GRDBTests: XCTestCase { private var database: PowerSyncDatabaseProtocol! private var schema: Schema! + private var pool: DatabasePool! override func setUp() async throws { try await super.setUp() schema = Schema(tables: [ Table(name: "users", columns: [ - .text("count"), - .integer("is_active"), - .real("weight"), - .text("description") + .text("name"), + .text("count") ]) ]) var config = Configuration() configurePowerSync(&config) - let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let dbURL = documentsDir.appendingPathComponent("test.sqlite") - let pool = try DatabasePool( + pool = try DatabasePool( path: dbURL.path, configuration: config ) @@ -48,10 +46,39 @@ final class GRDBTests: XCTestCase { func testValidValues() async throws { let result = try await database.get( - "SELECT powersync_rs_version as r" + "SELECT powersync_rs_version() as r" ) { cursor in try cursor.getString(index: 0) } print(result) + + try await database.execute( + "INSERT INTO users(id, name, count) VALUES(uuid(), 'steven', 1)" + ) + + let initialUsers = try await database.getAll( + "SELECT * FROM users" + ) { cursor in + try cursor.getString(name: "name") + } + print("initial users \(initialUsers)") + + // Now use a GRDB query + struct Users: Codable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var count: Int + + enum Columns { + static let name = Column(CodingKeys.name) + static let count = Column(CodingKeys.count) + } + } + + let grdbUsers = try await pool.write { db in + try Users.fetchAll(db) + } + + print(grdbUsers) } } From dbd9c091f86adfa428fedd347a20e48dd5e88f63 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 8 Sep 2025 10:43:58 -0600 Subject: [PATCH 03/15] Use latest GRDB package. Update tests and queries. --- Package.resolved | 4 +- Package.swift | 2 +- Sources/PowerSyncGRDB/GRDBPool.swift | 62 +++++++++++++++++++----- Tests/PowerSyncGRDBTests/BasicTest.swift | 53 +++++++++++++------- 4 files changed, 87 insertions(+), 34 deletions(-) diff --git a/Package.resolved b/Package.resolved index 03847e6..89dbb6f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift.git", "state" : { - "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", - "version" : "6.29.3" + "branch" : "dev/persistable-views", + "revision" : "63d92eab609bf230feb6f8d2c695a7e510519530" } }, { diff --git a/Package.swift b/Package.swift index 276ad3c..8e04a35 100644 --- a/Package.swift +++ b/Package.swift @@ -78,7 +78,7 @@ let package = Package( ) ], dependencies: conditionalDependencies + [ - .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.0.0") + .package(url: "https://github.com/groue/GRDB.swift.git", branch: "dev/persistable-views") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift index fc55865..ae257f8 100644 --- a/Sources/PowerSyncGRDB/GRDBPool.swift +++ b/Sources/PowerSyncGRDB/GRDBPool.swift @@ -7,30 +7,56 @@ import SQLite3 // linking PowerSync provides them // Declare the missing function manually @_silgen_name("sqlite3_enable_load_extension") -func sqlite3_enable_load_extension(_ db: OpaquePointer?, _ onoff: Int32) -> Int32 +func sqlite3_enable_load_extension( + _ db: OpaquePointer?, + _ onoff: Int32 +) -> 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 +func sqlite3_load_extension( + _ db: OpaquePointer?, + _ fileName: UnsafePointer?, + _ procName: UnsafePointer?, + _ errMsg: UnsafeMutablePointer?>? +) -> Int32 -enum PowerSyncGRDBConfigError: Error { - case bundleNotFound +enum PowerSyncGRDBError: Error { + case coreBundleNotFound case extensionLoadFailed(String) case unknownExtensionLoadError + case connectionUnavailable } -func configurePowerSync(_ config: inout Configuration) { +struct PowerSyncSchemaSource: DatabaseSchemaSource { + let schema: Schema + + func columnsForPrimaryKey(_: Database, inView view: DatabaseObjectID) throws -> [String]? { + if schema.tables.first(where: { table in + table.viewName == view.name + }) != nil { + return ["id"] + } + return nil + } +} + +func configurePowerSync( + config: inout Configuration, + schema: Schema +) { + // Register the PowerSync core extension config.prepareDatabase { database in guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { - throw PowerSyncGRDBConfigError.bundleNotFound + throw PowerSyncGRDBError.coreBundleNotFound } // Construct the full path to the shared library inside the bundle let fullPath = bundle.bundlePath + "/powersync-sqlite-core" - let rc = sqlite3_enable_load_extension(database.sqliteConnection, 1) - if rc != SQLITE_OK { - throw PowerSyncGRDBConfigError.extensionLoadFailed("Could not enable extension loading") + 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, fullPath, "sqlite3_powersync_init", &errorMsg) @@ -38,12 +64,22 @@ func configurePowerSync(_ config: inout Configuration) { if let errorMsg = errorMsg { let message = String(cString: errorMsg) sqlite3_free(errorMsg) - throw PowerSyncGRDBConfigError.extensionLoadFailed(message) + throw PowerSyncGRDBError.extensionLoadFailed(message) } else { - throw PowerSyncGRDBConfigError.unknownExtensionLoadError + throw PowerSyncGRDBError.unknownExtensionLoadError } } } + + // Supply the PowerSync views as a SchemaSource + let powerSyncSchemaSource = PowerSyncSchemaSource( + schema: schema + ) + if let schemaSource = config.schemaSource { + config.schemaSource = schemaSource.then(powerSyncSchemaSource) + } else { + config.schemaSource = powerSyncSchemaSource + } } class GRDBConnectionPool: SQLiteConnectionPoolProtocol { @@ -60,7 +96,7 @@ class GRDBConnectionPool: SQLiteConnectionPoolProtocol { ) async throws { try await pool.read { database in guard let connection = database.sqliteConnection else { - return + throw PowerSyncGRDBError.connectionUnavailable } onConnection(connection) } @@ -72,7 +108,7 @@ class GRDBConnectionPool: SQLiteConnectionPoolProtocol { // Don't start an explicit transaction try await pool.writeWithoutTransaction { database in guard let connection = database.sqliteConnection else { - return + throw PowerSyncGRDBError.connectionUnavailable } onConnection(connection) } diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index 1918f36..ae0c7c2 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -14,12 +14,15 @@ final class GRDBTests: XCTestCase { schema = Schema(tables: [ Table(name: "users", columns: [ .text("name"), - .text("count") ]) ]) var config = Configuration() - configurePowerSync(&config) + configurePowerSync( + config: &config, + schema: schema + ) + let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let dbURL = documentsDir.appendingPathComponent("test.sqlite") pool = try DatabasePool( @@ -45,40 +48,54 @@ final class GRDBTests: XCTestCase { } func testValidValues() async throws { - let result = try await database.get( - "SELECT powersync_rs_version() as r" - ) { cursor in - try cursor.getString(index: 0) - } - print(result) + // Create users with the PowerSync SDK + let initialUserName = "Bob" try await database.execute( - "INSERT INTO users(id, name, count) VALUES(uuid(), 'steven', 1)" + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: [initialUserName] ) - let initialUsers = try await database.getAll( + // Fetch those users + let initialUserNames = try await database.getAll( "SELECT * FROM users" ) { cursor in try cursor.getString(name: "name") } - print("initial users \(initialUsers)") - // Now use a GRDB query - struct Users: Codable, Identifiable, FetchableRecord, PersistableRecord { + XCTAssertTrue(initialUserNames.first == initialUserName) + + // Now define a GRDB struct for query purposes + struct User: Codable, Identifiable, FetchableRecord, PersistableRecord { var id: String var name: String - var count: Int + + static var databaseTableName = "users" enum Columns { static let name = Column(CodingKeys.name) - static let count = Column(CodingKeys.count) } } - let grdbUsers = try await pool.write { db in - try Users.fetchAll(db) + // Query the Users with GRDB, this should have the same result as with PowerSync + let grdbUserNames = try await pool.read { database in + try User.fetchAll(database) } - print(grdbUsers) + XCTAssertTrue(grdbUserNames.first?.name == initialUserName) + + // Insert a user with GRDB + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "another", + ).insert(database) + } + + let grdbUserNames2 = try await pool.read { database in + try User.order(User.Columns.name.asc).fetchAll(database) + } + XCTAssert(grdbUserNames2.count == 2) + XCTAssert(grdbUserNames2[1].name == "another") } } From 6f329346546baa8764a37d86e8c601493d947773 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 8 Sep 2025 12:53:24 -0600 Subject: [PATCH 04/15] wip: table update hooks --- .../Kotlin/KotlinSQLiteConnectionPool.swift | 4 + .../Protocol/SQLiteConnectionPool.swift | 2 + Sources/PowerSyncGRDB/GRDBPool.swift | 47 ++++- Tests/PowerSyncGRDBTests/BasicTest.swift | 181 ++++++++++++++++-- 4 files changed, 221 insertions(+), 13 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift index 531ce71..5dd503e 100644 --- a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -9,6 +9,10 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { self.pool = pool } + func getPendingUpdates() -> Set { + return pool.getPendingUpdates() + } + func __closePool() async throws { do { try pool.close() diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift index e0dd719..fb2be95 100644 --- a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -3,6 +3,8 @@ import Foundation /// 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. public protocol SQLiteConnectionPoolProtocol { + func getPendingUpdates() -> Set + /// Calls the callback with a read-only connection temporarily leased from the pool. func read( onConnection: @Sendable @escaping (OpaquePointer) -> Void, diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift index ae257f8..b1ece1a 100644 --- a/Sources/PowerSyncGRDB/GRDBPool.swift +++ b/Sources/PowerSyncGRDB/GRDBPool.swift @@ -82,13 +82,58 @@ func configurePowerSync( } } -class GRDBConnectionPool: SQLiteConnectionPoolProtocol { +final class PowerSyncTransactionObserver: TransactionObserver { + let onChange: (_ tableName: String) -> Void + + init( + onChange: @escaping (_ tableName: String) -> Void + ) { + self.onChange = onChange + } + + func observes(eventsOfKind _: DatabaseEventKind) -> Bool { + // We want all the events for the PowerSync SDK + return true + } + + func databaseDidChange(with event: DatabaseEvent) { + onChange(event.tableName) + } + + func databaseDidCommit(_: GRDB.Database) {} + + func databaseDidRollback(_: GRDB.Database) {} +} + +final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { let pool: DatabasePool + var pendingUpdates: Set + private let pendingUpdatesQueue = DispatchQueue( + label: "co.powersync.pendingUpdatesQueue" + ) init( pool: DatabasePool ) { self.pool = pool + self.pendingUpdates = Set() + pool.add( + transactionObserver: PowerSyncTransactionObserver { tableName in + // push the update + self.pendingUpdatesQueue.sync { + self.pendingUpdates.insert(tableName) + } + }, + extent: .databaseLifetime + ) + } + + func getPendingUpdates() -> Set { + self.pendingUpdatesQueue.sync { + let copy = self.pendingUpdates + self.pendingUpdates.removeAll() + return copy + } } func read( diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index ae0c7c2..3873cb8 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -4,6 +4,17 @@ import XCTest +struct User: Codable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + + static var databaseTableName = "users" + + enum Columns { + static let name = Column(CodingKeys.name) + } +} + final class GRDBTests: XCTestCase { private var database: PowerSyncDatabaseProtocol! private var schema: Schema! @@ -47,7 +58,7 @@ final class GRDBTests: XCTestCase { try await super.tearDown() } - func testValidValues() async throws { + func testBasicOperations() async throws { // Create users with the PowerSync SDK let initialUserName = "Bob" @@ -66,17 +77,6 @@ final class GRDBTests: XCTestCase { XCTAssertTrue(initialUserNames.first == initialUserName) // Now define a GRDB struct for query purposes - struct User: Codable, Identifiable, FetchableRecord, PersistableRecord { - var id: String - var name: String - - static var databaseTableName = "users" - - enum Columns { - static let name = Column(CodingKeys.name) - } - } - // Query the Users with GRDB, this should have the same result as with PowerSync let grdbUserNames = try await pool.read { database in try User.fetchAll(database) @@ -98,4 +98,161 @@ final class GRDBTests: XCTestCase { XCTAssert(grdbUserNames2.count == 2) XCTAssert(grdbUserNames2[1].name == "another") } + + func testPowerSyncUpdates() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let stream = try database.watch( + options: WatchOptions( + sql: "SELECT name FROM users ORDER BY id", + mapper: { cursor in + try cursor.getString(index: 0) + } + )) + for try await names in stream { + await resultsStore.append(names) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["one"] + ) + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["two"] + ) + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } + + func testPowerSyncUpdatesFromGRDB() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let stream = try database.watch( + options: WatchOptions( + sql: "SELECT name FROM users ORDER BY id", + mapper: { cursor in + try cursor.getString(index: 0) + } + )) + for try await names in stream { + await resultsStore.append(names) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "one", + ).insert(database) + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "two", + ).insert(database) + } + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } + + func testGRDBUpdatesFromPowerSync() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let observation = ValueObservation.tracking { + try User.order(User.Columns.name.asc).fetchAll($0) + } + + for try await users in observation.values(in: pool) { + print("users \(users)") + await resultsStore.append(users.map { $0.name }) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["one"] + ) + + try await database.execute( + sql: "INSERT INTO users(id, name) VALUES(uuid(), ?)", + parameters: ["two"] + ) + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } } From e2681f6dd80c629165e2bba2b4b811039c232b2c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 16 Sep 2025 20:20:56 +0200 Subject: [PATCH 05/15] Add test for GRDB updates triggered by GRDB --- Tests/PowerSyncGRDBTests/BasicTest.swift | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index 3873cb8..02f21d6 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -255,4 +255,57 @@ final class GRDBTests: XCTestCase { await fulfillment(of: [expectation], timeout: 5) watchTask.cancel() } + + func testGRDBUpdatesFromGRDB() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: Set = [] + + func append(_ names: [String]) { + results.formUnion(names) + } + + func getResults() -> Set { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let watchTask = Task { + let observation = ValueObservation.tracking { + try User.order(User.Columns.name.asc).fetchAll($0) + } + + for try await users in observation.values(in: pool) { + await resultsStore.append(users.map { $0.name }) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "one", + ).insert(database) + } + + try await pool.write { database in + try User( + id: UUID().uuidString, + name: "two", + ).insert(database) + } + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + } } From a79ec5cebbb497bb4c21a30f1df278a65343a29f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 17 Sep 2025 17:05:04 +0200 Subject: [PATCH 06/15] add join test --- Tests/PowerSyncGRDBTests/BasicTest.swift | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index 02f21d6..af63ced 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -11,10 +11,35 @@ struct User: Codable, Identifiable, FetchableRecord, PersistableRecord { static var databaseTableName = "users" enum Columns { + static let id = Column(CodingKeys.id) static let name = Column(CodingKeys.name) } } +struct Pet: Codable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var ownerId: String + + static var databaseTableName = "pets" + + enum CodingKeys: String, CodingKey { + case id + case name + case ownerId = "owner_id" + } + + enum Columns { + static let ownerId = Column(CodingKeys.ownerId) + } + + static let user = belongsTo( + User.self, + key: "user", + using: ForeignKey([Columns.ownerId], to: [User.Columns.id]) + ) +} + final class GRDBTests: XCTestCase { private var database: PowerSyncDatabaseProtocol! private var schema: Schema! @@ -24,7 +49,11 @@ final class GRDBTests: XCTestCase { try await super.setUp() schema = Schema(tables: [ Table(name: "users", columns: [ + .text("name") + ]), + Table(name: "pets", columns: [ .text("name"), + .text("owner_id") ]) ]) @@ -99,6 +128,43 @@ final class GRDBTests: XCTestCase { XCTAssert(grdbUserNames2[1].name == "another") } + func testJoins() async throws { + // Create users with the PowerSync SDK + try await pool.write { database in + let userId = UUID().uuidString + try User( + id: userId, + name: "Bob" + ).insert(database) + + try Pet( + id: UUID().uuidString, + name: "Fido", + ownerId: userId + ).insert(database) + } + + struct PetWithUser: Decodable, FetchableRecord { + struct PartialUser: Decodable { + var name: String + } + + var pet: Pet // The base record + var user: PartialUser // The partial associated record + } + + let petsWithUsers = try await pool.read { db in + try Pet + .including(required: Pet.user) + .asRequest(of: PetWithUser.self) + .fetchAll(db) + } + + XCTAssert(petsWithUsers.count == 1) + XCTAssert(petsWithUsers[0].pet.name == "Fido") + XCTAssert(petsWithUsers[0].user.name == "Bob") + } + func testPowerSyncUpdates() async throws { let expectation = XCTestExpectation(description: "Watch changes") From 8a278a14f6a80f2f0bc360e73e6e52151776a0c5 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 18 Sep 2025 18:24:42 +0200 Subject: [PATCH 07/15] WIP: Add GRDB demo app --- .../GRDB Demo.xcodeproj/project.pbxproj | 710 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 96 +++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 85 +++ .../GRDB Demo/Assets.xcassets/Contents.json | 6 + .../logo.imageset/Contents.json | 21 + .../Assets.xcassets/logo.imageset/pslogo.svg | 14 + Demo/GRDB Demo/GRDB Demo/Data/List.swift | 44 ++ Demo/GRDB Demo/GRDB Demo/Data/Todo.swift | 41 + Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift | 85 +++ .../GRDB Demo/Models/ErrorViewModel.swift | 52 ++ .../GRDB Demo/Models/ListViewModel.swift | 67 ++ .../GRDB Demo/Models/SupabaseViewModel.swift | 111 +++ .../GRDB Demo/Models/TodosViewModel.swift | 73 ++ .../GRDB Demo/Models/ViewModels.swift | 33 + .../GRDB Demo/Screens/ErrorAlertView.swift | 25 + .../GRDB Demo/Screens/RootScreen.swift | 14 + .../Screens/StatusIndicatorView.swift | 71 ++ .../Screens/lists/AddListSheet.swift | 55 ++ .../Screens/lists/ListItemView.swift | 51 ++ .../GRDB Demo/Screens/lists/ListsScreen.swift | 99 +++ .../Screens/signin/SigninScreen.swift | 120 +++ .../Screens/todos/AddTodoSheet.swift | 54 ++ .../Screens/todos/TodoItemView.swift | 41 + .../GRDB Demo/Screens/todos/TodosScreen.swift | 67 ++ .../GRDB Demo/Screens/view-helpers.swift | 9 + .../GRDB Demo/SupabaseConnector.swift | 107 +++ Demo/GRDB Demo/GRDB Demo/_Secrets.swift | 11 + .../GRDB DemoTests/GRDB_DemoTests.swift | 7 + .../GRDB DemoUITests/GRDB_DemoUITests.swift | 33 + .../GRDB_DemoUITestsLaunchTests.swift | 25 + Sources/PowerSyncGRDB/GRDBPool.swift | 16 +- 33 files changed, 2253 insertions(+), 8 deletions(-) create mode 100644 Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj create mode 100644 Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json create mode 100644 Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json create mode 100644 Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg create mode 100644 Demo/GRDB Demo/GRDB Demo/Data/List.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Data/Todo.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift create mode 100644 Demo/GRDB Demo/GRDB Demo/_Secrets.swift create mode 100644 Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift create mode 100644 Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift create mode 100644 Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..de26646 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj @@ -0,0 +1,710 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + BE1EABD42E7B0C0600D0A0A7 /* GRDBQuery in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EABD32E7B0C0600D0A0A7 /* GRDBQuery */; }; + BE1EAC0A2E7C290E00D0A0A7 /* PowerSyncGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EAC092E7C290E00D0A0A7 /* PowerSyncGRDB */; }; + BE1EAC4C2E7C45F300D0A0A7 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EAC4B2E7C45F300D0A0A7 /* Auth */; }; + BE1EAC4F2E7C461800D0A0A7 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = BE1EAC4E2E7C461800D0A0A7 /* Supabase */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + BE1EABB32E7B075E00D0A0A7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BE1EAB9D2E7B075D00D0A0A7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BE1EABA42E7B075D00D0A0A7; + remoteInfo = "GRDB Demo"; + }; + BE1EABBD2E7B075E00D0A0A7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BE1EAB9D2E7B075D00D0A0A7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BE1EABA42E7B075D00D0A0A7; + remoteInfo = "GRDB Demo"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + BE1EABEE2E7C26DD00D0A0A7 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + BE1EABA52E7B075D00D0A0A7 /* GRDB Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GRDB Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + BE1EABB22E7B075E00D0A0A7 /* GRDB DemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "GRDB DemoTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + BE1EABBC2E7B075E00D0A0A7 /* GRDB DemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "GRDB DemoUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + BE1EABA72E7B075D00D0A0A7 /* GRDB Demo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GRDB Demo"; + sourceTree = ""; + }; + BE1EABB52E7B075E00D0A0A7 /* GRDB DemoTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GRDB DemoTests"; + sourceTree = ""; + }; + BE1EABBF2E7B075E00D0A0A7 /* GRDB DemoUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GRDB DemoUITests"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + BE1EABA22E7B075D00D0A0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BE1EAC4F2E7C461800D0A0A7 /* Supabase in Frameworks */, + BE1EAC0A2E7C290E00D0A0A7 /* PowerSyncGRDB in Frameworks */, + BE1EABD42E7B0C0600D0A0A7 /* GRDBQuery in Frameworks */, + BE1EAC4C2E7C45F300D0A0A7 /* Auth in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABAF2E7B075E00D0A0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABB92E7B075E00D0A0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + BE1EAB9C2E7B075D00D0A0A7 = { + isa = PBXGroup; + children = ( + BE1EABA72E7B075D00D0A0A7 /* GRDB Demo */, + BE1EABB52E7B075E00D0A0A7 /* GRDB DemoTests */, + BE1EABBF2E7B075E00D0A0A7 /* GRDB DemoUITests */, + BE1EAC4D2E7C461800D0A0A7 /* Frameworks */, + BE1EABA62E7B075D00D0A0A7 /* Products */, + ); + sourceTree = ""; + }; + BE1EABA62E7B075D00D0A0A7 /* Products */ = { + isa = PBXGroup; + children = ( + BE1EABA52E7B075D00D0A0A7 /* GRDB Demo.app */, + BE1EABB22E7B075E00D0A0A7 /* GRDB DemoTests.xctest */, + BE1EABBC2E7B075E00D0A0A7 /* GRDB DemoUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + BE1EAC4D2E7C461800D0A0A7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */ = { + isa = PBXNativeTarget; + buildConfigurationList = BE1EABC62E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB Demo" */; + buildPhases = ( + BE1EABA12E7B075D00D0A0A7 /* Sources */, + BE1EABA22E7B075D00D0A0A7 /* Frameworks */, + BE1EABA32E7B075D00D0A0A7 /* Resources */, + BE1EABEE2E7C26DD00D0A0A7 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + BE1EABA72E7B075D00D0A0A7 /* GRDB Demo */, + ); + name = "GRDB Demo"; + packageProductDependencies = ( + BE1EABD32E7B0C0600D0A0A7 /* GRDBQuery */, + BE1EAC092E7C290E00D0A0A7 /* PowerSyncGRDB */, + BE1EAC4B2E7C45F300D0A0A7 /* Auth */, + BE1EAC4E2E7C461800D0A0A7 /* Supabase */, + ); + productName = "GRDB Demo"; + productReference = BE1EABA52E7B075D00D0A0A7 /* GRDB Demo.app */; + productType = "com.apple.product-type.application"; + }; + BE1EABB12E7B075E00D0A0A7 /* GRDB DemoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BE1EABC92E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoTests" */; + buildPhases = ( + BE1EABAE2E7B075E00D0A0A7 /* Sources */, + BE1EABAF2E7B075E00D0A0A7 /* Frameworks */, + BE1EABB02E7B075E00D0A0A7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BE1EABB42E7B075E00D0A0A7 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BE1EABB52E7B075E00D0A0A7 /* GRDB DemoTests */, + ); + name = "GRDB DemoTests"; + packageProductDependencies = ( + ); + productName = "GRDB DemoTests"; + productReference = BE1EABB22E7B075E00D0A0A7 /* GRDB DemoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + BE1EABBB2E7B075E00D0A0A7 /* GRDB DemoUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BE1EABCC2E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoUITests" */; + buildPhases = ( + BE1EABB82E7B075E00D0A0A7 /* Sources */, + BE1EABB92E7B075E00D0A0A7 /* Frameworks */, + BE1EABBA2E7B075E00D0A0A7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BE1EABBE2E7B075E00D0A0A7 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BE1EABBF2E7B075E00D0A0A7 /* GRDB DemoUITests */, + ); + name = "GRDB DemoUITests"; + packageProductDependencies = ( + ); + productName = "GRDB DemoUITests"; + productReference = BE1EABBC2E7B075E00D0A0A7 /* GRDB DemoUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + BE1EAB9D2E7B075D00D0A0A7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + BE1EABA42E7B075D00D0A0A7 = { + CreatedOnToolsVersion = 26.0; + }; + BE1EABB12E7B075E00D0A0A7 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = BE1EABA42E7B075D00D0A0A7; + }; + BE1EABBB2E7B075E00D0A0A7 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = BE1EABA42E7B075D00D0A0A7; + }; + }; + }; + buildConfigurationList = BE1EABA02E7B075D00D0A0A7 /* Build configuration list for PBXProject "GRDB Demo" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = BE1EAB9C2E7B075D00D0A0A7; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + BE1EABCF2E7B07DE00D0A0A7 /* XCLocalSwiftPackageReference "../../../powersync-swift" */, + BE1EABD22E7B0C0600D0A0A7 /* XCRemoteSwiftPackageReference "GRDBQuery" */, + BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = BE1EABA62E7B075D00D0A0A7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */, + BE1EABB12E7B075E00D0A0A7 /* GRDB DemoTests */, + BE1EABBB2E7B075E00D0A0A7 /* GRDB DemoUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + BE1EABA32E7B075D00D0A0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABB02E7B075E00D0A0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABBA2E7B075E00D0A0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + BE1EABA12E7B075D00D0A0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABAE2E7B075E00D0A0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE1EABB82E7B075E00D0A0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + BE1EABB42E7B075E00D0A0A7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */; + targetProxy = BE1EABB32E7B075E00D0A0A7 /* PBXContainerItemProxy */; + }; + BE1EABBE2E7B075E00D0A0A7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BE1EABA42E7B075D00D0A0A7 /* GRDB Demo */; + targetProxy = BE1EABBD2E7B075E00D0A0A7 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + BE1EABC42E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + BE1EABC52E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + BE1EABC72E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-Demo"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BE1EABC82E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-Demo"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + BE1EABCA2E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDB Demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BE1EABCB2E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDB Demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + BE1EABCD2E7B075E00D0A0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = "GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BE1EABCE2E7B075E00D0A0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZGT7463CVJ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "JourneyApps.GRDB-DemoUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = "GRDB Demo"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BE1EABA02E7B075D00D0A0A7 /* Build configuration list for PBXProject "GRDB Demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABC42E7B075E00D0A0A7 /* Debug */, + BE1EABC52E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BE1EABC62E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB Demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABC72E7B075E00D0A0A7 /* Debug */, + BE1EABC82E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BE1EABC92E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABCA2E7B075E00D0A0A7 /* Debug */, + BE1EABCB2E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BE1EABCC2E7B075E00D0A0A7 /* Build configuration list for PBXNativeTarget "GRDB DemoUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BE1EABCD2E7B075E00D0A0A7 /* Debug */, + BE1EABCE2E7B075E00D0A0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + BE1EABCF2E7B07DE00D0A0A7 /* XCLocalSwiftPackageReference "../../../powersync-swift" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../powersync-swift"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + BE1EABD22E7B0C0600D0A0A7 /* XCRemoteSwiftPackageReference "GRDBQuery" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDBQuery"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.11.0; + }; + }; + BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/supabase-community/supabase-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BE1EABD32E7B0C0600D0A0A7 /* GRDBQuery */ = { + isa = XCSwiftPackageProductDependency; + package = BE1EABD22E7B0C0600D0A0A7 /* XCRemoteSwiftPackageReference "GRDBQuery" */; + productName = GRDBQuery; + }; + BE1EAC092E7C290E00D0A0A7 /* PowerSyncGRDB */ = { + isa = XCSwiftPackageProductDependency; + productName = PowerSyncGRDB; + }; + BE1EAC4B2E7C45F300D0A0A7 /* Auth */ = { + isa = XCSwiftPackageProductDependency; + package = BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Auth; + }; + BE1EAC4E2E7C461800D0A0A7 /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + package = BE1EAC4A2E7C45F300D0A0A7 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Supabase; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = BE1EAB9D2E7B075D00D0A0A7 /* Project object */; +} diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..53d7464 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "84d25347b5249e7ab78894935a203b13d9d55e5a01b7fe00745bdf028062df42", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "branch" : "dev/persistable-views", + "revision" : "3e1a711d3fedfcab2af0e52ddae03497b665e5fb" + } + }, + { + "identity" : "grdbquery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDBQuery", + "state" : { + "revision" : "540dc48e86af2972b4f1815616aa1ed8ac97845a", + "version" : "0.11.0" + } + }, + { + "identity" : "powersync-sqlite-core-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + "state" : { + "revision" : "00776db5157c8648671b00e6673603144fafbfeb", + "version" : "0.4.5" + } + }, + { + "identity" : "supabase-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase-community/supabase-swift.git", + "state" : { + "revision" : "ec607e021e6adace332eddd9e1e90ea6d4af5068", + "version" : "2.32.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "d1c6b70f7c5f19fb0b8750cb8dcdf2ea6e2d8c34", + "version" : "3.15.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", + "version" : "1.4.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", + "version" : "1.6.1" + } + } + ], + "version" : 3 +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ffdfe15 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..11f1a66 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pslogo.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg new file mode 100644 index 0000000..441e712 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Assets.xcassets/logo.imageset/pslogo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Demo/GRDB Demo/GRDB Demo/Data/List.swift b/Demo/GRDB Demo/GRDB Demo/Data/List.swift new file mode 100644 index 0000000..68b252b --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Data/List.swift @@ -0,0 +1,44 @@ +import GRDB +import PowerSync + +/// PowerSync client side schema +let listsTable = Table( + name: "lists", + columns: [ + .text("name"), + .text("owner_id") + ] +) + +struct List: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var ownerId: String + + static var databaseTableName = "lists" + + + enum CodingKeys: String, CodingKey { + case id + case name + case ownerId = "owner_id" + } + + enum Columns { + static let id = Column(CodingKeys.id) + static let name = Column(CodingKeys.name) + static let ownerId = Column(CodingKeys.ownerId) + } + + static let todos = hasMany( + Todo.self, key: "todos", + using: ForeignKey([Todo.Columns.listId], to: [Columns.id]) + ) +} + +/// Result for displaying lists in the main view +struct ListWithTodoCounts: Decodable, Hashable, Identifiable, FetchableRecord { + var id: String + var name: String + var pendingCount: Int +} diff --git a/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift b/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift new file mode 100644 index 0000000..f0dc12d --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift @@ -0,0 +1,41 @@ +import Foundation +import GRDB +import PowerSync + +/// PowerSync client side schema +let todosTable = Table( + name: "todos", + columns: [ + .text("name"), + .text("list_id"), + // Conversion should automatically be handled by GRDB + .integer("completed"), + .text("completed_at") + ] +) + +struct Todo: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord { + var id: String + var name: String + var listId: String + var isCompleted: Bool + var completedAt: Date? + + static var databaseTableName = "todos" + + enum CodingKeys: String, CodingKey { + case id + case name + case listId = "list_id" + case isCompleted = "completed" + case completedAt = "completed_at" + } + + enum Columns { + static let id = Column(CodingKeys.id) + static let name = Column(CodingKeys.name) + static let listId = Column(CodingKeys.listId) + static let isCompleted = Column(CodingKeys.isCompleted) + static let completedAt = Column(CodingKeys.completedAt) + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift new file mode 100644 index 0000000..0d8dd28 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift @@ -0,0 +1,85 @@ +import GRDB +import GRDBQuery +import PowerSync +import PowerSyncGRDB +import SwiftUI + +@Observable +class Databases { + let grdb: DatabasePool + let powerSync: PowerSyncDatabaseProtocol + + init(grdb: DatabasePool, powerSync: PowerSyncDatabaseProtocol) { + self.grdb = grdb + self.powerSync = powerSync + } +} + +func openDatabase() + -> Databases +{ + let schema = Schema( + tables: [ + listsTable, + todosTable + ]) + + let dbUrl = FileManager + .default + .urls( + for: .documentDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("test.sqlite") + + var config = Configuration() + + configurePowerSync( + config: &config, + schema: schema + ) + + guard let grdb = try? DatabasePool( + path: dbUrl.path, + configuration: config + ) else { + fatalError("Could not open database") + } + + let powerSync = OpenedPowerSyncDatabase( + schema: schema, + pool: GRDBConnectionPool( + pool: grdb + ), + identifier: "test" + ) + + return Databases( + grdb: grdb, + powerSync: powerSync + ) +} + +@main +struct GRDB_DemoApp: App { + let viewModels: ViewModels + + init() { + viewModels = ViewModels( + databases: openDatabase() + ) + } + + var body: some Scene { + WindowGroup { + ErrorAlertView { + RootScreen( + supabaseViewModel: viewModels.supabaseViewModel + ) + } + .environment(viewModels) + // Used by GRDB observed queries + .databaseContext(.readWrite { viewModels.databases.grdb }) + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift new file mode 100644 index 0000000..a7bdd22 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/ErrorViewModel.swift @@ -0,0 +1,52 @@ +import GRDB +import SwiftUI + +func presentError(_ error: Error) -> String { + if let grdbError = error as? DatabaseError { + return grdbError.message ?? "Unknown GRDB error" + } else { + return error.localizedDescription + } +} + +/// A small view model which allows reporting errors to an observable state. +/// This state can be used by a shared view as an alert service. +@Observable +class ErrorViewModel { + var errorMessage: String? + + func report(_ message: String) { + errorMessage = message + } + + /// Runs a callback and presents ant error if thrown + @discardableResult + func withReporting( + _ message: String? = nil, + _ callback: () throws -> R + ) rethrows -> R { + do { + return try callback() + } catch { + errorMessage = message ?? ": " + presentError(error) + throw error + } + } + + @discardableResult + func withReportingAsync( + _ message: String? = nil, + _ callback: @escaping () async throws -> R + ) async throws -> R { + do { + return try await callback() + } catch { + errorMessage = message ?? ": " + presentError(error) + throw error + } + } + + func clear() { + errorMessage = nil + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift new file mode 100644 index 0000000..dde900e --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/ListViewModel.swift @@ -0,0 +1,67 @@ +import Auth +import Combine +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +struct ListsWithTodoCountsRequest: ValueObservationQueryable { + static var defaultValue: [ListWithTodoCounts] { [] } + + func fetch(_ database: Database) throws -> [ListWithTodoCounts] { + // Association for completed todos + let pendingTodos = List.todos.filter(Todo.Columns.isCompleted == false) + + // It's tricky to annotate with opposing checks for isCompleted at once + // So we just check the pending todos count + let request = List + .annotated(with: [ + pendingTodos.count.forKey("pendingCount") + ]).order(sql: "pendingCount DESC") + + return try ListWithTodoCounts.fetchAll(database, request) + } +} + +class ListViewModel { + let grdb: DatabasePool + let errorModel: ErrorViewModel + let supabaseModel: SupabaseViewModel + + init( + grdb: DatabasePool, + errorModel: ErrorViewModel, + supabaseModel: SupabaseViewModel + ) { + self.grdb = grdb + self.errorModel = errorModel + self.supabaseModel = supabaseModel + } + + func createList(name: String) throws { + try errorModel.withReporting("Could not create list") { + guard let userId = supabaseModel.session?.user.id.uuidString else { + throw NSError(domain: "AppError", code: 1, userInfo: [NSLocalizedDescriptionKey: "No userId or session found"]) + } + try grdb.write { database in + try List( + id: UUID().uuidString, + name: name, + ownerId: userId + ).insert(database) + } + } + } + + func deleteList(id: String) throws { + try errorModel.withReporting("Could not delete list") { + try grdb.write { database in + /// This should automatically delete all the todos due to the hasMany relationship + try List.deleteOne( + database, + key: id + ) + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift new file mode 100644 index 0000000..666aabb --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/SupabaseViewModel.swift @@ -0,0 +1,111 @@ +import Combine +import Supabase +import SwiftUI + +class SupabaseViewModel: ObservableObject { + let client: SupabaseClient + @Published var session: Session? + + private var authTask: Task? + + init( + url: URL = Secrets.supabaseURL, + anonKey: String = Secrets.supabaseAnonKey + ) { + client = SupabaseClient( + supabaseURL: url, + supabaseKey: anonKey + ) + + // Start observing auth state changes + authTask = Task { [weak self] in + guard let self = self else { + fatalError("Could not watch Supabase") + } + for await change in self.client.auth.authStateChanges { + await MainActor.run { + self.session = change.session + } + } + } + // Set initial session + session = client.auth.currentSession + } + + deinit { + authTask?.cancel() + } + + func signIn( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) { + Task { + do { + let session = try await client.auth.signIn(email: email, password: password) + await MainActor.run { + self.session = session + completion(.success(session)) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } + + + func signOut( + hook: @escaping () async throws -> Void, + completion: @escaping (Result) -> Void + ) { + Task { + do { + try await client.auth.signOut() + try await hook() + await MainActor.run { + self.session = nil + completion(.success(())) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } + + + + func register( + email: String, + password: String, + completion: @escaping (Result) -> Void + ) { + Task { + do { + let response = try await client.auth.signUp(email: email, password: password) + await MainActor.run { + guard let session = response.session else { + completion(.failure( + NSError( + domain: "SupabaseModel", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "No session returned. Please check your email for confirmation."] + ) + )) + return + } + self.session = session + completion(.success(session)) + } + } catch { + await MainActor.run { + completion(.failure(error)) + } + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift new file mode 100644 index 0000000..bbbf2b5 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift @@ -0,0 +1,73 @@ +import Combine +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +struct ListsTodosRequest: ValueObservationQueryable { + let list: ListWithTodoCounts + + static var defaultValue: [Todo] { [] } + + func fetch(_ database: Database) throws -> [Todo] { + try Todo + .filter(Todo.Columns.listId == list.id) + .order(Todo.Columns.name) + .order(Todo.Columns.isCompleted) + .fetchAll(database) + } +} + +@Observable +class TodoViewModel { + let grdb: DatabasePool + let errorModel: ErrorViewModel + + init( + grdb: DatabasePool, + errorModel: ErrorViewModel + ) { + self.grdb = grdb + self.errorModel = errorModel + } + + func createTodo(name: String, listId: String) throws { + try errorModel.withReporting("Could not create todo") { + try grdb.write { database in + try Todo( + id: UUID().uuidString, + name: name, + listId: listId, + isCompleted: false + ).insert(database) + } + } + } + + func toggleCompleted(todo: Todo) throws { + try errorModel.withReporting("Could not update completed at") { + var updatedTodo = todo + try grdb.write { database in + if todo.isCompleted { + updatedTodo.isCompleted = false + updatedTodo.completedAt = nil + } else { + updatedTodo.completedAt = Date() + updatedTodo.isCompleted = true + } + _ = try updatedTodo.update(database) + } + } + } + + func deleteTodo(_ id: String) throws { + try errorModel.withReporting("Could not delete todo") { + try grdb.write { database in + _ = try Todo.deleteOne( + database, + key: id + ) + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift b/Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift new file mode 100644 index 0000000..2e605a9 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Models/ViewModels.swift @@ -0,0 +1,33 @@ +import Combine +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +@Observable +class ViewModels { + let errorViewModel: ErrorViewModel + let listViewModel: ListViewModel + let todoViewModel: TodoViewModel + let supabaseViewModel: SupabaseViewModel + + let databases: Databases + + init( + databases: Databases, + ) { + self.databases = databases + errorViewModel = ErrorViewModel() + supabaseViewModel = SupabaseViewModel() + listViewModel = ListViewModel( + grdb: databases.grdb, + errorModel: errorViewModel, + supabaseModel: supabaseViewModel + ) + todoViewModel = TodoViewModel( + grdb: databases.grdb, + errorModel: errorViewModel + ) + } + +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift new file mode 100644 index 0000000..7ba2d96 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/ErrorAlertView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// A Simple View which presents the latest error state from the `ErrorViewModel` +struct ErrorAlertView: View { + @Environment(ViewModels.self) var viewModels + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .alert(isPresented: Binding( + get: { viewModels.errorViewModel.errorMessage != nil }, + set: { newValue in + if !newValue { viewModels.errorViewModel.clear() } + } + )) { + Alert( + title: Text("Error"), + message: Text(viewModels.errorViewModel.errorMessage ?? ""), + dismissButton: .default(Text("OK")) { + viewModels.errorViewModel.clear() + } + ) + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift new file mode 100644 index 0000000..d6ba771 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/RootScreen.swift @@ -0,0 +1,14 @@ +import Auth +import SwiftUI + +struct RootScreen: View { + @ObservedObject var supabaseViewModel: SupabaseViewModel + + var body: some View { + if supabaseViewModel.session != nil { + ListsScreen() + } else { + SigninScreen() + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift new file mode 100644 index 0000000..16cd449 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift @@ -0,0 +1,71 @@ +import PowerSync +import SwiftUI + +struct StatusIndicatorView: View { + @Environment(ViewModels.self) var viewModels + + var powerSync: PowerSyncDatabaseProtocol { + viewModels.databases.powerSync + } + + @State var statusImageName: String = "wifi.slash" + @State private var showErrorAlert = false + + let content: () -> Content + + var body: some View { + content() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + if powerSync.currentStatus.anyError != nil { + showErrorAlert = true + } + } label: { + Image(systemName: statusImageName) + } + .contextMenu { + if powerSync.currentStatus.connected || powerSync.currentStatus.connecting { + Button("Disconnect") { + Task { + try await powerSync.disconnect() + } + } + } else { + Button("Connect") { + Task { + try await powerSync.connect( + connector: SupabaseConnector(supabase: viewModels.supabaseViewModel) + ) + } + } + } + } + } + } + .alert(isPresented: $showErrorAlert) { + Alert( + title: Text("Error"), + message: Text(String("\(powerSync.currentStatus.anyError ?? "Unknown error")")), + dismissButton: .default(Text("OK")) + ) + } + .task { + do { + for try await status in powerSync.currentStatus.asFlow() { + if powerSync.currentStatus.anyError != nil { + statusImageName = "exclamationmark.triangle.fill" + } else if status.connected { + statusImageName = "wifi" + } else if status.connecting { + statusImageName = "wifi.exclamationmark" + } else { + statusImageName = "wifi.slash" + } + } + } catch { + print("Could not monitor status") + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift b/Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift new file mode 100644 index 0000000..857822c --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/lists/AddListSheet.swift @@ -0,0 +1,55 @@ +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +/// View which allows creating a new List +struct AddListSheet: View { + @Environment(ViewModels.self) var viewModels + + @Binding var isPresented: Bool + @State var newListName: String = "" + @FocusState private var isTextFieldFocused: Bool + + var body: some View { + VStack(spacing: 16) { + Text("New List") + .font(.headline) + TextField("List name", text: $newListName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isTextFieldFocused) + .padding() + HStack { + Button("Cancel") { + isPresented = false + newListName = "" + } + Spacer() + Button("Add") { + do { + try viewModels.listViewModel.createList(name: newListName) + isPresented = false + newListName = "" + } catch { + // Don't close the dialog + print("Error adding list: \(error)") + } + } + .disabled(newListName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal) + } + .padding() + .onAppear { + isTextFieldFocused = true + } + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 16) + .fill( + modalBackgroundColor + ) + .shadow(radius: 8) + ) + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift new file mode 100644 index 0000000..69c187a --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListItemView.swift @@ -0,0 +1,51 @@ +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +/// Main view for viewing and editing Lists +struct ListItemView: View { + @Environment(ViewModels.self) var viewModels + + var list: ListWithTodoCounts + let onOpen: () -> Void + + var body: some View { + VStack { + HStack { + Text(list.name).font(.title) + Spacer() + Button { + onOpen() + } label: { + Image(systemName: "arrow.right.circle") + } + .buttonStyle(BorderlessButtonStyle()) + #if os(macOS) + Button { + try? viewModels.listViewModel.deleteList(id: list.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(BorderlessButtonStyle()) + .foregroundColor(.red) + #endif + } + HStack { + if list.pendingCount > 0 { + Text("\(list.pendingCount) Pending") + .font(.subheadline) + } + Spacer() + } + } + .padding() + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + try? viewModels.listViewModel.deleteList(id: list.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift new file mode 100644 index 0000000..6d80e4a --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/lists/ListsScreen.swift @@ -0,0 +1,99 @@ +import GRDB +import GRDBQuery +import PowerSync +import SwiftUI + +struct ListsScreen: View { + @Query(ListsWithTodoCountsRequest()) + var lists: [ListWithTodoCounts] + + @Environment(ViewModels.self) var viewModels + + @State private var showingAddSheet = false + @State private var selectedList: ListWithTodoCounts? + + var body: some View { + NavigationStack { + StatusIndicatorView { + ZStack { + SwiftUI.List(lists) { list in + ListItemView( + list: list + ) { + selectedList = list + } + } + + // Floating Action Button + VStack { + Spacer() + HStack { + Spacer() + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .padding() + .background(Circle().fill(Color.accentColor)) + .shadow(radius: 4) + } + .buttonStyle(BorderlessButtonStyle()) + .padding() + .accessibilityLabel("Create New List") + } + } + // Modal overlay + if showingAddSheet { + Color.black.opacity(0.3) // Dimmed background + .ignoresSafeArea() + AddListSheet(isPresented: $showingAddSheet) + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(modalBackgroundColor) + .shadow(radius: 8) + ) + .transition(.scale) + } + } + } + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + viewModels.supabaseViewModel.signOut { + try await viewModels.databases.powerSync.disconnectAndClear() + } completion: { _ in } + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + } + } + .navigationTitle("Todo Lists") + // Navigation to TodosView + .navigationDestination(item: $selectedList) { list in + TodosView(list: list) + } + } + .task { + // Automatically connect on startup + try? await viewModels.errorViewModel.withReportingAsync { + try await viewModels.databases.powerSync.connect( + connector: SupabaseConnector( + supabase: viewModels.supabaseViewModel + ) + ) + } + } + } +} + +#Preview { + ListsScreen() + .environment( + ViewModels( + databases: openDatabase() + ) + ) +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift new file mode 100644 index 0000000..1e38256 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct SigninScreen: View { + @State private var email = "" + @State private var password = "" + @State private var isRegistering = false + @State private var errorMessage: String? + @State private var busy = false + + @FocusState private var emailFieldFocused: Bool + + @Environment(ViewModels.self) var viewModels + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0 / 255, green: 33 / 255, blue: 98 / 255), // #002162 + Color(red: 10 / 255, green: 43 / 255, blue: 120 / 255) // Slightly lighter blue + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 24) { + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .padding(.top, 40) + + Text(isRegistering ? "Register" : "Sign In") + .font(.largeTitle) + .bold() + .foregroundColor(.white) + + VStack(spacing: 16) { + TextField("Email", text: $email) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocapitalization(.none) + .keyboardType(.emailAddress) + .focused($emailFieldFocused) + + SecureField("Password", text: $password) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + .padding(.horizontal, 32) + + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + + Button(isRegistering ? "Register" : "Sign In") { + if email.isEmpty || password.isEmpty { + errorMessage = "Please enter both email and password." + return + } + errorMessage = nil + busy = true + if isRegistering { + viewModels.supabaseViewModel.register( + email: email, + password: password + ) { result in + switch result { + case .success: + break + // Don't need to do anything, will be automatically navigated + case let .failure(error): + errorMessage = "Could not register: \(error)" + } + busy = false + } + } else { + viewModels.supabaseViewModel.signIn( + email: email, + password: password + ) { result in + switch result { + case .success: + // Don't need to do anything, will be automatically navigated + break + case let .failure(error): + errorMessage = "Could not login: \(error)" + } + busy = false + } + } + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .foregroundColor(.white) + .padding(.horizontal, 32) + + Button(isRegistering ? "Already have an account? Sign In" : "Don't have an account? Register") { + isRegistering.toggle() + errorMessage = nil + } + .font(.footnote) + .padding(.top, 8) + .foregroundColor(.white) + } + .padding() + .onAppear { + emailFieldFocused = true + } + } + } +} + +#Preview { + SigninScreen() + .environment( + ViewModels(databases: openDatabase() + ) + ) +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift new file mode 100644 index 0000000..6ecbaa7 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/AddTodoSheet.swift @@ -0,0 +1,54 @@ +import GRDB +import GRDBQuery +import SwiftUI + +/// View which allows creating a new Todo +struct AddTodoSheet: View { + @Binding var isPresented: Bool + @State var newTodoName: String = "" + + @FocusState private var isTextFieldFocused: Bool + + var onAdd: (String) throws -> Void + + var body: some View { + VStack(spacing: 16) { + Text("New Todo") + .font(.headline) + TextField("Todo name", text: $newTodoName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isTextFieldFocused) + .padding() + HStack { + Button("Cancel") { + isPresented = false + newTodoName = "" + } + Spacer() + Button("Add") { + do { + try onAdd(newTodoName) + // Close the sheet + isPresented = false + newTodoName = "" + } catch { + // Don't close the sheet + print("Error adding todo: \(error)") + } + } + .disabled(newTodoName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal) + } + .padding() + .onAppear { + isTextFieldFocused = true + } + .frame(width: 300) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(modalBackgroundColor) + .shadow(radius: 8) + ) + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift new file mode 100644 index 0000000..e1ef54e --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift @@ -0,0 +1,41 @@ +import GRDB +import GRDBQuery +import SwiftUI + +struct TodoItemView: View { + var todo: Todo + + @Environment(ViewModels.self) var viewModels + + static let completedAtFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm yyyy/MM/dd" + return formatter + }() + + var body: some View { + VStack { + HStack { + Text(todo.name).font(.title) + Spacer() + Button { + try? viewModels.todoViewModel.toggleCompleted(todo: todo) + } label: { + if todo.isCompleted { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + } else { + // make the icon empty circle when not completed + Image(systemName: "circle").foregroundColor(.green) + } + } + } + HStack { + if let completedAt = todo.completedAt { + Text("Completed at \(Self.completedAtFormatter.string(from: completedAt))") + } + Spacer() + } + } + .padding() + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift new file mode 100644 index 0000000..0365154 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodosScreen.swift @@ -0,0 +1,67 @@ +import GRDB +import GRDBQuery +import SwiftUI + +struct TodosView: View { + let list: ListWithTodoCounts + + @Environment(ViewModels.self) var viewModels + + @Query + var todos: [Todo] + + @State var showingAddSheet: Bool = false + + init(list: ListWithTodoCounts) { + self.list = list + _todos = Query(ListsTodosRequest(list: list)) + } + + var body: some View { + StatusIndicatorView { + ZStack { + SwiftUI.List(todos) { todo in + TodoItemView(todo: todo) + } + // Floating Action Button + VStack { + Spacer() + HStack { + Spacer() + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .padding() + .background(Circle().fill(Color.accentColor)) + .shadow(radius: 4) + } + .buttonStyle(BorderlessButtonStyle()) + .padding() + .accessibilityLabel("Create New Todo") + } + } + // Modal overlay + if showingAddSheet { + Color.black.opacity(0.4) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + showingAddSheet = false + } + AddTodoSheet( + isPresented: $showingAddSheet + ) { name in + try viewModels.todoViewModel.createTodo( + name: name, + listId: list.id + ) + } + } + } + + .navigationTitle(list.name) + } + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift b/Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift new file mode 100644 index 0000000..faea651 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/Screens/view-helpers.swift @@ -0,0 +1,9 @@ +import SwiftUI + +var modalBackgroundColor: Color { + #if os(iOS) + return Color(.systemGray6) + #else + return Color(nsColor: .windowBackgroundColor) + #endif +} diff --git a/Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift b/Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift new file mode 100644 index 0000000..6c15991 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/SupabaseConnector.swift @@ -0,0 +1,107 @@ +import Auth +import PowerSync +import Supabase +import SwiftUI + +@MainActor // _session is mutable, limiting to the MainActor satisfies Sendable constraints +final class SupabaseConnector: PowerSyncBackendConnectorProtocol { + let supabase: SupabaseViewModel + + init( + supabase: SupabaseViewModel + ) { + self.supabase = supabase + } + + func fetchCredentials() async throws -> PowerSyncCredentials? { + guard let session = supabase.session else { + return nil + } + + return PowerSyncCredentials( + endpoint: Secrets.powerSyncEndpoint, + token: session.accessToken + ) + } + + func uploadData(database: PowerSyncDatabaseProtocol) async throws { + guard let transaction = try await database.getNextCrudTransaction() else { return } + + var lastEntry: CrudEntry? + do { + for entry in transaction.crud { + lastEntry = entry + let tableName = entry.table + + let table = supabase.client.from(tableName) + + switch entry.op { + case .put: + var data = entry.opData ?? [:] + data["id"] = entry.id + try await table.upsert(data).execute() + case .patch: + guard let opData = entry.opData else { continue } + try await table.update(opData).eq("id", value: entry.id).execute() + case .delete: + try await table.delete().eq("id", value: entry.id).execute() + } + } + + try await transaction.complete() + + } catch { + if let errorCode = PostgresFatalCodes.extractErrorCode(from: error), + PostgresFatalCodes.isFatalError(errorCode) + { + /// Instead of blocking the queue with these errors, + /// discard the (rest of the) transaction. + /// + /// Note that these errors typically indicate a bug in the application. + /// If protecting against data loss is important, save the failing records + /// elsewhere instead of discarding, and/or notify the user. + print("Data upload error: \(error)") + print("Discarding entry: \(lastEntry!)") + try await transaction.complete() + return + } + + print("Data upload error - retrying last entry: \(lastEntry!), \(error)") + throw error + } + } +} + + +private enum PostgresFatalCodes { + /// Postgres Response codes that we cannot recover from by retrying. + static let fatalResponseCodes: [String] = [ + // Class 22 — Data Exception + // Examples include data type mismatch. + "22...", + // Class 23 — Integrity Constraint Violation. + // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. + "23...", + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + "42501", + ] + + static func isFatalError(_ code: String) -> Bool { + return fatalResponseCodes.contains { pattern in + code.range(of: pattern, options: [.regularExpression]) != nil + } + } + + static func extractErrorCode(from error: any Error) -> String? { + // Look for code: Optional("XXXXX") pattern + let errorString = String(describing: error) + if let range = errorString.range(of: "code: Optional\\(\"([^\"]+)\"\\)", options: .regularExpression), + let codeRange = errorString[range].range(of: "\"([^\"]+)\"", options: .regularExpression) + { + // Extract just the code from within the quotes + let code = errorString[codeRange].dropFirst().dropLast() + return String(code) + } + return nil + } +} diff --git a/Demo/GRDB Demo/GRDB Demo/_Secrets.swift b/Demo/GRDB Demo/GRDB Demo/_Secrets.swift new file mode 100644 index 0000000..7f735cf --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/_Secrets.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A protocol which specified the base structure for secrets +protocol SecretsProvider { + static var powerSyncEndpoint: String { get } + static var supabaseURL: URL { get } + static var supabaseAnonKey: String { get } +} + +// Default conforming type +enum Secrets: SecretsProvider {} diff --git a/Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift b/Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift new file mode 100644 index 0000000..e91661b --- /dev/null +++ b/Demo/GRDB Demo/GRDB DemoTests/GRDB_DemoTests.swift @@ -0,0 +1,7 @@ +import Testing + +struct GRDB_DemoTests { + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } +} diff --git a/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift new file mode 100644 index 0000000..bf31784 --- /dev/null +++ b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +final class GRDB_DemoUITests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift new file mode 100644 index 0000000..26d4c34 --- /dev/null +++ b/Demo/GRDB Demo/GRDB DemoUITests/GRDB_DemoUITestsLaunchTests.swift @@ -0,0 +1,25 @@ +import XCTest + +final class GRDB_DemoUITestsLaunchTests: XCTestCase { + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift index b1ece1a..048a6c5 100644 --- a/Sources/PowerSyncGRDB/GRDBPool.swift +++ b/Sources/PowerSyncGRDB/GRDBPool.swift @@ -41,7 +41,7 @@ struct PowerSyncSchemaSource: DatabaseSchemaSource { } } -func configurePowerSync( +public func configurePowerSync( config: inout Configuration, schema: Schema ) { @@ -105,14 +105,14 @@ final class PowerSyncTransactionObserver: TransactionObserver { func databaseDidRollback(_: GRDB.Database) {} } -final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { +public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { let pool: DatabasePool var pendingUpdates: Set private let pendingUpdatesQueue = DispatchQueue( label: "co.powersync.pendingUpdatesQueue" ) - init( + public init( pool: DatabasePool ) { self.pool = pool @@ -128,7 +128,7 @@ final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { ) } - func getPendingUpdates() -> Set { + public func getPendingUpdates() -> Set { self.pendingUpdatesQueue.sync { let copy = self.pendingUpdates self.pendingUpdates.removeAll() @@ -136,7 +136,7 @@ final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { } } - func read( + public func read( onConnection: @Sendable @escaping (OpaquePointer) -> Void ) async throws { try await pool.read { database in @@ -147,7 +147,7 @@ final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { } } - func write( + public func write( onConnection: @Sendable @escaping (OpaquePointer) -> Void ) async throws { // Don't start an explicit transaction @@ -159,13 +159,13 @@ final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { } } - func withAllConnections( + public func withAllConnections( onConnection _: @escaping (OpaquePointer, [OpaquePointer]) -> Void ) async throws { // TODO: } - func close() throws { + public func close() throws { try pool.close() } } From 4002d859945d88371e8415bd43a56772c97ee03f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 19 Sep 2025 15:18:54 +0200 Subject: [PATCH 08/15] demo improvements --- .../GRDB Demo.xcodeproj/project.pbxproj | 20 ++++++++++++ Demo/GRDB Demo/GRDB Demo/Data/Todo.swift | 8 ++--- .../GRDB Demo/Models/TodosViewModel.swift | 4 +-- .../Screens/StatusIndicatorView.swift | 32 ++++++++++++------- .../Screens/signin/SigninScreen.swift | 2 ++ .../Screens/todos/TodoItemView.swift | 2 +- 6 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj index de26646..a8b994b 100644 --- a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj @@ -437,7 +437,17 @@ DEVELOPMENT_TEAM = ZGT7463CVJ; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; @@ -481,7 +491,17 @@ DEVELOPMENT_TEAM = ZGT7463CVJ; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; diff --git a/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift b/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift index f0dc12d..79d5792 100644 --- a/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift +++ b/Demo/GRDB Demo/GRDB Demo/Data/Todo.swift @@ -6,7 +6,7 @@ import PowerSync let todosTable = Table( name: "todos", columns: [ - .text("name"), + .text("description"), .text("list_id"), // Conversion should automatically be handled by GRDB .integer("completed"), @@ -16,7 +16,7 @@ let todosTable = Table( struct Todo: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord { var id: String - var name: String + var description: String var listId: String var isCompleted: Bool var completedAt: Date? @@ -25,7 +25,7 @@ struct Todo: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecor enum CodingKeys: String, CodingKey { case id - case name + case description case listId = "list_id" case isCompleted = "completed" case completedAt = "completed_at" @@ -33,7 +33,7 @@ struct Todo: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecor enum Columns { static let id = Column(CodingKeys.id) - static let name = Column(CodingKeys.name) + static let description = Column(CodingKeys.description) static let listId = Column(CodingKeys.listId) static let isCompleted = Column(CodingKeys.isCompleted) static let completedAt = Column(CodingKeys.completedAt) diff --git a/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift b/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift index bbbf2b5..f971247 100644 --- a/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift +++ b/Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift @@ -12,7 +12,7 @@ struct ListsTodosRequest: ValueObservationQueryable { func fetch(_ database: Database) throws -> [Todo] { try Todo .filter(Todo.Columns.listId == list.id) - .order(Todo.Columns.name) + .order(Todo.Columns.description) .order(Todo.Columns.isCompleted) .fetchAll(database) } @@ -36,7 +36,7 @@ class TodoViewModel { try grdb.write { database in try Todo( id: UUID().uuidString, - name: name, + description: name, listId: listId, isCompleted: false ).insert(database) diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift index 16cd449..a3387f1 100644 --- a/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift +++ b/Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift @@ -9,20 +9,27 @@ struct StatusIndicatorView: View { } @State var statusImageName: String = "wifi.slash" - @State private var showErrorAlert = false + @State var directionStatusImageName: String? let content: () -> Content var body: some View { content() .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .automatic) { Button { - if powerSync.currentStatus.anyError != nil { - showErrorAlert = true + if let error = powerSync.currentStatus.anyError { + viewModels.errorViewModel.report("\(error)") } } label: { - Image(systemName: statusImageName) + ZStack { + // Network status + Image(systemName: statusImageName) + // Upload/Download status + if let name = directionStatusImageName { + Image(systemName: name) + } + } } .contextMenu { if powerSync.currentStatus.connected || powerSync.currentStatus.connecting { @@ -43,13 +50,6 @@ struct StatusIndicatorView: View { } } } - .alert(isPresented: $showErrorAlert) { - Alert( - title: Text("Error"), - message: Text(String("\(powerSync.currentStatus.anyError ?? "Unknown error")")), - dismissButton: .default(Text("OK")) - ) - } .task { do { for try await status in powerSync.currentStatus.asFlow() { @@ -62,6 +62,14 @@ struct StatusIndicatorView: View { } else { statusImageName = "wifi.slash" } + + if status.downloading { + directionStatusImageName = "chevron.down.2" + } else if status.uploading { + directionStatusImageName = "chevron.up.2" + } else { + directionStatusImageName = nil + } } } catch { print("Could not monitor status") diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift b/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift index 1e38256..757ad08 100644 --- a/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift +++ b/Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift @@ -38,8 +38,10 @@ struct SigninScreen: View { VStack(spacing: 16) { TextField("Email", text: $email) .textFieldStyle(RoundedBorderTextFieldStyle()) + #if os (iOS) || os (tvOS) || targetEnvironment(macCatalyst) .autocapitalization(.none) .keyboardType(.emailAddress) + #endif .focused($emailFieldFocused) SecureField("Password", text: $password) diff --git a/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift index e1ef54e..0c37b28 100644 --- a/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift +++ b/Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift @@ -16,7 +16,7 @@ struct TodoItemView: View { var body: some View { VStack { HStack { - Text(todo.name).font(.title) + Text(todo.description).font(.title) Spacer() Button { try? viewModels.todoViewModel.toggleCompleted(todo: todo) From 6f0e630464ba3ca417b4fdf810ccdf39d51c44ae Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 23 Sep 2025 17:43:18 +0200 Subject: [PATCH 09/15] Table updates from PowerSync side --- Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift | 4 +- .../Kotlin/KotlinSQLiteConnectionPool.swift | 72 ++++++-- .../Protocol/SQLiteConnectionPool.swift | 14 +- .../Config/Configuration+PowerSync.swift | 62 +++++++ .../Config/PowerSyncSchemaSource.swift | 20 ++ .../Connections/GRDBConnecitonLease.swift | 18 ++ .../Connections/GRDBConnectionPool.swift | 115 ++++++++++++ Sources/PowerSyncGRDB/Errors.swift | 17 ++ Sources/PowerSyncGRDB/GRDBPool.swift | 171 ------------------ .../PowerSyncGRDB/SQLite/SQLite+Utils.swift | 17 ++ .../PowerSyncTransactionObserver.swift | 42 +++++ .../PowerSyncGRDB/Updates/UpdateBroker.swift | 5 + Tests/PowerSyncGRDBTests/BasicTest.swift | 5 +- 13 files changed, 369 insertions(+), 193 deletions(-) create mode 100644 Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift create mode 100644 Sources/PowerSyncGRDB/Config/PowerSyncSchemaSource.swift create mode 100644 Sources/PowerSyncGRDB/Connections/GRDBConnecitonLease.swift create mode 100644 Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift create mode 100644 Sources/PowerSyncGRDB/Errors.swift delete mode 100644 Sources/PowerSyncGRDB/GRDBPool.swift create mode 100644 Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift create mode 100644 Sources/PowerSyncGRDB/Updates/PowerSyncTransactionObserver.swift create mode 100644 Sources/PowerSyncGRDB/Updates/UpdateBroker.swift diff --git a/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift index 0d8dd28..4fd7ff0 100644 --- a/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift +++ b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift @@ -33,9 +33,7 @@ func openDatabase() .appendingPathComponent("test.sqlite") var config = Configuration() - - configurePowerSync( - config: &config, + config.configurePowerSync( schema: schema ) diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift index 5dd503e..0dd8872 100644 --- a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -1,7 +1,18 @@ 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 @@ -9,12 +20,22 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { self.pool = pool } - func getPendingUpdates() -> Set { - return pool.getPendingUpdates() + func linkUpdates(callback: any KotlinSuspendFunction1) { + updateTrackingTask = Task { + do { + for try await updates in pool.tableUpdates { + _ = try await callback.invoke(p1: updates) + } + } catch { + // none of these calls should actually throw + } + } } func __closePool() async throws { do { + updateTrackingTask?.cancel() + updateTrackingTask = nil try pool.close() } catch { try? PowerSyncKotlin.throwPowerSyncException( @@ -26,10 +47,22 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { } } - func __leaseRead(callback: @escaping (Any) -> Void) async throws { + func __leaseRead(callback: any LeaseCallback) async throws { do { - try await pool.read { pointer in - callback(UInt(bitPattern: pointer)) + var errorToThrow: Error? + try await pool.read { lease in + do { + try callback.execute( + lease: KotlinLeaseAdapter( + lease: lease + ) + ) + } catch { + errorToThrow = error + } + } + if let errorToThrow { + throw errorToThrow } } catch { try? PowerSyncKotlin.throwPowerSyncException( @@ -41,10 +74,22 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { } } - func __leaseWrite(callback: @escaping (Any) -> Void) async throws { + func __leaseWrite(callback: any LeaseCallback) async throws { do { - try await pool.write { pointer in - callback(UInt(bitPattern: pointer)) + var errorToThrow: Error? + try await pool.write { lease in + do { + try callback.execute( + lease: KotlinLeaseAdapter( + lease: lease + ) + ) + } catch { + errorToThrow = error + } + } + if let errorToThrow { + throw errorToThrow } } catch { try? PowerSyncKotlin.throwPowerSyncException( @@ -56,11 +101,16 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { } } - func __leaseAll(callback: @escaping (Any, [Any]) -> Void) async throws { + func __leaseAll(callback: any AllLeaseCallback) async throws { // TODO, actually use all connections do { - try await pool.write { pointer in - callback(UInt(bitPattern: pointer), []) + try await pool.write { lease in + try? callback.execute( + writeLease: KotlinLeaseAdapter( + lease: lease + ), + readLeases: [] + ) } } catch { try? PowerSyncKotlin.throwPowerSyncException( diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift index fb2be95..8d9123a 100644 --- a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -1,25 +1,29 @@ import Foundation +public protocol SQLiteConnectionLease { + var pointer: OpaquePointer { get } +} + /// 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. public protocol SQLiteConnectionPoolProtocol { - func getPendingUpdates() -> Set + var tableUpdates: AsyncStream> { get } /// Calls the callback with a read-only connection temporarily leased from the pool. func read( - onConnection: @Sendable @escaping (OpaquePointer) -> Void, + onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void, ) async throws /// Calls the callback with a read-write connection temporarily leased from the pool. func write( - onConnection: @Sendable @escaping (OpaquePointer) -> Void, + onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void, ) async throws /// Invokes the callback with all connections leased from the pool. func withAllConnections( onConnection: @Sendable @escaping ( - _ writer: OpaquePointer, - _ readers: [OpaquePointer] + _ writer: SQLiteConnectionLease, + _ readers: [SQLiteConnectionLease] ) -> Void, ) async throws diff --git a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift new file mode 100644 index 0000000..7990c97 --- /dev/null +++ b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift @@ -0,0 +1,62 @@ +import Foundation +import GRDB +import PowerSync +import SQLite3 + +/// Extension for GRDB `Configuration` to add PowerSync support. +/// +/// Call `configurePowerSync(schema:)` on your existing GRDB `Configuration` to: +/// - Register the PowerSync SQLite core extension (required for PowerSync features). +/// - Add PowerSync schema views to your database schema source. +/// +/// This enables PowerSync replication and view management in your GRDB database. +/// +/// Example usage: +/// ```swift +/// var config = Configuration() +/// config.configurePowerSync(schema: mySchema) +/// let dbQueue = try DatabaseQueue(path: dbPath, configuration: config) +/// ``` +/// +/// - Parameter schema: The PowerSync `Schema` describing your sync views. +public extension Configuration { + mutating func configurePowerSync( + schema: Schema + ) { + // Register the PowerSync core extension + prepareDatabase { database in + guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { + throw PowerSyncGRDBError.coreBundleNotFound + } + + // Construct the full path to the shared library inside the bundle + let fullPath = bundle.bundlePath + "/powersync-sqlite-core" + + 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, fullPath, "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 + } + } + } + + // Supply the PowerSync views as a SchemaSource + let powerSyncSchemaSource = PowerSyncSchemaSource( + schema: schema + ) + if let schemaSource = schemaSource { + self.schemaSource = schemaSource.then(powerSyncSchemaSource) + } else { + schemaSource = powerSyncSchemaSource + } + } +} diff --git a/Sources/PowerSyncGRDB/Config/PowerSyncSchemaSource.swift b/Sources/PowerSyncGRDB/Config/PowerSyncSchemaSource.swift new file mode 100644 index 0000000..af9317d --- /dev/null +++ b/Sources/PowerSyncGRDB/Config/PowerSyncSchemaSource.swift @@ -0,0 +1,20 @@ +import GRDB +import PowerSync + +/// A schema source used by GRDB to resolve primary keys for PowerSync views. +/// +/// This struct allows GRDB to identify the primary key columns for tables/views +/// defined in the PowerSync schema, enabling correct integration with GRDB's +/// database observation and record management features. +struct PowerSyncSchemaSource: DatabaseSchemaSource { + let schema: Schema + + func columnsForPrimaryKey(_: Database, inView view: DatabaseObjectID) throws -> [String]? { + if schema.tables.first(where: { table in + table.viewName == view.name + }) != nil { + return ["id"] + } + return nil + } +} diff --git a/Sources/PowerSyncGRDB/Connections/GRDBConnecitonLease.swift b/Sources/PowerSyncGRDB/Connections/GRDBConnecitonLease.swift new file mode 100644 index 0000000..7104f24 --- /dev/null +++ b/Sources/PowerSyncGRDB/Connections/GRDBConnecitonLease.swift @@ -0,0 +1,18 @@ +import Foundation +import GRDB +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, +/// allowing direct access to the underlying SQLite connection for PowerSync operations. +final class GRDBConnectionLease: SQLiteConnectionLease { + var pointer: OpaquePointer + + init(database: Database) throws { + guard let connection = database.sqliteConnection else { + throw PowerSyncGRDBError.connectionUnavailable + } + pointer = connection + } +} diff --git a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift new file mode 100644 index 0000000..aa0d34e --- /dev/null +++ b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift @@ -0,0 +1,115 @@ +import Foundation +import GRDB +import PowerSync +import SQLite3 + +/// Adapts a GRDB `DatabasePool` for use with the PowerSync SDK. +/// +/// This class implements `SQLiteConnectionPoolProtocol` and provides +/// integration between GRDB's connection pool and PowerSync's requirements, +/// including table update observation and direct access to SQLite connections. +/// +/// - Provides async streams of table updates for replication. +/// - Bridges GRDB's managed connections to PowerSync's lease abstraction. +/// - Allows both read and write access to raw SQLite connections. +public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { + let pool: DatabasePool + + public private(set) var tableUpdates: AsyncStream> + private var tableUpdatesContinuation: AsyncStream>.Continuation? + + public init( + pool: DatabasePool + ) { + self.pool = pool + // Cannot capture Self before initializing all properties + var tempContinuation: AsyncStream>.Continuation? + tableUpdates = AsyncStream { continuation in + tempContinuation = continuation + pool.add( + transactionObserver: PowerSyncTransactionObserver { updates in + // push the update + continuation.yield(updates) + }, + extent: .databaseLifetime + ) + } + tableUpdatesContinuation = tempContinuation + } + + public func read( + onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void + ) async throws { + try await pool.read { database in + try onConnection( + GRDBConnectionLease(database: database) + ) + } + } + + public func write( + onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void + ) async throws { + // Don't start an explicit transaction, we do this internally + let updateBroker = UpdateBroker() + try await pool.writeWithoutTransaction { database in + + let brokerPointer = Unmanaged.passUnretained(updateBroker).toOpaque() + + /// GRDB only registers an update hook if it detects a requirement for one. + /// It also removes its own update hook if no longer needed. + /// We use the SQLite connection pointer directly, which sidesteps GRDB. + /// We can register our own temporary update hook here. + let previousParamPointer = sqlite3_update_hook( + database.sqliteConnection, + { brokerPointer, _, _, tableNameCString, _ in + let broker = Unmanaged.fromOpaque(brokerPointer!).takeUnretainedValue() + broker.updates.insert(String(cString: tableNameCString!)) + }, + brokerPointer + ) + + // This should not be present + assert(previousParamPointer == nil, "A pre-existing update hook was already registered and has been overwritten.") + + defer { + // Deregister our temporary hook + sqlite3_update_hook(database.sqliteConnection, nil, nil) + } + + try onConnection( + GRDBConnectionLease(database: database) + ) + } + + // Notify GRDB consumers of updates + // Seems like we need to do this in a write transaction + try await pool.write { database in + for table in updateBroker.updates { + try database.notifyChanges(in: Table(table)) + if table.hasPrefix("ps_data__") { + let stripped = String(table.dropFirst("ps_data__".count)) + try database.notifyChanges(in: Table(stripped)) + } else if table.hasPrefix("ps_data_local__") { + let stripped = String(table.dropFirst("ps_data_local__".count)) + try database.notifyChanges(in: Table(stripped)) + } + } + } + guard let pushUpdates = tableUpdatesContinuation else { + return + } + // Notify the PowerSync SDK consumers of updates + pushUpdates.yield(updateBroker.updates) + } + + public func withAllConnections( + onConnection _: @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) -> Void + ) async throws { + // TODO: + } + + public func close() throws { + try pool.close() + } +} diff --git a/Sources/PowerSyncGRDB/Errors.swift b/Sources/PowerSyncGRDB/Errors.swift new file mode 100644 index 0000000..b4bd917 --- /dev/null +++ b/Sources/PowerSyncGRDB/Errors.swift @@ -0,0 +1,17 @@ +/// Errors thrown by the PowerSyncGRDB integration layer. +/// +/// These errors represent issues encountered when bridging GRDB and PowerSync, +/// such as missing extensions, failed extension loads, or unavailable connections. +public enum PowerSyncGRDBError: Error { + /// The PowerSync SQLite core bundle could not be found. + case coreBundleNotFound + + /// Failed to load the PowerSync SQLite extension, with an associated error message. + case extensionLoadFailed(String) + + /// An unknown error occurred while loading the PowerSync SQLite extension. + case unknownExtensionLoadError + + /// The underlying SQLite connection could not be obtained from GRDB. + case connectionUnavailable +} diff --git a/Sources/PowerSyncGRDB/GRDBPool.swift b/Sources/PowerSyncGRDB/GRDBPool.swift deleted file mode 100644 index 048a6c5..0000000 --- a/Sources/PowerSyncGRDB/GRDBPool.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -import GRDB -import PowerSync -import SQLite3 - -// The system SQLite does not expose this, -// linking PowerSync provides them -// Declare the missing function manually -@_silgen_name("sqlite3_enable_load_extension") -func sqlite3_enable_load_extension( - _ db: OpaquePointer?, - _ onoff: Int32 -) -> 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 - -enum PowerSyncGRDBError: Error { - case coreBundleNotFound - case extensionLoadFailed(String) - case unknownExtensionLoadError - case connectionUnavailable -} - -struct PowerSyncSchemaSource: DatabaseSchemaSource { - let schema: Schema - - func columnsForPrimaryKey(_: Database, inView view: DatabaseObjectID) throws -> [String]? { - if schema.tables.first(where: { table in - table.viewName == view.name - }) != nil { - return ["id"] - } - return nil - } -} - -public func configurePowerSync( - config: inout Configuration, - schema: Schema -) { - // Register the PowerSync core extension - config.prepareDatabase { database in - guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { - throw PowerSyncGRDBError.coreBundleNotFound - } - - // Construct the full path to the shared library inside the bundle - let fullPath = bundle.bundlePath + "/powersync-sqlite-core" - - 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, fullPath, "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 - } - } - } - - // Supply the PowerSync views as a SchemaSource - let powerSyncSchemaSource = PowerSyncSchemaSource( - schema: schema - ) - if let schemaSource = config.schemaSource { - config.schemaSource = schemaSource.then(powerSyncSchemaSource) - } else { - config.schemaSource = powerSyncSchemaSource - } -} - -final class PowerSyncTransactionObserver: TransactionObserver { - let onChange: (_ tableName: String) -> Void - - init( - onChange: @escaping (_ tableName: String) -> Void - ) { - self.onChange = onChange - } - - func observes(eventsOfKind _: DatabaseEventKind) -> Bool { - // We want all the events for the PowerSync SDK - return true - } - - func databaseDidChange(with event: DatabaseEvent) { - onChange(event.tableName) - } - - func databaseDidCommit(_: GRDB.Database) {} - - func databaseDidRollback(_: GRDB.Database) {} -} - -public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { - let pool: DatabasePool - var pendingUpdates: Set - private let pendingUpdatesQueue = DispatchQueue( - label: "co.powersync.pendingUpdatesQueue" - ) - - public init( - pool: DatabasePool - ) { - self.pool = pool - self.pendingUpdates = Set() - pool.add( - transactionObserver: PowerSyncTransactionObserver { tableName in - // push the update - self.pendingUpdatesQueue.sync { - self.pendingUpdates.insert(tableName) - } - }, - extent: .databaseLifetime - ) - } - - public func getPendingUpdates() -> Set { - self.pendingUpdatesQueue.sync { - let copy = self.pendingUpdates - self.pendingUpdates.removeAll() - return copy - } - } - - public func read( - onConnection: @Sendable @escaping (OpaquePointer) -> Void - ) async throws { - try await pool.read { database in - guard let connection = database.sqliteConnection else { - throw PowerSyncGRDBError.connectionUnavailable - } - onConnection(connection) - } - } - - public func write( - onConnection: @Sendable @escaping (OpaquePointer) -> Void - ) async throws { - // Don't start an explicit transaction - try await pool.writeWithoutTransaction { database in - guard let connection = database.sqliteConnection else { - throw PowerSyncGRDBError.connectionUnavailable - } - onConnection(connection) - } - } - - public func withAllConnections( - onConnection _: @escaping (OpaquePointer, [OpaquePointer]) -> Void - ) async throws { - // TODO: - } - - public func close() throws { - try pool.close() - } -} diff --git a/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift b/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift new file mode 100644 index 0000000..b57e81a --- /dev/null +++ b/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift @@ -0,0 +1,17 @@ +// The system SQLite does not expose this, +// linking PowerSync provides them +// Declare the missing function manually +@_silgen_name("sqlite3_enable_load_extension") +func sqlite3_enable_load_extension( + _ db: OpaquePointer?, + _ onoff: Int32 +) -> 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/Sources/PowerSyncGRDB/Updates/PowerSyncTransactionObserver.swift b/Sources/PowerSyncGRDB/Updates/PowerSyncTransactionObserver.swift new file mode 100644 index 0000000..33ed192 --- /dev/null +++ b/Sources/PowerSyncGRDB/Updates/PowerSyncTransactionObserver.swift @@ -0,0 +1,42 @@ +import GRDB + +/// Transaction observer used to track table updates made from GRDB mutations. +/// +/// This class implements `TransactionObserver` and buffers table names that are +/// changed during a transaction. After the transaction commits, it notifies +/// listeners with the set of updated tables. Used by PowerSync to observe +/// changes made through GRDB APIs. +final class PowerSyncTransactionObserver: TransactionObserver { + private var buffered: Set = [] + + let onChange: (_ tables: Set) -> Void + + init( + /// Called after a transaction has been committed, with the set of tables that changed + onChange: @escaping (_ tables: Set) -> Void + ) { + self.onChange = onChange + } + + func observes(eventsOfKind _: DatabaseEventKind) -> Bool { + // We want all the events for the PowerSync SDK + return true + } + + func databaseDidChange(with event: DatabaseEvent) { + buffered.insert(event.tableName) + } + + /// GRDB monitors statement execution in order to only + /// fire this after the commit has been executed + func databaseDidCommit(_: GRDB.Database) { + // Notify about all buffered changes + onChange(buffered) + buffered.removeAll() + } + + func databaseDidRollback(_: GRDB.Database) { + // Discard buffered changes + buffered.removeAll() + } +} diff --git a/Sources/PowerSyncGRDB/Updates/UpdateBroker.swift b/Sources/PowerSyncGRDB/Updates/UpdateBroker.swift new file mode 100644 index 0000000..6f18f98 --- /dev/null +++ b/Sources/PowerSyncGRDB/Updates/UpdateBroker.swift @@ -0,0 +1,5 @@ + +/// A temporary update broker which collects table updates during a write operation. +class UpdateBroker { + var updates: Set = [] +} diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index af63ced..aeb3fb7 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -58,8 +58,8 @@ final class GRDBTests: XCTestCase { ]) var config = Configuration() - configurePowerSync( - config: &config, + + config.configurePowerSync( schema: schema ) @@ -300,7 +300,6 @@ final class GRDBTests: XCTestCase { } for try await users in observation.values(in: pool) { - print("users \(users)") await resultsStore.append(users.map { $0.name }) if await resultsStore.count() == 2 { expectation.fulfill() From 41174b1ca4e9a6f08404dca6e1b605b372c0d0cc Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 25 Sep 2025 16:12:50 +0200 Subject: [PATCH 10/15] Use SQLite Session API for PowerSync updates. --- .../xcshareddata/swiftpm/Package.resolved | 13 ++- .../Kotlin/KotlinSQLiteConnectionPool.swift | 87 +++++++------------ .../Protocol/SQLiteConnectionPool.swift | 12 ++- .../Connections/GRDBConnectionPool.swift | 68 +++++---------- Tests/PowerSyncGRDBTests/BasicTest.swift | 12 +++ 5 files changed, 85 insertions(+), 107 deletions(-) diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 547bdd5..40d2cf3 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -10,13 +10,22 @@ "version" : "0.6.7" } }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "branch" : "dev/persistable-views", + "revision" : "3e1a711d3fedfcab2af0e52ddae03497b665e5fb" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "3396dd7eb9d4264b19e3d95bfe0d77347826f4c2", - "version" : "0.4.4" + "revision" : "00776db5157c8648671b00e6673603144fafbfeb", + "version" : "0.4.5" } }, { diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift index 0dd8872..dbed477 100644 --- a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -20,7 +20,7 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { self.pool = pool } - func linkUpdates(callback: any KotlinSuspendFunction1) { + func linkExternalUpdates(callback: any KotlinSuspendFunction1) { updateTrackingTask = Task { do { for try await updates in pool.tableUpdates { @@ -32,86 +32,63 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { } } - func __closePool() async throws { - do { + func __processPowerSyncUpdates(updates: Set) async throws { + return try await wrapExceptions { + try await pool.processPowerSyncUpdates(updates) + } + } + + func __dispose() async throws { + return try await wrapExceptions { updateTrackingTask?.cancel() updateTrackingTask = nil - try pool.close() - } catch { - try? PowerSyncKotlin.throwPowerSyncException( - exception: PowerSyncException( - message: error.localizedDescription, - cause: nil - ) - ) } } func __leaseRead(callback: any LeaseCallback) async throws { - do { - var errorToThrow: Error? + return try await wrapExceptions { try await pool.read { lease in - do { - try callback.execute( - lease: KotlinLeaseAdapter( - lease: lease - ) + try callback.execute( + lease: KotlinLeaseAdapter( + lease: lease ) - } catch { - errorToThrow = error - } - } - if let errorToThrow { - throw errorToThrow - } - } catch { - try? PowerSyncKotlin.throwPowerSyncException( - exception: PowerSyncException( - message: error.localizedDescription, - cause: nil ) - ) + } } } func __leaseWrite(callback: any LeaseCallback) async throws { - do { - var errorToThrow: Error? + return try await wrapExceptions { try await pool.write { lease in - do { - try callback.execute( - lease: KotlinLeaseAdapter( - lease: lease - ) + try callback.execute( + lease: KotlinLeaseAdapter( + lease: lease ) - } catch { - errorToThrow = error - } - } - if let errorToThrow { - throw errorToThrow - } - } catch { - try? PowerSyncKotlin.throwPowerSyncException( - exception: PowerSyncException( - message: error.localizedDescription, - cause: nil ) - ) + } } } func __leaseAll(callback: any AllLeaseCallback) async throws { - // TODO, actually use all connections - do { + // FIXME, actually use all connections + // We currently only use this for schema updates + return try await wrapExceptions { try await pool.write { lease in - try? callback.execute( + try callback.execute( writeLease: KotlinLeaseAdapter( lease: lease ), readLeases: [] ) } + } + } + + private func wrapExceptions( + _ callback: () async throws -> Result + ) async throws -> Result { + do { + return try await callback() } catch { try? PowerSyncKotlin.throwPowerSyncException( exception: PowerSyncException( @@ -119,6 +96,8 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { cause: nil ) ) + // Won't reach here + throw error } } } diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift index 8d9123a..6f3111b 100644 --- a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -1,6 +1,8 @@ import Foundation +/// A lease representing a temporarily borrowed SQLite connection from the pool. public protocol SQLiteConnectionLease { + /// Pointer to the underlying SQLite connection. var pointer: OpaquePointer { get } } @@ -9,14 +11,18 @@ public protocol SQLiteConnectionLease { public protocol SQLiteConnectionPoolProtocol { var tableUpdates: AsyncStream> { get } + /// Processes updates from PowerSync, notifying any active leases of changes + /// (made by PowerSync) to tracked tables. + func processPowerSyncUpdates(_ updates: Set) async throws + /// Calls the callback with a read-only connection temporarily leased from the pool. func read( - onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void, + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void, ) async throws /// Calls the callback with a read-write connection temporarily leased from the pool. func write( - onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void, + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void, ) async throws /// Invokes the callback with all connections leased from the pool. @@ -24,7 +30,7 @@ public protocol SQLiteConnectionPoolProtocol { onConnection: @Sendable @escaping ( _ writer: SQLiteConnectionLease, _ readers: [SQLiteConnectionLease] - ) -> Void, + ) throws -> Void, ) async throws /// Closes the connection pool and associated resources. diff --git a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift index aa0d34e..b63eaab 100644 --- a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift +++ b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift @@ -37,8 +37,25 @@ public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { tableUpdatesContinuation = tempContinuation } + public func processPowerSyncUpdates(_ updates: Set) async throws { + try await pool.write { database in + for table in updates { + try database.notifyChanges(in: Table(table)) + if table.hasPrefix("ps_data__") { + let stripped = String(table.dropFirst("ps_data__".count)) + try database.notifyChanges(in: Table(stripped)) + } else if table.hasPrefix("ps_data_local__") { + let stripped = String(table.dropFirst("ps_data_local__".count)) + try database.notifyChanges(in: Table(stripped)) + } + } + } + // Pass the updates to the output stream + tableUpdatesContinuation?.yield(updates) + } + public func read( - onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void ) async throws { try await pool.read { database in try onConnection( @@ -48,63 +65,18 @@ public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { } public func write( - onConnection: @Sendable @escaping (SQLiteConnectionLease) -> Void + onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void ) async throws { // Don't start an explicit transaction, we do this internally - let updateBroker = UpdateBroker() try await pool.writeWithoutTransaction { database in - - let brokerPointer = Unmanaged.passUnretained(updateBroker).toOpaque() - - /// GRDB only registers an update hook if it detects a requirement for one. - /// It also removes its own update hook if no longer needed. - /// We use the SQLite connection pointer directly, which sidesteps GRDB. - /// We can register our own temporary update hook here. - let previousParamPointer = sqlite3_update_hook( - database.sqliteConnection, - { brokerPointer, _, _, tableNameCString, _ in - let broker = Unmanaged.fromOpaque(brokerPointer!).takeUnretainedValue() - broker.updates.insert(String(cString: tableNameCString!)) - }, - brokerPointer - ) - - // This should not be present - assert(previousParamPointer == nil, "A pre-existing update hook was already registered and has been overwritten.") - - defer { - // Deregister our temporary hook - sqlite3_update_hook(database.sqliteConnection, nil, nil) - } - try onConnection( GRDBConnectionLease(database: database) ) } - - // Notify GRDB consumers of updates - // Seems like we need to do this in a write transaction - try await pool.write { database in - for table in updateBroker.updates { - try database.notifyChanges(in: Table(table)) - if table.hasPrefix("ps_data__") { - let stripped = String(table.dropFirst("ps_data__".count)) - try database.notifyChanges(in: Table(stripped)) - } else if table.hasPrefix("ps_data_local__") { - let stripped = String(table.dropFirst("ps_data_local__".count)) - try database.notifyChanges(in: Table(stripped)) - } - } - } - guard let pushUpdates = tableUpdatesContinuation else { - return - } - // Notify the PowerSync SDK consumers of updates - pushUpdates.yield(updateBroker.updates) } public func withAllConnections( - onConnection _: @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) -> Void + onConnection _: @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) throws -> Void ) async throws { // TODO: } diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index aeb3fb7..9eeb902 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -321,6 +321,18 @@ final class GRDBTests: XCTestCase { watchTask.cancel() } + func testShouldThrowErrorsFromPowerSync() async throws { + do { + try await database.execute( + sql: "INSERT INTO non_existent_table(id, name) VALUES(uuid(), ?)", + parameters: ["one"] + ) + XCTFail("Should throw error") + } catch { + XCTAssertTrue(error.localizedDescription.contains("non_existent_table")) // Expected + } + } + func testGRDBUpdatesFromGRDB() async throws { let expectation = XCTestExpectation(description: "Watch changes") From 7aae6cfbdb4b05ab8062807eafd8c9c4ce3b1cbe Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 25 Sep 2025 16:50:36 +0200 Subject: [PATCH 11/15] Update GRDB dependency --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- Package.resolved | 4 ++-- Package.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 53d7464..44f11e5 100644 --- a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift.git", "state" : { - "branch" : "dev/persistable-views", - "revision" : "3e1a711d3fedfcab2af0e52ddae03497b665e5fb" + "revision" : "c5d02eac3241dd980fa42e5644afd2e7e3f63401", + "version" : "7.7.0" } }, { diff --git a/Package.resolved b/Package.resolved index 89dbb6f..7de7930 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift.git", "state" : { - "branch" : "dev/persistable-views", - "revision" : "63d92eab609bf230feb6f8d2c695a7e510519530" + "revision" : "c5d02eac3241dd980fa42e5644afd2e7e3f63401", + "version" : "7.7.0" } }, { diff --git a/Package.swift b/Package.swift index 8e04a35..459fcb9 100644 --- a/Package.swift +++ b/Package.swift @@ -78,7 +78,7 @@ let package = Package( ) ], dependencies: conditionalDependencies + [ - .package(url: "https://github.com/groue/GRDB.swift.git", branch: "dev/persistable-views") + .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.7.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. From 76aeb1c358cbe34a42a5d0fc3bba3d7f5993419c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 25 Sep 2025 16:51:11 +0200 Subject: [PATCH 12/15] demo update --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 44f11e5..a9e2dc7 100644 --- a/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/GRDB Demo/GRDB Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "state" : { - "revision" : "00776db5157c8648671b00e6673603144fafbfeb", - "version" : "0.4.5" + "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", + "version" : "0.4.6" } }, { From 281558a2de0860d0b89c3d610e806f5d8bd5b66f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 29 Sep 2025 14:48:19 +0200 Subject: [PATCH 13/15] Update README. Cleanup public APIs. WIP WatchOS. --- README.md | 75 ++++++++++++----- .../Kotlin/KotlinSQLiteConnectionPool.swift | 6 -- .../PowerSync/Kotlin/kotlinWithSession.swift | 37 +++++++++ .../Protocol/SQLiteConnectionPool.swift | 4 - Sources/PowerSync/Utils/withSession.swift | 41 ++++++++++ .../Config/Configuration+PowerSync.swift | 81 ++++++++++--------- .../Connections/GRDBConnectionPool.swift | 34 +++++--- .../PowerSyncGRDB/GRDBPowerSyncDatabase.swift | 54 +++++++++++++ .../PowerSyncGRDB/Updates/UpdateBroker.swift | 5 -- Tests/PowerSyncGRDBTests/BasicTest.swift | 6 +- 10 files changed, 256 insertions(+), 87 deletions(-) create mode 100644 Sources/PowerSync/Kotlin/kotlinWithSession.swift create mode 100644 Sources/PowerSync/Utils/withSession.swift create mode 100644 Sources/PowerSyncGRDB/GRDBPowerSyncDatabase.swift delete mode 100644 Sources/PowerSyncGRDB/Updates/UpdateBroker.swift diff --git a/README.md b/README.md index 58423e1..8ee0476 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ _[PowerSync](https://www.powersync.com) is a sync engine for building local-firs This is the PowerSync SDK for Swift clients. The SDK reference is available [here](https://docs.powersync.com/client-sdk-references/swift), API references are [documented here](https://powersync-ja.github.io/powersync-swift/documentation/powersync/). -## Structure: Packages +## Available Products -- [Sources](./Sources/) +The SDK provides two main products: - - This is the Swift SDK implementation. +- **PowerSync**: Core SDK with SQLite support for data synchronization. +- **PowerSyncDynamic**: Forced dynamically linked version of `PowerSync` - useful for XCode previews. +- **PowerSyncGRDB [ALPHA]**: GRDB integration allowing PowerSync to work with GRDB databases. This product is currently in an alpha release. ## Demo Apps / Example Projects @@ -38,6 +40,11 @@ Add name: "PowerSync", package: "powersync-swift" ), + // Optional: Add if using GRDB + .product( + name: "PowerSyncGRDB", + package: "powersync-swift" + ) ] ) ] @@ -47,29 +54,59 @@ to your `Package.swift` file. ## Usage -Create a PowerSync client +### Basic PowerSync Setup ```swift import PowerSync -let powersync = PowerSyncDatabase( - schema: Schema( - tables: [ - Table( - name: "users", - columns: [ - .text("count"), - .integer("is_active"), - .real("weight"), - .text("description") - ] - ) - ] - ), +let mySchema = Schema( + tables: [ + Table( + name: "users", + columns: [ + .text("count"), + .integer("is_active"), + .real("weight"), + .text("description") + ] + ) + ] +) + +let powerSync = PowerSyncDatabase( + schema: mySchema, logger: DefaultLogger(minSeverity: .debug) ) ``` +### GRDB Integration + +If you're using [GRDB.swift](https://github.com/groue/GRDB.swift) by [Gwendal Roué](https://github.com/groue), you can integrate PowerSync with your existing database. Special thanks to Gwendal for their help in developing this integration. + +**⚠️ Note:** The GRDB integration is currently in **alpha** release and the API may change significantly. While functional, it should be used with caution in production environments. + +```swift +import PowerSync +import PowerSyncGRDB +import GRDB + +// Configure GRDB with PowerSync support +var config = Configuration() +config.configurePowerSync(schema: mySchema) + +// Create database with PowerSync enabled +let dbPool = try DatabasePool( + path: dbPath, + configuration: config +) + +let powerSync = try openPowerSyncWithGRDB( + pool: dbPool, + schema: mySchema, + identifier: "app-db" +) +``` + ## Underlying Kotlin Dependency The PowerSync Swift SDK makes use of the [PowerSync Kotlin Multiplatform 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. @@ -89,4 +126,4 @@ XCode previews can be enabled by either: Enabling `Editor -> Canvas -> Use Legacy Previews Execution` in XCode. -Or adding the `PowerSyncDynamic` product when adding PowerSync to your project. This product will assert that PowerSync should be dynamically linked, which restores XCode previews. \ No newline at end of file +Or adding the `PowerSyncDynamic` product when adding PowerSync to your project. This product will assert that PowerSync should be dynamically linked, which restores XCode previews. diff --git a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift index dbed477..ba315fc 100644 --- a/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift +++ b/Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift @@ -32,12 +32,6 @@ final class SwiftSQLiteConnectionPoolAdapter: PowerSyncKotlin.SwiftPoolAdapter { } } - func __processPowerSyncUpdates(updates: Set) async throws { - return try await wrapExceptions { - try await pool.processPowerSyncUpdates(updates) - } - } - func __dispose() async throws { return try await wrapExceptions { updateTrackingTask?.cancel() diff --git a/Sources/PowerSync/Kotlin/kotlinWithSession.swift b/Sources/PowerSync/Kotlin/kotlinWithSession.swift new file mode 100644 index 0000000..dbd9873 --- /dev/null +++ b/Sources/PowerSync/Kotlin/kotlinWithSession.swift @@ -0,0 +1,37 @@ +import PowerSyncKotlin + +func kotlinWithSession( + db: OpaquePointer, + action: @escaping () throws -> ReturnType, + onComplete: @escaping (Result, Set) -> Void, +) throws { + try withSession( + db: UnsafeMutableRawPointer(db), + onComplete: { powerSyncResult, updates in + let result: Result + switch powerSyncResult { + case let success as PowerSyncResult.Success: + do { + let casted = try safeCast(success.value, to: ReturnType.self) + result = .success(casted) + } catch { + result = .failure(error) + } + + case let failure as PowerSyncResult.Failure: + result = .failure(failure.exception.asError()) + + default: + result = .failure(PowerSyncError.operationFailed(message: "Unknown error encountered when processing session")) + } + onComplete(result, updates) + }, + block: { + do { + return try PowerSyncResult.Success(value: action()) + } catch { + return PowerSyncResult.Failure(exception: error.toPowerSyncError()) + } + } + ) +} diff --git a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift index 6f3111b..5e64688 100644 --- a/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift +++ b/Sources/PowerSync/Protocol/SQLiteConnectionPool.swift @@ -11,10 +11,6 @@ public protocol SQLiteConnectionLease { public protocol SQLiteConnectionPoolProtocol { var tableUpdates: AsyncStream> { get } - /// Processes updates from PowerSync, notifying any active leases of changes - /// (made by PowerSync) to tracked tables. - func processPowerSyncUpdates(_ updates: Set) async throws - /// Calls the callback with a read-only connection temporarily leased from the pool. func read( onConnection: @Sendable @escaping (SQLiteConnectionLease) throws -> Void, diff --git a/Sources/PowerSync/Utils/withSession.swift b/Sources/PowerSync/Utils/withSession.swift new file mode 100644 index 0000000..9a565b6 --- /dev/null +++ b/Sources/PowerSync/Utils/withSession.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Executes an action within a SQLite database connection session and handles its result. +/// +/// The Raw SQLite connection is only available in some niche scenarios. +/// +/// - Executes the provided action in a SQLite session +/// - Handles success/failure results +/// - Tracks table updates during execution +/// - Provides type-safe result handling +/// +/// Example usage: +/// ```swift +/// try withSession(db: database) { +/// return try someOperation() +/// } onComplete: { result, updates in +/// switch result { +/// case .success(let value): +/// print("Operation succeeded with: \(value)") +/// case .failure(let error): +/// print("Operation failed: \(error)") +/// } +/// } +/// ``` +/// +/// - Parameters: +/// - db: The database connection pointer +/// - action: The operation to execute within the session +/// - onComplete: Callback that receives the operation result and set of updated tables +/// - Throws: Errors from session initialization or execution +public func withSession( + db: OpaquePointer, + action: @escaping () throws -> ReturnType, + onComplete: @escaping (Result, Set) -> Void, +) throws { + return try kotlinWithSession( + db: db, + action: action, + onComplete: onComplete, + ) +} diff --git a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift index 7990c97..0d7249a 100644 --- a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift +++ b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift @@ -3,50 +3,59 @@ import GRDB import PowerSync import SQLite3 -/// Extension for GRDB `Configuration` to add PowerSync support. -/// -/// Call `configurePowerSync(schema:)` on your existing GRDB `Configuration` to: -/// - Register the PowerSync SQLite core extension (required for PowerSync features). -/// - Add PowerSync schema views to your database schema source. -/// -/// This enables PowerSync replication and view management in your GRDB database. -/// -/// Example usage: -/// ```swift -/// var config = Configuration() -/// config.configurePowerSync(schema: mySchema) -/// let dbQueue = try DatabaseQueue(path: dbPath, configuration: config) -/// ``` -/// -/// - Parameter schema: The PowerSync `Schema` describing your sync views. public extension Configuration { + /// Configures GRDB to work with PowerSync by registering required extensions and schema sources. + /// + /// Call this method on your existing GRDB `Configuration` to: + /// - Register the PowerSync SQLite core extension (required for PowerSync features). + /// - Add PowerSync schema views to your database schema source. + /// + /// This enables PowerSync replication and view management in your GRDB database. + /// + /// Example usage: + /// ```swift + /// var config = Configuration() + /// config.configurePowerSync(schema: mySchema) + /// let dbQueue = try DatabaseQueue(path: dbPath, configuration: config) + /// ``` + /// + /// - Parameter schema: The PowerSync `Schema` describing your sync views. mutating func configurePowerSync( schema: Schema ) { // Register the PowerSync core extension prepareDatabase { database in - guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { - throw PowerSyncGRDBError.coreBundleNotFound - } + #if os(watchOS) + // Use static initialization on watchOS + let initResult = sqlite3_powersync_init_static(database.sqliteConnection, nil) + if initResult != SQLITE_OK { + throw PowerSyncGRDBError.extensionLoadFailed("Could not initialize PowerSync statically") + } + #else + // Dynamic loading on other platforms + guard let bundle = Bundle(identifier: "co.powersync.sqlitecore") else { + throw PowerSyncGRDBError.coreBundleNotFound + } - // Construct the full path to the shared library inside the bundle - let fullPath = bundle.bundlePath + "/powersync-sqlite-core" + // Construct the full path to the shared library inside the bundle + let fullPath = bundle.bundlePath + "/powersync-sqlite-core" - 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, fullPath, "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 + 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, fullPath, "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 + } } - } + #endif } // Supply the PowerSync views as a SchemaSource @@ -54,7 +63,7 @@ public extension Configuration { schema: schema ) if let schemaSource = schemaSource { - self.schemaSource = schemaSource.then(powerSyncSchemaSource) + self.schemaSource = powerSyncSchemaSource.then(schemaSource) } else { schemaSource = powerSyncSchemaSource } diff --git a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift index b63eaab..c8c8874 100644 --- a/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift +++ b/Sources/PowerSyncGRDB/Connections/GRDBConnectionPool.swift @@ -12,7 +12,7 @@ import SQLite3 /// - Provides async streams of table updates for replication. /// - Bridges GRDB's managed connections to PowerSync's lease abstraction. /// - Allows both read and write access to raw SQLite connections. -public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { +final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { let pool: DatabasePool public private(set) var tableUpdates: AsyncStream> @@ -41,13 +41,6 @@ public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { try await pool.write { database in for table in updates { try database.notifyChanges(in: Table(table)) - if table.hasPrefix("ps_data__") { - let stripped = String(table.dropFirst("ps_data__".count)) - try database.notifyChanges(in: Table(stripped)) - } else if table.hasPrefix("ps_data_local__") { - let stripped = String(table.dropFirst("ps_data_local__".count)) - try database.notifyChanges(in: Table(stripped)) - } } } // Pass the updates to the output stream @@ -69,16 +62,31 @@ public final class GRDBConnectionPool: SQLiteConnectionPoolProtocol { ) async throws { // Don't start an explicit transaction, we do this internally try await pool.writeWithoutTransaction { database in - try onConnection( - GRDBConnectionLease(database: database) - ) + guard let pointer = database.sqliteConnection else { + throw PowerSyncGRDBError.connectionUnavailable + } + + try withSession( + db: pointer, + ) { + try onConnection( + GRDBConnectionLease(database: database) + ) + } onComplete: { _, changes in + self.tableUpdatesContinuation?.yield(changes) + } } } public func withAllConnections( - onConnection _: @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) throws -> Void + onConnection: @Sendable @escaping (SQLiteConnectionLease, [SQLiteConnectionLease]) throws -> Void ) async throws { - // TODO: + // FIXME, we currently don't support updating the schema + try await pool.write { database in + let lease = try GRDBConnectionLease(database: database) + try onConnection(lease, []) + } + pool.invalidateReadOnlyConnections() } public func close() throws { diff --git a/Sources/PowerSyncGRDB/GRDBPowerSyncDatabase.swift b/Sources/PowerSyncGRDB/GRDBPowerSyncDatabase.swift new file mode 100644 index 0000000..3d92967 --- /dev/null +++ b/Sources/PowerSyncGRDB/GRDBPowerSyncDatabase.swift @@ -0,0 +1,54 @@ +import GRDB +import PowerSync + +/// Creates a PowerSync database instance that integrates with an existing GRDB database pool. +/// +/// Use this function to initialize PowerSync with a GRDB database: +/// ```swift +/// // Define your PowerSync schema +/// let schema = Schema( +/// tables: [ +/// Table( +/// name: "users", +/// columns: [ +/// .text("name"), +/// .integer("age"), +/// .text("email") +/// ] +/// ) +/// ] +/// ) +/// +/// // Configure GRDB with PowerSync support +/// var config = Configuration() +/// config.configurePowerSync(schema: schema) +/// +/// // Create the database pool +/// let dbPool = try DatabasePool(path: "path/to/db", configuration: config) +/// +/// // Initialize PowerSync with GRDB +/// let powerSync = try openPowerSyncWithGRDB( +/// pool: dbPool, +/// schema: schema, +/// identifier: "app-db" +/// ) +/// ``` +/// +/// - Parameters: +/// - pool: The GRDB DatabasePool instance to use for storage +/// - schema: The PowerSync schema describing your sync views +/// - identifier: A unique identifier for this database instance +/// - Returns: A PowerSync database that works with the provided GRDB pool +public func openPowerSyncWithGRDB( + pool: DatabasePool, + schema: Schema, + identifier: String +) -> PowerSyncDatabaseProtocol { + return OpenedPowerSyncDatabase( + schema: schema, + pool: GRDBConnectionPool( + pool: pool + ), + identifier: identifier + ) +} diff --git a/Sources/PowerSyncGRDB/Updates/UpdateBroker.swift b/Sources/PowerSyncGRDB/Updates/UpdateBroker.swift deleted file mode 100644 index 6f18f98..0000000 --- a/Sources/PowerSyncGRDB/Updates/UpdateBroker.swift +++ /dev/null @@ -1,5 +0,0 @@ - -/// A temporary update broker which collects table updates during a write operation. -class UpdateBroker { - var updates: Set = [] -} diff --git a/Tests/PowerSyncGRDBTests/BasicTest.swift b/Tests/PowerSyncGRDBTests/BasicTest.swift index 9eeb902..03505d7 100644 --- a/Tests/PowerSyncGRDBTests/BasicTest.swift +++ b/Tests/PowerSyncGRDBTests/BasicTest.swift @@ -70,11 +70,9 @@ final class GRDBTests: XCTestCase { configuration: config ) - database = OpenedPowerSyncDatabase( + database = openPowerSyncWithGRDB( + pool: pool, schema: schema, - pool: GRDBConnectionPool( - pool: pool - ), identifier: "test" ) From a81986eadb87349b02078e421d8cb3369f6d37b1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 3 Oct 2025 15:29:00 +0200 Subject: [PATCH 14/15] Update READMEs --- CHANGELOG.md | 1 + Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift | 6 +-- Demo/GRDB Demo/GRDB Demo/README.md | 50 +++++++++++++++++++++ Package.swift | 2 +- README.md | 11 ++++- 5 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 Demo/GRDB Demo/GRDB Demo/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a8370..43ccf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.6.1 (unreleased) * Update Kotlin SDK to 1.7.0. +* Added Alpha `PowerSyncGRDB` product which supports sharing GRDB `DatabasePool`s with PowerSync and application logic. ## 1.6.0 diff --git a/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift index 4fd7ff0..e4e2bcb 100644 --- a/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift +++ b/Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift @@ -44,11 +44,9 @@ func openDatabase() fatalError("Could not open database") } - let powerSync = OpenedPowerSyncDatabase( + let powerSync = openPowerSyncWithGRDB( + pool: grdb, schema: schema, - pool: GRDBConnectionPool( - pool: grdb - ), identifier: "test" ) diff --git a/Demo/GRDB Demo/GRDB Demo/README.md b/Demo/GRDB Demo/GRDB Demo/README.md new file mode 100644 index 0000000..bd57df0 --- /dev/null +++ b/Demo/GRDB Demo/GRDB Demo/README.md @@ -0,0 +1,50 @@ +# PowerSync Swift GRDB Demo App + +A Todo List app demonstrating the use of the PowerSync Swift SDK with GRDB and Supabase. + +## Set up your Supabase and PowerSync projects + +To run this demo, you need Supabase and PowerSync projects. Detailed instructions for integrating PowerSync with Supabase can be found in [the integration guide](https://docs.powersync.com/integration-guides/supabase). + +Follow this guide to: + +1. Create and configure a Supabase project. +2. Create a new PowerSync instance, connecting to the database of the Supabase project. See instructions [here](https://docs.powersync.com/integration-guides/supabase-+-powersync#connect-powersync-to-your-supabase). +3. Deploy sync rules. + +## Configure The App + +1. Open this directory in XCode. + +2. Copy the `_Secrets.swift` file to a new `Secrets.swift` file and insert the credentials of your Supabase and PowerSync projects (more info can be found [here](https://docs.powersync.com/integration-guides/supabase-+-powersync#test-everything-using-our-demo-app)). + +```bash +cp _Secrets.swift Secrets.swift +``` + +### GRDB Implementation Details + +This demo uses GRDB.swift for local data storage and querying. The key differences from the standard PowerSync demo are: + +1. Queries and mutations are handled using GRDB's data access patterns +2. Observable database queries are implemented using GRDB's ValueObservation + +### Troubleshooting + +If you run into build issues, try: + +1. Clear Swift caches + +```bash +rm -rf ~/Library/Caches/org.swift.swiftpm +rm -rf ~/Library/org.swift.swiftpm +``` + +2. In Xcode: + +- Reset Packages: File -> Packages -> Reset Package Caches +- Clean Build: Product -> Clean Build Folder. + +## Run project + +Build the project, launch the app and sign in or register a new user. The app demonstrates real-time synchronization of todo lists between multiple devices and the cloud, powered by PowerSync's offline-first architecture and GRDB's robust local database capabilities. diff --git a/Package.swift b/Package.swift index 769a2aa..316de00 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let packageName = "PowerSync" // Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin // build. Also see docs/LocalBuild.md for details -let localKotlinSdkOverride: String? = "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin" +let localKotlinSdkOverride: String? = "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin/internal" // Set this to the absolute path of your powersync-sqlite-core checkout if you want to use a // local build of the core extension. diff --git a/README.md b/README.md index 8ee0476..54feb27 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ The easiest way to test the PowerSync Swift SDK is to run our demo application. - [Demo/PowerSyncExample](./Demo/README.md): A simple to-do list application demonstrating the use of the PowerSync Swift SDK using a Supabase connector. +- [Demo/GRDB Demo](./Demo/README.md): A simple to-do list application demonstrating the use of the PowerSync Swift SDK using a Supabase connector and GRDB connections. + ## Installation Add @@ -103,10 +105,17 @@ let dbPool = try DatabasePool( let powerSync = try openPowerSyncWithGRDB( pool: dbPool, schema: mySchema, - identifier: "app-db" + identifier: "app-db.sqlite" ) ``` +Feel free to use the `DatabasePool` for view logic and the `PowerSyncDatabase` for PowerSync operations. + +#### Limitations + +- Updating the PowerSync schema, with `updateSchema`, is not currently fully supported with GRDB connections. +- This integration requires currently statically linking PowerSync and GRDB. + ## Underlying Kotlin Dependency The PowerSync Swift SDK makes use of the [PowerSync Kotlin Multiplatform 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. From 1c1f2bb4c9ad73118e748c074bb6f821fdd484ad Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 3 Oct 2025 16:00:28 +0200 Subject: [PATCH 15/15] Register extension on WatchOS --- Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift | 2 +- Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift index 0d7249a..ec92093 100644 --- a/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift +++ b/Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift @@ -27,7 +27,7 @@ public extension Configuration { prepareDatabase { database in #if os(watchOS) // Use static initialization on watchOS - let initResult = sqlite3_powersync_init_static(database.sqliteConnection, nil) + let initResult = sqlite3_powersync_init(database.sqliteConnection, nil, nil) if initResult != SQLITE_OK { throw PowerSyncGRDBError.extensionLoadFailed("Could not initialize PowerSync statically") } diff --git a/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift b/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift index b57e81a..971d6cf 100644 --- a/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift +++ b/Sources/PowerSyncGRDB/SQLite/SQLite+Utils.swift @@ -7,6 +7,13 @@ func sqlite3_enable_load_extension( _ 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(