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
20 changes: 20 additions & 0 deletions Demo/GRDB Demo/GRDB Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions Demo/GRDB Demo/GRDB Demo/Data/Todo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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?
Expand All @@ -25,15 +25,15 @@ 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"
}

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)
Expand Down
4 changes: 1 addition & 3 deletions Demo/GRDB Demo/GRDB Demo/GRDB_DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ func openDatabase()
.appendingPathComponent("test.sqlite")

var config = Configuration()

configurePowerSync(
config: &config,
config.configurePowerSync(
schema: schema
)

Expand Down
4 changes: 2 additions & 2 deletions Demo/GRDB Demo/GRDB Demo/Models/TodosViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
32 changes: 20 additions & 12 deletions Demo/GRDB Demo/GRDB Demo/Screens/StatusIndicatorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,27 @@ struct StatusIndicatorView<Content: View>: 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 {
Expand All @@ -43,13 +50,6 @@ struct StatusIndicatorView<Content: View>: 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() {
Expand All @@ -62,6 +62,14 @@ struct StatusIndicatorView<Content: View>: 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")
Expand Down
2 changes: 2 additions & 0 deletions Demo/GRDB Demo/GRDB Demo/Screens/signin/SigninScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Demo/GRDB Demo/GRDB Demo/Screens/todos/TodoItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 61 additions & 11 deletions Sources/PowerSync/Kotlin/KotlinSQLiteConnectionPool.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
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<Void, Never>?

init(
pool: SQLiteConnectionPoolProtocol
) {
self.pool = pool
}

func getPendingUpdates() -> Set<String> {
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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
14 changes: 9 additions & 5 deletions Sources/PowerSync/Protocol/SQLiteConnectionPool.swift
Original file line number Diff line number Diff line change
@@ -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<String>
var tableUpdates: AsyncStream<Set<String>> { 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

Expand Down
62 changes: 62 additions & 0 deletions Sources/PowerSyncGRDB/Config/Configuration+PowerSync.swift
Original file line number Diff line number Diff line change
@@ -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<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 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)
Copy link

@groue groue Sep 23, 2025

Choose a reason for hiding this comment

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

🙂 This makes me happy when a feature is used as intended.

I would reverse the chain, though: powerSyncSchemaSource.then(schemaSource). You can, and should, do that because PowerSyncSchemaSource is well-behaved: it returns nil for views it does not own.

The current code runs the current schema source before powerSyncSchemaSource. This is fragile, because this only works if the current schema source is well-behaved. Unfortunately, you do not control it. You can write in your documentation that PowerSync requires a well-behaved schema source... But why bother, when you can chain in the opposite direction, and avoid all problems.

That's how then rewards the well-behaved schema sources ;-)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you! That's a good point. We'll update the order of the chain :)

} else {
schemaSource = powerSyncSchemaSource
}
}
}
Loading
Loading