Skip to content

Add PowerSyncSwiftData: a SwiftData custom DataStore backed by PowerSync (#126)#146

Open
asiergmorato wants to merge 1 commit into
powersync-ja:mainfrom
Chubby-Studio:feature/swiftdata-integration
Open

Add PowerSyncSwiftData: a SwiftData custom DataStore backed by PowerSync (#126)#146
asiergmorato wants to merge 1 commit into
powersync-ja:mainfrom
Chubby-Studio:feature/swiftdata-integration

Conversation

@asiergmorato

@asiergmorato asiergmorato commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

What

A new PowerSyncSwiftData product: a SwiftData custom DataStore backed by PowerSync, proposed as a solution to the use cases discussed in #126. Apps use @Model / @Query / ModelContext as usual; underneath, PowerSync owns the SQLite database, local writes are captured into ps_crud, and sync downloads update @Query views live.

The store is multi-process, delivering levels 1–3 of the quality model @simolus3 outlined in #126: widgets and App Intents extensions read and write the shared App Group database, changes from any process update the app's @Query views live. Only connect() stays single-owner (level 4 deliberately not pursued, as discussed there).

Stacked on #147. The three core SDK changes this builds on (absolute paths, concurrent-open retry, cross-process change signal) live in #147 for separate review. Until that merges this branch still contains those commits, so the diff here includes them; they drop out once #147 lands and this rebases on main.

let database = PowerSyncDatabase(schema: try PowerSyncSchema(for: [Note.self]))
try await database.connect(connector: myConnector)

let configuration = PowerSyncDataStoreConfiguration(name: "powersync", database: database)
let container = try ModelContainer(for: SwiftData.Schema([Note.self]), configurations: [configuration])

let observer = PowerSyncChangeObserver(container: container, configuration: configuration)
try await observer.start(observing: [Note.self])

Full documentation in Sources/PowerSyncSwiftData/README.md, including supported types, predicate translation, relationships, schema evolution, multi-process setup and every known limitation.

How it works (short version)

  • One database, no second connection. The store translates SwiftData operations to the async PowerSync API over the app's existing PowerSyncDatabase. fetch/save are synchronous, so a small bridge blocks the calling thread while the async work runs on a dedicated TaskExecutor over a private GCD queue — neither the work nor its continuations ever need a cooperative-pool thread (stress-tested with the pool saturated).
  • Snapshots are dictionaries keyed by property name. SwiftData materializes models from a custom snapshot's Codable representation by property name (DataStoreSnapshotCodingKey.modeledProperty); the suite pins this with a misaligned-name exit test.
  • Writes go through PowerSync's views inside one write transaction per save, so triggers capture them for upload exactly like handwritten SQL.
  • Reactivity: PowerSyncChangeObserver watches the observed tables; on changes that didn't come through this process's SwiftData (sync downloads, other processes) it reconciles into a background context and saves — echo-suppressed by author, so nothing loops or re-uploads — and ModelContext.didSave is what @Query reacts to.
  • Multi-process: each process opens its own database + container over the App Group file (the standard SwiftData pattern). Data already flowed safely through WAL; the two missing pieces were SDK-level and are part of this PR (see below).

Core SDK changes

The multi-process support rests on three changes to the PowerSync target (absolute database paths, concurrent-open retry, and a cross-process Darwin change signal). They are in #147 with their own description and tests.

Key decisions (and why)

  • Native thin store over the async PowerSync API — no second SQLite connection, no GRDB dependency; PowerSync stays the single source of truth.
  • PowerSyncSchema(for: models) derives the PowerSync schema from the @Models, removing the duplication the GRDB integration suffers (manual overrides remain possible).
  • erase() deliberately throws: resetting local data is disconnectAndClear()'s job; erasing through the store would upload a DELETE for every row.
  • Sync ownership stays single-process (connect() only from the app); extension writes upload when the app's client runs — immediately if it's alive, thanks to the signal.
  • No private-API mimicry for @Query refresh: the observer achieves live updates through public didSave semantics instead of imitating SwiftData's internal remote-change notification.
  • Two private SwiftData surfaces are used, with defense in depth (disclosed prominently): attribute key paths via Mirror over schemaMetadata (Schema.Attribute exposes no key path, unlike Schema.Relationship), and the PersistentIdentifier Codable envelope. Both are behind runtime tripwires that fail descriptively on first use rather than corrupting, the envelope is only a fallback behind an identifier→key mint cache, and models can opt their key paths into public API via PredicateCodableKeyPathProviding — generated automatically by the optional @PowerSyncModel macro (separate PowerSyncSwiftDataMacros product, so the core gains no dependency; macro plugins build for the host and modern toolchains use swift-syntax prebuilts).

Testing and validation

79 new Swift Testing tests (joining the existing 109 XCTests; strict-concurrency build clean), organized by functional area:

  • Round trips for every attribute type; CRUD verified against getNextCrudTransaction() (PUT/PATCH/DELETE with column values); identifier minting/remapping incl. reference cycles in one save; many-to-many through a join model end to end.
  • Predicate translation units + integration, NULL-safe !=/NOT semantics over optionals verified against the in-memory fallback, lowercase UUID storage/bindings (Postgres renders uuids lowercase), fetchCount honoring offset, the documented fallback contract (SwiftData only falls back in-memory for fetch()).
  • Reactivity (downloads simulated by writing ps_data__* directly), observer robustness (start() throws instead of hanging on misconfiguration, streams restart with backoff, bursts coalesce), echo suppression and loop freedom.
  • Multi-process: concurrent-open retry (reproduced in-process with a system-SQLite lock holder), cross-process signal (two pools over one file reproduce two-process blindness deterministically), the full extension-write chain (write on store B → shared crud queue → app-side didSave within milliseconds).
  • SDK-drift guards for both private surfaces, including simulated-drift tests exercising the runtime failure paths (read fails descriptively; save refuses to persist; a @PowerSyncModel model keeps working with reflection suppressed).
  • Schema evolution (added optional/required-with-default properties, removed keys, new models on an existing file, migration plans rejected) and bridge stress (cooperative pool saturated at 4× cores; 16 concurrent contexts).
  • Benchmarks over a 1,000,000-row table (macOS): 28-day count ≈ 1–3 ms, 60-day sorted first page of 200 ≈ 3 ms, full 60-day window materializing 60,000 models ≈ 0.6 s, 10k-insert batch save ≈ 0.5 s.

Beyond the suite:

  • A real two-process harness (separate OS processes over one file) validated concurrent cold opens, external-write visibility, ps_crud sharing, writer contention, and the signal chain ending in ModelContext.didSave.
  • An adversarial multi-agent audit produced 52 verified findings; every correctness finding was fixed and pinned by a test (several listed above).
  • Real-world pilot: a production app (FitWoody, our app from the original [Feature request] First-class support for reading PowerSync data from iOS app extensions (Widgets, App Intents, Live Activities) #126 report) ran the store against its existing PowerSync database with live Supabase sync — including App Intents from Shortcuts reading and writing through ModelContext with the app's @Query views updating live. Happy to share findings as we keep testing, per the offer in the issue thread.
  • Demos/SwiftDataDemo: a SwiftData to-do app with a Supabase connector and a widget sharing the database via App Group, including an interactive button whose intent writes from the widget's process.

Known limitations (all documented in the module README)

  • String sorts use COLLATE NOCASE; starts(with:)/contains map to LIKE (ASCII case-insensitivity differs from Swift); locale-aware operators fall back to in-memory. Optional-chained to-one predicates ($0.playlist?.id == x, $0.playlist?.attr == x) DO translate (FK comparison / IN (SELECT ...) subquery, NULL-safe).
  • The observer re-reads a watched table per change burst and keeps observed models registered (memory ∝ rows) — fine for UI-scale tables, documented for large ones.
  • No transformables, no .unique upsert semantics, no model inheritance, no ModelContainer.erase(). ModelContext.fetchHistory/deleteHistory are not provided (the protocol can't be fully satisfied by a third-party store today; kept on a branch for a follow-up once there's a clean story).
  • Two SwiftData SDK gaps block full protocol fidelity and are disclosed: HistoryTombstone has no public initializer (deletions surface as deletedIdentifiers on the concrete transaction type), and any PartialKeyPath<M> & Sendable can only be built from key-path literals (updates carry updatedPropertyNames). Public initializers for these — and a public key-path accessor on Schema.Attribute — would let third-party stores drop every workaround; we intend to file Feedbacks.
  • Device-level suspension validation for extensions remains a manual procedure (documented in the demo README); saves are single short transactions by construction.

Review guide

  • Start with Sources/PowerSyncSwiftData/README.md — it is the contract this PR implements.
  • The store core: PowerSyncDataStore (fetch/save/batch/history), PowerSyncSnapshot (by-name Codable materialization), SchemaMapper/ValueCoercion (mapping + types), PredicateTranslator, AsyncBridge, PowerSyncChangeObserver, PowerSyncHistory.
  • Core SDK changes are isolated: AsyncConnectionPool (absolute paths + open retry + signal wiring), CrossProcessChangeSignal, one-line filters in watch.swift and StreamingSyncClient.
  • History is linear and commit messages are written to be read; the design doc that drove the work is in docs/ (in Spanish — happy to translate any part on request).
  • Maturity label is of course your call — the README says alpha, mirroring PowerSyncGRDB.

@asiergmorato

Copy link
Copy Markdown
Contributor Author

Pushed a batch addressing three of the known limitations listed above (the PR description is updated accordingly).

Column-name overrides (3884e1d): the configuration and PowerSyncSchema(for:) now accept columnNameForProperty, so camelCase Swift properties can map to snake_case backend columns without renaming either side. To-one relationships append _id to the override's result. Overridden columns flow through the derived schema, the upload queue, predicate translation, and the early validation (a mismatch against the actual database still fails container creation with the missing column named).

Optional-chained to-one predicates now translate to SQL (3884e1d): $0.playlist?.id == x compares the foreign-key column directly, and $0.playlist?.attr == x resolves through an IN (SELECT id ...) subquery. Both preserve Swift's optional-chain semantics: a nil relationship makes == false and != true, NULL-safe in SQL. These previously fell back to in-memory filtering (correct but O(table)); since this is the most natural shape for filtering by a relationship in #Predicate (direct model comparison doesn't compile, models aren't Codable), it deserved a fast path.

History performance (3884e1d): the canonical catch-up shape ($0.token > last) is now prefiltered in SQL, selecting whole transactions by id so a transaction whose rows straddle the bound is never truncated (pinned by a test). HistoryDescriptor.sortBy is applied on 26.0+ runtimes instead of being ignored.

Suite is at 141 Swift Testing tests across 34 suites plus the existing 109 XCTests, all green.

@simolus3 simolus3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for starting on this. I had a quick and very high-level look. Some initial thoughts are:

  1. You were concerned about errors where we might hold a database transaction while the process is killed. I don't see anything in this PR that would fix this, did you manage to get experience on how much of a problem this is in practice?
  2. I'm happy to explore IPC like cross-process change signals, but these core changes should be extracted into a separate PR. In our other SDKs, we also have an asynchronous locking scheme in the SDK to avoid using the write connection concurrently (SQLite uses file locks to guard against this, but an async check doesn't pin a thread and eliminates SQLITE_BUSY risks).
  3. I'm fine with adding things relying on public Swift Data APIs (like a custom data store). But things that only kind of work or stuff that relies on implementation details that could change (you mentioned the history implementation should IMO be removed for now since we have no guarantee we can make this work.

@@ -0,0 +1,415 @@
import Foundation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given the limitations around this, would it make sense to not provide a HistoryProviding store initially?

I also wonder whether an option that is only derived from ps_crud (thus not capturing local-only tables but also not having any additional overhead) could be interesting.

@asiergmorato

Copy link
Copy Markdown
Contributor Author

Thanks for the quick review @simolus3! This all makes sense. Here's what I'll do:

  1. Suspension / 0xDEAD10CC. I don't have device telemetry yet, but two reasons the exposure looks bounded: every write is a single short transaction (the ModelContext model accumulates changes and commits them in one save(), so we never hold a transaction open between operations, only for the save itself, which is milliseconds), and because a custom DataStore delegates the actual write to us, the exposure is the same as DefaultStore's rather than something new. Core Data + App Groups has had this class of crash for years, and Apple's own SwiftData samples write from the widget process. Concrete plan: we run this in a production app (FitWoody) and will report 0xDEAD10CC rates from TestFlight, especially under HealthKit background delivery. I can also wrap extension saves in performExpiringActivity and add an interrupt-on-suspend hook if you'd like belt and suspenders.

  2. Core changes into a separate PR. Agreed. I'll extract the three core changes (absolute paths, concurrent-open retry, cross-process change signal) into their own PR and rebase this one on top. On locking: I'll move the open retry off the thread-pinning usleep to an async wait to match what you described. Where does the async locking scheme in your other SDKs live? Happy to align with it rather than invent a parallel mechanism.

  3. History. Will do, I'll pull HistoryProviding out of this PR for now. (For the record it doesn't depend on private SwiftData internals, it's a local-only journal plus the public HistoryProviding protocol. But you're right that the protocol can't be fully satisfied today: HistoryTombstone has no public initializer and updatedAttributes can't be built from runtime key paths, so a generic HistoryChange consumer wouldn't see deletes. Better to land it once there's a clean story.) On deriving from ps_crud: interesting, though the wrinkle is that ps_crud is cleared as uploads complete, so it would be ephemeral, more "what's pending upload" than history. Worth its own discussion.

Will ping here as the split PR goes up.

asiergmorato added a commit to Chubby-Studio/powersync-swift that referenced this pull request Jun 15, 2026
Per review feedback on powersync-ja#146: the HistoryProviding conformance cannot be
fully satisfied by a third-party store today (HistoryTombstone has no
public initializer, and updatedAttributes can't be built from runtime
key paths), so a generic HistoryChange consumer would not see deletes.
Rather than ship a partial protocol conformance, history is removed
from this PR and preserved on the feature/swiftdata-history branch for
a follow-up once there is a clean story.

Removed: PowerSyncHistory.swift and HistoryTests.swift; the local-only
journal table from the schema builder; journaling from save and batch
delete; the history table probe in validateMapping; the entity-type
registry that only history consumed (SnapshotEntityRegistry.registerType
/ type(named:)); the historyUnavailable error; the README section; and
the history mention in the changelog.

The store is unchanged otherwise. swift test: 109 XCTests plus 133
Swift Testing tests across 33 suites, all green.
Add the Alpha PowerSyncSwiftData product: a SwiftData custom DataStore
backed by PowerSync, so apps can use @Model/@Query/ModelContext with
PowerSync storage and sync (iOS 18 / macOS 15 / watchOS 11 / tvOS 18).
Includes derived PowerSync schemas (PowerSyncSchema(for:)), predicate
and sort translation to SQL, relationships, live updates via
PowerSyncChangeObserver, and a read-only mode for widgets and app
extensions.

Add the optional PowerSyncSwiftDataMacros product with the
@PowerSyncModel macro, which generates a PredicateCodableKeyPathProviding
conformance so models expose their key paths through public API instead
of the reflection fallback.

Includes the SwiftDataDemo app (with a read-only widget sharing the
database via an App Group), tests, and the design spec.

Rebased onto upstream/main so the diff is exclusively the SwiftData
product plus the shared manifest/docs updates (Package.swift,
Package.resolved, CHANGELOG.md, README.md). No changes to the core
PowerSync target or its tests.
@asiergmorato asiergmorato force-pushed the feature/swiftdata-integration branch from d6bd5ee to 190a2af Compare June 29, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants