Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
)
44 changes: 35 additions & 9 deletions Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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(UInt(bitPattern: 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(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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @stevensJourney - it looks like you're about to need a way to iterate all available connections.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi!, yes, in order to completely satisfy our internal driver requirements, we would require this.

I haven't full scanned through the GRDB docs yet, but I haven't seen a way yet to achieve this. Is this currently possible with GRDB?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, not yet, that would be a new feature. We'd have to clarify the exact required semantics.

In particular, I wonder if the abstract pool you have to conform to is assumed to run a fixed set of available connections, or if it is allowed to close existing connections and open new ones.

  • If it is assumed to run a fixed set of available connections, then leaseAll makes sure that all future database access are impacted by the effects of the callback.
  • If the pool can close and open connections at will, then it is possible for a future database access to run in a connection that did not run the callback.

To lift the ambiguity, the semantics of leaseAll need to be clarified.

There is another related clarification that is needed: can concurrent database accesses run during the execution of leaseAll, or not?

Maybe there are other constraints that I'm not aware of. In all cases, make sure you make the semantics crystal clear.

Copy link

@groue groue Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, GRDB has a prepareDatabase callback that executes code in any connection that opens, before it is made available to the rest of the application. This is where GRDB users register functions, collations, and make general connection setup:

var config = Configuration()
config.prepareDatabase { db in
    // Setup stuff on demand
}
let dbPool = try DatabasePool(path: "...", configuration.config)
try dbPool.read { db in // or dbPool.write
    // Setup has run on that connection, guaranteed
}

If the only goal of leaseAll is to execute database code early, then I would suggest replacing it with a similar pattern in the PowerSync pool protocol. This should be possible in all target languages that have a notion of closure that can be executed later. And this would void all the complex semantics questions I have asked above.

Copy link

@groue groue Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally, if the goal of leaseAll is to call sqlite3_db_release_memory(), then DatabasePool has a ready-made releaseMemory() method.

In summary, I strongly suggest clarifying the intent behind leaseAll, so that we avoid the XY problem (and leaseAll has a big XY smell).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed responses and queries.

The current implementation of leaseAll in this SDK assumes a lock is taken on all the current SQLite connections. The pool can be fixed or dynamically sized - the only requirement is that the lock be taken at the time of and during the request.

prepareDatabase is a good solution for executing code when connections are opened, but this does not align with the current requirements of leaseAll.

You are very correct about the XY Problem scenario described. For context, we allow users to update the PowerSync schema after the client has been initialised. E.g. This is triggered here, We currently apply the change using a write connection and thereafter refresh the schema on the read connections - preventing any reads during this operation: which might be invalid.
If are any alternatives for ensuring the Schema is refreshed on read connections, I think we could avoid requiring this functionality for now.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, thanks for the clarification 👍 When you're on the leaseAll implementation, please open a new discussion in the GRDB repo. All the building blocks are there, we just need to design a public API.

callback(UInt(bitPattern: 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)
)
}
}
17 changes: 15 additions & 2 deletions Sources/PowerSync/PowerSyncDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
26 changes: 26 additions & 0 deletions Sources/PowerSync/Protocol/SQLiteConnectionPool.swift
Original file line number Diff line number Diff line change
@@ -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: @Sendable @escaping (
_ writer: OpaquePointer,
_ readers: [OpaquePointer]
) -> Void,
) async throws

/// Closes the connection pool and associated resources.
func close() throws
}
90 changes: 90 additions & 0 deletions Sources/PowerSyncGRDB/GRDBPool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<Int8>?, _ procName: UnsafePointer<Int8>?, _ errMsg: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>?) -> 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think on watchOS we would have to call the powersync_init_static() C function instead, since everything is linked statically there.

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<Int8>?
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 {
// Don't start an explicit transaction
try await pool.writeWithoutTransaction { 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()
}
}
Loading
Loading