SyncPilledDocs

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.

  • updatedAt dates 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 one apply skips.

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(), then await 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 gets SyncPilledError.accountDeleted from sync(): 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.

On this page