The Swift package
Connect SwiftData, Core Data or any local store to SyncPilled with a snapshot and an apply function. Files, deletions, accounts and limits.
SyncPilled is a Swift 6 package for iOS 18 and macOS 15, with no
dependencies. Your app keeps its database; the package needs two things from it.
Your data source
struct MyStore: SyncDataSource {
/// Every synced record, as it is now.
func snapshot() async throws -> [LocalRecord] {
try await MainActor.run {
try context.fetch(FetchDescriptor<Entry>()).map { entry in
LocalRecord(
collection: "entries", id: entry.id.uuidString,
data: ["grams": .number(entry.grams), "note": .string(entry.note)],
updatedAt: entry.updatedAt
)
}
}
}
/// Writes in what changed on other devices.
func apply(_ records: [IncomingRecord], unchanged: UnchangedCheck) async throws -> [RecordKey] {
try await MainActor.run {
var written: [RecordKey] = []
for record in records {
let key = RecordKey(collection: record.collection, id: record.id)
let current = try fetchEntry(record.id)
// Read, check and write with nothing in between.
guard unchanged(key, current.map(localRecord)) else { continue }
if record.deleted { current.map(context.delete) } else { upsert(record) }
written.append(key)
}
try context.save()
return written
}
}
}Each sync compares the snapshot with what was last in step with the server,
pushes the difference, pulls what changed elsewhere and hands it to apply. A
record changed on this device and not yet pushed is never overwritten: the
unchanged check says so, and the engine pushes it next time.
updatedAtdates an edit. Without it, an edit is dated when a sync finds it.- Collections are lowercase names, such as
entries. A collection you only upload, for backup or analysis, is oneapplyskips.
Files
Put photos and other files in LocalRecord.files. Each is uploaded once, by
its SHA-256, before the record that names it, and arrives in
IncomingRecord.files. A fingerprint, such as the modification date, keeps
an unchanged file from being reread on every sync.
Signing out, and deleting an account
- Signing out or switching accounts:
try await engine.forget(), thenawait client.resetSignIn(), then clear your local data. - Deleting an account:
try await client.deleteAccount()deletes the person's records and files on the server, for good. Every other device signed in to the account then getsSyncPilledError.accountDeletedfromsync(): forget, erase the local copy, and start over.
Limits
An app can limit how much one person stores. A change or file past a person's limit
stays on the device and is sent again by later syncs; the rest of the sync goes
ahead. SyncReport.refused counts them and SyncReport.limitsReached names
them (quota_exceeded), so your app can say storage is full. When the server
has had too many requests, sync() throws
SyncPilledError.rateLimited(retryAfter:), and engine.pausedUntil says when
to try again. See Limits.