markbattistella/swiftdatasync
SwiftDataSync connects an app-owned SwiftData store to any number of shared CloudKit record zones. SwiftData remains the durable local source of truth while CKSyncEngine delivers a transactionally maintained outbox to the owner's private database or a participant's shared dat
Requirements
| Platform | Minimum | | --- | --- | | Swift | 6.0 | | iOS / iPadOS / Mac Catalyst | 17.0 | | macOS | 14.0 | | tvOS | 17.0 | | watchOS | 10.0 | | visionOS | 1.0 |
Installation
Add the package with Swift Package Manager:
dependencies: [
.package(
url: "https://github.com/markbattistella/SwiftDataSync",
from: "1.0.0"
)
]Then add SwiftDataSync to the app target.
App configuration
Every consuming app still owns its CloudKit contract. Enable:
- iCloud with CloudKit
- Remote notifications
- A CloudKit container
CKSharingSupportedin the app's Info property list
Deploy the app's CloudKit schema before shipping.
Create one configuration. Unlike a single-zone setup, this doesn't carry a fixed zone name of its own - an app can track any number of zones side by side, each named however its own model needs:
let configuration = SwiftDataSyncConfiguration(
containerIdentifier: "iCloud.com.example.Tasks",
appGroupIdentifier: "group.com.example.Tasks",
stateKeyPrefix: "tasks.sync",
shareTitle: "Shared tasks",
appName: "Tasks",
dataName: "workspace"
)SwiftData adapter
The package deliberately does not reflect arbitrary @Model classes into CloudKit. Each app implements SwiftDataSyncStore so record fields, deletions, migrations, and merge policy remain explicit.
@MainActor
final class TaskCloudStore: SwiftDataSyncStore {
let modelContext: ModelContext
// Return durable outbox rows, materialise CKRecords, apply fetched records,
// preserve conflicts, and commit through this same ModelContext.
}Every pending change's collectionID (on SwiftDataSyncPendingChange) tells the engine which zone it belongs to - the outbox row should be inserted or updated in the same SwiftData transaction as the app model being changed. This guarantees that terminating the app between a local save and a network request cannot silently lose the upload.
Create and retain the engine:
let syncEngine = SwiftDataSyncEngine(
configuration: configuration,
store: TaskCloudStore(modelContext: modelContainer.mainContext)
)Provision a zone this device owns - safe to call repeatedly (e.g. once per owned zone at every launch):
let zoneID = configuration.ownedZoneID(named: "Workspace-\(workspaceID)")
syncEngine.ensureZoneExists(zoneID, collectionID: workspaceID)Call reconcileOutbox() after committing a local mutation. Call fetchChangesNow() for an explicit user-requested refresh.
Data-safety contract
- Local SwiftData is authoritative.
- Network delivery is eventually consistent.
- Failed changes remain in the durable outbox.
- A participant's private data is protected before adopting a share, scoped to the one collection being adopted - every other collection this device already owns or participates in is untouched.
- A revoked share invokes the app's recovery hook for just that one collection instead of deleting local data.
- CloudKit conflicts are passed to the app for explicit preservation and merge.
CloudSyncKit
SwiftDataSync is separate from CloudSyncKit. CloudSyncKit observes NSPersistentCloudKitContainer events. SwiftDataSync performs custom-record-zone syncing and cross-account sharing with CKSyncEngine.
Licence
SwiftDataSync is released under the MIT licence.
Package Metadata
Repository: markbattistella/swiftdatasync
Default branch: main
README: README.md