iOS- Offline- First- Framework
A local store for Swift apps that keeps working offline and syncs with your server.
License: MIT
Tests: 375Measured:
How it was measured
swift test at tag v2.1.0 (205c546) on macOS 27.0.1 with Swift 6.4: 375 tests in 75 suites passed; run again with OFFLINE_FIRST_NODE set so the three reference-server suites ran, with the same result; the two end-to-end suites that need OFFLINE_FIRST_SERVER_URL were skipped. npm test of Examples/SyncServer on Node 26.11.1: 28 tests passed. The README states 372 tests on an iPhone simulator, which was not re-run here.
Summary
- A Swift package that keeps records in a file on the device, queues every change and syncs with any server when there is a connection.
- Version 2.1.0 fixes the data-integrity findings of two audits of 2.0.0 and of their later rounds, each with a test that failed before the fix.
- Only a push answer the store has read settles a change; after a lost or unreadable answer the change stays in doubt until its next push.
- A new server epoch counts only when two requests of one sync report it, and the store never goes back to an earlier epoch.
- Answers from another deployment behind the same URL are only partly covered; eight open issues describe each case and the way back.
A Swift package that keeps an app's records in a file on the device, queues every change and syncs with a server whenever there is a connection. Saving never waits for the network. When the same record changed on two devices, a conflict policy you choose decides what is kept. It works with any backend: a small HTTP protocol with a reference server, your own API through a two-function protocol, or an in-memory server for tests and previews. The package has no dependencies.
Features
- Writes that never wait: save and delete go to an atomically written file and a durable queue of pending changes; failed pushes are retried with exponential backoff and jitter, and parked after the last attempt instead of being lost
- Conflict policies: last write wins on hybrid logical clock timestamps, server wins, client wins, your own merge, or conflict-free fields (LWWRegister, LWWMap, GCounter, PNCounter, ORSet)
- Any backend: HTTPSyncTransport speaks a documented two-endpoint JSON protocol that the Node reference server in Examples/SyncServer implements; any other API fits behind the SyncTransport protocol
- SwiftUI: SyncedCollection is an @Observable model of a store; automatic sync runs when the device comes online and shortly after local edits
- Data at rest: AES-GCM file encryption with a key kept in the Keychain, record schema migrations and a policy for damaged files
- Bounded against misbehaving servers and damaged files: outbox size, pull pages, pushes per record, response, revision and change token sizes, and file names that cannot leave their directory
- Stated guarantees: a write the server accepted is never replaced by a stale copy, only an answer that was read settles a change, and a new server epoch counts only when two requests of one sync report it; the Guarantees and limits article says what a server must do for that
- Tested with Swift Testing: 375 tests on macOS and 372 on an iPhone simulator, including property tests that run every conflict policy through lost requests, restarts, token resets, stale pages and crashes, and end-to-end suites in CI against the reference server, which has 28 Node tests of its own
Quickstart
1import OfflineFirstFramework2import SwiftUI3 4/// A to-do item. Any `Codable` struct with an `id` can be a record.5struct Todo: SyncableRecord, Hashable {6 var id = UUID()7 var title: String8 var isDone = false9}10 11enum TodoStore {12 // InMemorySyncServer stands in for a backend; swap in HTTPSyncTransport or your own13 // SyncTransport when you have one. The initializer throws only for an invalid name or a14 // store file it cannot read.15 static let shared = try! OfflineStore<Todo>(name: "todos", transport: InMemorySyncServer<Todo>())16}17 18struct ContentView: View {19 @State private var todos = SyncedCollection(store: TodoStore.shared)20 @State private var newTitle = ""21 22 var body: some View {23 NavigationStack {24 List {25 TextField("New to-do", text: $newTitle)26 .onSubmit {27 let todo = Todo(title: newTitle)28 newTitle = ""29 Task { await todos.save(todo) }30 }31 ForEach(todos.records) { todo in32 Text(todo.title)33 }34 }35 .navigationTitle("To-dos")36 .toolbar {37 Text("\(todos.pendingCount) to sync")38 }39 .refreshable { await todos.sync() }40 }41 }42}Installation
Xcode (File › Add Package Dependencies…)
1https://github.com/muhittincamdali/iOS-Offline-First-Framework.gitSwift Package Manager
1dependencies: [2 .package(url: "https://github.com/muhittincamdali/iOS-Offline-First-Framework.git", from: "2.1.0")3],4targets: [5 .target(name: "YourApp", dependencies: [6 .product(name: "OfflineFirstFramework", package: "iOS-Offline-First-Framework")7 ])8]Screenshots




Timeline
Repository opened as a set of manager classes; the sync manager simulated its results and never contacted a server
Version 1.0.0 tagged; its manifest declared a missing Resources folder and unsafe compiler flags, so it could not be added as a dependency by version
Trimmed down: RxSwift and CocoaLumberjack replaced with Combine, managers shortened, sync still a placeholder
Rebuilt from scratch as an offline store with a sync engine and released as 2.0.0; the SwiftPersistence package was merged in and archived, no 1.0.0 API remains. Two audits of 2.0.0 the same day listed eleven must-fix data-integrity findings
v2.1.0: data-integrity release after fifteen rounds of fixes and checks; rounds 14 and 15 were dropped after regressions, the release branch went back to the last commit of round 13, and eight known limitations were filed as issues