Synced SQLite For All Platforms
PowerSync is a powerful alternative to traditional approaches of data provisioning and state management which can be complex or boilerplate-heavy. A configured PowerSync service ensures that relevant and authorized data is synced to clients, providing an automatically synced local data store via SQLite.
Our goal is to bring these benefits to as many development environments as possible by offering high-quality SDKs.
This post covers the journey of building our Swift SDK, explaining some of the design decisions behind it.
Prior Art: JS SDK
When I joined the PowerSync SDK journey, the Flutter/Dart SDK was the only stable SDK available. My goal was to bring the magic of PowerSync to the JavaScript/TypeScript ecosystem. Given the variety of environments and frameworks in the JS world, any viable solution needed to be flexible. Building a flexible system often calls for a well-structured coding methodology and good architectural practices.
I prefer to build systems that are modular, extensible, and reusable. A good system, in my view, is one that anticipates the unknown and makes navigating uncertainty more manageable and even pleasant. With that mindset, I set out to adapt the Dart SDK into a shared JavaScript implementation. Initially, the goal was to target React Native, but the architecture needed to support future extensions for Web and Node.js.
Over time, this modular approach proved invaluable. Our JavaScript ecosystem expanded from a single React Native SDK using Quick SQLite to also support:
- Web via WA-SQLite
- Node.js via Better SQLite
- An additional React Native option via OP-SQLite
Having most of the core logic in a pluggable module made delivering these variants much faster and more consistent.
Quickly Hacking An Alpha SDK With SKIE
The PowerSync Kotlin and Swift SDKs have a different, but somewhat similar, relationship to the structure used in our JavaScript SDKs.
The first alpha version of the Swift SDK was generated by taking Objective-C code output from the compiled Kotlin SDK and then using SKIE to generate Swift interfaces. We published a detailed blog post on this process here.
SKIE is an incredible tool by Touchlab that makes it much easier to reuse Kotlin code in Swift. I was impressed the first time I used it. We feed in the Kotlin SDK, add a few SKIE build steps, and out comes a (mostly) usable Swift version of the PowerSync SDK. It was exciting: with relatively little additional effort, we extended and reused our existing codebase and produced two SDKs for nearly the price of one. Swift developers could now access the benefits of PowerSync, but there was still work to be done.
While SKIE does a great job bridging Kotlin and Swift, it's not realistic to expect it to deliver a full-featured SDK out of the box. Our initial alpha SDK was largely the raw SKIE output. We documented it and provided example usage, but there were significant caveats. For example, developers had to deal with:
- Mangled function names
- Kotlin type wrappers instead of native Swift types
- Boilerplate code to bridge between Kotlin interfaces and Swift
The alpha SDK was functional, but the developer experience left much to be desired. Improving this experience became a key focus for our next phase.
Beta: Going From Project Car To Daily Driver
If the Swift SDK were a project car, the Kotlin SDK would be the crate engine we dropped in to get things moving. With some skill, a developer could take it for a spin, but it wasn’t quite ready for a daily commute. The beta release aimed to install the missing basics: comfort, usability, and a smoother ride.
As with many project cars, we asked ourselves: is all this customization worth it? Wouldn’t it be better to just build the Swift SDK entirely in Swift? After all, many of the alpha shortcomings could be avoided that way. Fortunately, our modular design philosophy offered a better middle ground.
The beta release aimed to resolve the key issues from the alpha. We shifted toward a more Swift-focused API. Even if we kept the Kotlin implementation under the hood, we could still define Swift-native interfaces and provide a clean, idiomatic developer experience.
To that end, we introduced:
- Swift protocols for the PowerSync APIs
- A lightweight adapter to bridge the public Swift protocols to the internal Kotlin implementation
- Use of Swift’s native Foundation APIs
- Elimination (mostly) of the need for SKIE-generated Kotlin types in public APIs
- Internal handling of boilerplate that developers previously had to write themselves
This approach let us maintain the shared Kotlin core while offering Swift developers a modern, intuitive SDK.
Beta to Stable: Leveraging Shared Code and Prioritizing Developer Experience
Stability is a top priority for any SDK. Developers rely on our tools in the critical paths of their apps, from development all the way to production. Our SDK should be rock-solid, especially in production, and never introduce unnecessary bugs into the development process.
The beta release revealed some key improvements which would be required for pushing the Swift SDK past the stable threshold. Diving into these improvements can give some insight into the benefits of using a shared Kotlin implementation wrapped with a dedicated Swift layer.
The mantra when developing the stable release could be summarized to “do the heavy lifting in shared code - polish and make it shiny in the Swift layer”.
Threading the needle
While the alpha and beta SDKs advertised thread-safe operations, the stable release delivered on this promise with concrete improvements.
The stable release made core SDK functions truly thread-safe:
// This pattern works reliably across multiple threads Task { try await powerSync.connect() // First task initiates connection } Task { try await powerSync.connect() // Second task waits for first to complete } Task { try await powerSync.disconnect() // Properly synchronized with other operations }
In the alpha/beta versions, concurrent calls to some PowerSync SDK methods could lead to race conditions where internal state was corrupted or crashes could occur. The stable release implemented proper synchronization to ensure methods execute sequentially even when called simultaneously from different tasks. The majority of these improvements were directly implemented once in the core Kotlin SDK.
Connection Pooling for Concurrent Reads
A major architectural improvement in the stable SDK is our connection pooling system:
┌─────────────────┐ ┌─────────────────┐ │ │ │ Write Connection│ │ │ │ (Sequential) │ │ Your App │ └─────────────────┘ │ │ ┌─────────────────┐ │ ┌───────────┐ │ │ Read Connection │ │ │ PowerSync │──┼───────│ Pool │ │ └───────────┘ │ │ (Concurrent) │ └─────────────────┘ └─────────────────┘
The beta SDK used a single read connection and a single write connection, which limited concurrent read operations. The stable release implements:
- A dedicated write connection that ensures sequential, atomic modifications
- A pool of read connections that allows multiple simultaneous read operations
This architecture dramatically improves performance for apps that need to display data from multiple queries simultaneously, a common requirement in modern Swift applications.
The logic for connection pooling and managing SQLite driver connections is shared from the Kotlin SDK. The Swift SDK contains almost no implementation for this logic. “Write once, share always” streamlined this functionality for our Swift SDK.
Watch Query Concurrency
Our watch query system (which provides real-time updates when data changes) received substantial concurrency and stability improvements:
// Now truly concurrent and reliable
let tasksStream = try powerSync.watch(
sql: "SELECT * FROM tasks",
parameters: []
) {...}
let projectsStream = try powerSync.watch(sql: "SELECT * FROM projects", ...)
let usersStream = try powerSync.watch(sql: "SELECT * FROM users", ...)
// All three streams update independently and efficiently
// when underlying data changes
In the beta SDK, watched queries could miss updates or yield stale data due to internal race conditions. The stable release implements thread-safe notification mechanisms that ensure:
- All data changes properly trigger relevant watched queries
- Notifications are coalesced efficiently to prevent redundant updates
- Read operations for watched queries utilize the connection pool
Our testing methodology focused on real-world usage patterns that developers might encounter. All stability improvements were implemented in the shared Kotlin core SDK, where we developed rigorous unit tests to verify thread safety and concurrency handling. This approach ensures that the improvements benefit both the Kotlin and Swift SDKs without duplicating effort.
Truly Native Swift Experience
The beta release aimed to abstract the SKIE interfaces from developers. While the beta release did make the SDK more pleasant to use in a Swift application, the efforts there proved to be incomplete. We identified a few vital improvements which would provide a much cleaner developer experience.
Our stable release delivers a truly native Swift experience by completely removing translated Kotlin types from all public-facing APIs, as detailed in this PR.
This transformation was comprehensive, replacing Kotlin interfaces with Swift Protocols and native types across the entire SDK. The key to success here is that while the public facing API was a little rough, the core implementation was solid. We could still leverage the benefits of a shared core implementation. All the improvements mentioned here were reaped from minor improvements made to Swift Protocols and thin adapters.
Improved Types
Developers don’t want to write repeated boiler-plate code which converts SDK results to usable values. We had a few remaining instances where Kotlin wrapper types were returned by the SDK. These wrappers were painful to work with. Using Date values in our SyncStatus response was one area which was improved. The stable release was updated to provide Swift Date types for all time based data.
// Beta SDK: currentStatus.lastSyncedAt returned a Kotlin datetime wrapper
// which would require boxing to a Swift Date instance
let lastSyncTime = Date(
timeIntervalSince1970: TimeInterval(
database.currentStatus.lastSyncedAt!.epochSeconds
)
)
// Stable SDK (native Swift)
let lastSyncTime: Date = database.currentStatus.lastSyncedAt // Already a Swift Date
Improved Transactions
Transactions are a powerful and useful concept in database engineering. Our beta SDK left much to desire when using transactions. The transaction Context, which provides means for executing SQLite operations within the active transaction was still tightly coupled to the SKIE output which lacked support for result set generics.
// Alpha SDK (lost generics, required unsafe casting)
let attachmentIDs = try transaction.getAll(
sql: "SELECT photo_id FROM todos WHERE list_id = ?",
parameters: [id]
) { cursor in
cursor.getString(index: 0)! // Force unwrap needed
} as? [String] // Manual cast required
// Stable SDK (Swift native types)
let attachmentIDs = try transaction.getAll(
sql: "SELECT photo_id FROM todos WHERE list_id = ?",
parameters: [id]
) { cursor in
try cursor.getString(index: 0) // Returns native Swift String
} // Swift properly infers [String] type
Bumping transaction support to a stable state was implemented by wrapping the functional (but a little messy) Kotlin implementations with small Swift wrapper implementations. This resulted in fairly large wins with minimal effort.
Simplified CRUD Operations
Some of our SDK methods required jumping through hoops in order to get the job done. This was mostly a remnant of letting SKIE code leak through to the public API. It should be noted that these inconveniences were not a result of SKIE being bad, but rather the lack of proper presentation of the API on our end.
// Alpha SDK (awkward Kotlin invocation)
_ = try await transaction.complete.invoke(p1: nil)
// Stable SDK (natural Swift method call)
try await transaction.complete()
Light-weight Swift wrappers were once again the solution to this quagmire.
Improved SQL Cursor APIs
Our Kotlin and Swift SDKs use cursors to map SQLite result sets to typed result data structures. Once again, the beta SDK bled some Kotlin through to the public Swift SDK. Beta users often found themselves having to skate-by with strange result types.
// Alpha SDK (Kotlin primitive wrappers)
let value = (
cursor.getBoolean(index: 0)?.boolValue, // Kotlin Bool
cursor.getDouble(index: 1)?.value, // Kotlin Double
cursor.getLong(index: 2)?.int64Value // Kotlin Long
)
// Stable SDK (native Swift types)
let value = (
try cursor.getBoolean(index: 0), // Swift Bool
try cursor.getDouble(index: 1), // Swift Double
try cursor.getInt64(index: 2) // Swift Int64
)
How does one fix this? Write a SQLCursor from scratch in Swift? No thank you sir!
We already had all the core implementation provided by the Kotlin SDK - all we had to do was bundle and wrap it in a nice bow.
The stable SDK also included numerous quality-of-life improvements:
- @discardableResult annotations to silence unnecessary warnings
- Support for nil SQL parameters without compiler warnings
- Type-safe error handling with dedicated SqlCursorError type
- Consistent behavior between index and name-based SqlCursor column value getters
In my opinion, these changes completely transform the developer experience, turning what was a clunky semi SKIE-translated API into a polished, idiomatic Swift SDK that feels natural to Swift developers.
Why This Architecture Matters
Understanding our approach to the Swift SDK architecture directly impacts your development experience:
- Faster bug fixes and feature parity - When we implement a new feature or fix a bug in the Kotlin core, Swift developers get these improvements with minimal delay
- Better testing coverage - With a shared core, our test suite becomes more comprehensive across SDKs
- Futureproofing - As Apple introduces new Swift features or deprecates old ones, we can adapt our Swift layer without touching the battle-tested core
The Future
We recently released stable versions of both the Kotlin and Swift SDKs. Swift developers can now confidently use PowerSync in their production apps.
Thanks to the Kotlin-based core, Swift users benefit from timely updates; new features and bug fixes are available shortly after they’re released in the Kotlin SDK. As with all our SDKs, we’ve designed the architecture to remain flexible. We’ve kept the door open for additional Swift-specific features and support for alternative SQLite drivers.
We’re excited about what’s ahead and look forward to continuing to grow the PowerSync ecosystem across platforms.