Add PowerSyncSwiftData: a SwiftData custom DataStore backed by PowerSync (#126)#146
Add PowerSyncSwiftData: a SwiftData custom DataStore backed by PowerSync (#126)#146asiergmorato wants to merge 1 commit into
Conversation
|
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
left a comment
There was a problem hiding this comment.
Thanks for starting on this. I had a quick and very high-level look. Some initial thoughts are:
- 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?
- 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_BUSYrisks). - 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 | |||
There was a problem hiding this comment.
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.
|
Thanks for the quick review @simolus3! This all makes sense. Here's what I'll do:
Will ping here as the split PR goes up. |
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.
d6bd5ee to
190a2af
Compare
What
A new
PowerSyncSwiftDataproduct: a SwiftData customDataStorebacked by PowerSync, proposed as a solution to the use cases discussed in #126. Apps use@Model/@Query/ModelContextas usual; underneath, PowerSync owns the SQLite database, local writes are captured intops_crud, and sync downloads update@Queryviews 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
@Queryviews live. Onlyconnect()stays single-owner (level 4 deliberately not pursued, as discussed there).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)
PowerSyncDatabase.fetch/saveare synchronous, so a small bridge blocks the calling thread while the async work runs on a dedicatedTaskExecutorover a private GCD queue — neither the work nor its continuations ever need a cooperative-pool thread (stress-tested with the pool saturated).Codablerepresentation by property name (DataStoreSnapshotCodingKey.modeledProperty); the suite pins this with a misaligned-name exit test.PowerSyncChangeObserverwatches 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 — andModelContext.didSaveis what@Queryreacts to.Core SDK changes
The multi-process support rests on three changes to the
PowerSynctarget (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)
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 isdisconnectAndClear()'s job; erasing through the store would upload a DELETE for every row.connect()only from the app); extension writes upload when the app's client runs — immediately if it's alive, thanks to the signal.@Queryrefresh: the observer achieves live updates through publicdidSavesemantics instead of imitating SwiftData's internal remote-change notification.MirroroverschemaMetadata(Schema.Attributeexposes no key path, unlikeSchema.Relationship), and thePersistentIdentifierCodable 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 viaPredicateCodableKeyPathProviding— generated automatically by the optional@PowerSyncModelmacro (separatePowerSyncSwiftDataMacrosproduct, 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:
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.!=/NOTsemantics over optionals verified against the in-memory fallback, lowercase UUID storage/bindings (Postgres renders uuids lowercase),fetchCounthonoring offset, the documented fallback contract (SwiftData only falls back in-memory forfetch()).ps_data__*directly), observer robustness (start()throws instead of hanging on misconfiguration, streams restart with backoff, bursts coalesce), echo suppression and loop freedom.didSavewithin milliseconds).@PowerSyncModelmodel keeps working with reflection suppressed).Beyond the suite:
ps_crudsharing, writer contention, and the signal chain ending inModelContext.didSave.ModelContextwith the app's@Queryviews 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)
COLLATE NOCASE;starts(with:)/containsmap toLIKE(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)..uniqueupsert semantics, no model inheritance, noModelContainer.erase().ModelContext.fetchHistory/deleteHistoryare 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).HistoryTombstonehas no public initializer (deletions surface asdeletedIdentifierson the concrete transaction type), andany PartialKeyPath<M> & Sendablecan only be built from key-path literals (updates carryupdatedPropertyNames). Public initializers for these — and a public key-path accessor onSchema.Attribute— would let third-party stores drop every workaround; we intend to file Feedbacks.Review guide
Sources/PowerSyncSwiftData/README.md— it is the contract this PR implements.PowerSyncDataStore(fetch/save/batch/history),PowerSyncSnapshot(by-name Codable materialization),SchemaMapper/ValueCoercion(mapping + types),PredicateTranslator,AsyncBridge,PowerSyncChangeObserver,PowerSyncHistory.AsyncConnectionPool(absolute paths + open retry + signal wiring),CrossProcessChangeSignal, one-line filters inwatch.swiftandStreamingSyncClient.docs/(in Spanish — happy to translate any part on request).PowerSyncGRDB.