SyncReconcilerKit is a Swift package that provides a generic, testable, and concurrency-safe synchronization engine for SwiftData. It helps reconcile server-based DTOs with local models in an upsert + reconcile pattern:
- Insert new entities if they don’t exist.
- Update existing ones if the server’s version is newer.
- Delete local entities that are missing from the server (optional, for full-slice syncs).
- Synchronize children recursively with flexible tasks.
This pattern makes it easy to build server-driven, offline-capable apps without duplicating boilerplate merge logic.
- 🔄 Generic upsert engine for any SwiftData model.
- 🧩 Reusable child sync tasks (handle multiple child collections per parent).
- 🛡️ Concurrency-safe with Swift 6.2 strict isolation (
@ModelActor,Sendable). - 🧪 Fully testable with in-memory containers and Swift Testing.
- ⚙️ Configurable deletion policies:
.none,.hardDeleteMissing,.softDeleteMissing. - ♻️ Automatic reactivation: if a soft-deleted entity reappears from the server with a newer
updatedAt, it is restored (withdeletedAt = nil). - 📦 No external dependencies — just SwiftData and Swift standard libraries.
Add the package to your Xcode project:
- In Xcode, go to File → Add Packages…
- Enter the package URL of your repository (e.g.,
https://github.com/BasinPhoto/SyncReconcilerKit.git) - Add
SyncReconcilerKitto your app and test targets.
struct StudioDTO: RemoteStampedDTO {
typealias ID = String
let id: String
let name: String
let updatedAt: Date
let rooms: [RoomDTO]
}
struct RoomDTO: RemoteStampedDTO {
typealias ID = String
let id: String
let title: String
let updatedAt: Date
}@Model
final class Studio: SyncableModel, SoftDeletable {
@Attribute(.unique) var remoteId: String
var name: String
var updatedAt: Date
var deletedAt: Date? // for soft-delete support
@Relationship(deleteRule: .cascade) var rooms: [Room] = []
required convenience init(dto: StudioDTO) {
self.init(remoteId: dto.id, name: dto.name, updatedAt: dto.updatedAt)
}
init(remoteId: String, name: String, updatedAt: Date) {
self.remoteId = remoteId
self.name = name
self.updatedAt = updatedAt
}
func apply(_ dto: StudioDTO) {
self.name = dto.name
}
}let roomsTask = ChildSyncTask<StudioDTO, Studio, Room>(
extract: { $0.rooms },
fetchExistingByIds: { ids, ctx in
try ctx.fetch(FetchDescriptor(predicate: #Predicate<Room> { ids.contains($0.remoteId) }))
},
fetchScopeForDeletion: { parent, ctx in
let pid = parent.remoteId
return try ctx.fetch(FetchDescriptor(predicate: #Predicate<Room> { $0.studio?.remoteId == pid }))
},
applyExtra: { model, _, parent, _ in
if model.studio !== parent { model.studio = parent }
},
deletionPolicy: .hardDeleteMissing
)let dtos: [StudioDTO] = try await api.fetchStudios()
let engine = SyncEngine(modelContainer: container)
try await engine.upsertCollection(
from: dtos,
fetchExistingByIds: { ids, ctx in
try ctx.fetch(FetchDescriptor(predicate: #Predicate<Studio> { ids.contains($0.remoteId) }))
},
deletionPolicy: .softDeleteMissing,
childTasks: [AnyChildTask(roomsTask, parentId: \.id)],
extractParentIdForChildren: \.id,
requireNonEmptyFullSlice: true
)This package is fully covered by Swift Testing.
• ✅ Generic parent/child sync
• ✅ Swift Testing suite
• ✅ Support for soft-delete strategies
• ✅ Automatic reactivation of soft-deleted entities
• 🔜 Built-in utilities for fetchByIds and scoping
MIT License. See LICENSE for details.