Skip to content

Swift / iOS

Add to Package.swift:

dependencies: [
.package(url: "https://github.com/trainstar/synchro.git", from: "0.1.0")
]

Then add "Synchro" to your target’s dependencies:

.target(
name: "MyApp",
dependencies: ["Synchro"]
)
pod 'Synchro', '~> 0.1.0'

Platforms: iOS 16.0+, macOS 13.0+

Dependencies: GRDB.swift 7.0+

let config = SynchroConfig(
dbPath: dbURL.path,
serverURL: URL(string: "https://api.example.com")!,
authProvider: { await getToken() },
clientID: UIDevice.current.identifierForVendor!.uuidString,
appVersion: "1.0.0"
)
let client = try SynchroClient(config: config)
ParameterTypeDefaultDescription
dbPathStringRequiredPath to the SQLite database file
serverURLURLRequiredSync server base URL
authProvider@Sendable () async throws -> StringRequiredReturns a JWT token for authentication
clientIDStringRequiredUnique device identifier
platformString"ios"Platform name sent during registration
appVersionStringRequiredSemantic version of the app
syncIntervalTimeInterval30Seconds between sync cycles
pushDebounceTimeInterval0.5Seconds after a write before triggering push
maxRetryAttemptsInt5Maximum retry count before entering error state
pullPageSizeInt100Rows per pull page (capped at 1000)
pushBatchSizeInt100Pending changes per push batch (max 1000)
snapshotPageSizeInt100Rows per snapshot page (capped at 1000)
// Fetch multiple rows
let rows = try client.query(
"SELECT * FROM tasks WHERE user_id = ?",
params: [userId]
)
// Fetch a single row
let task = try client.queryOne(
"SELECT * FROM tasks WHERE id = ?",
params: [id]
)
let result = try client.execute(
"INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
params: [UUID().uuidString, "Review proposal", userId]
)
// result.rowsAffected == 1
let affected = try client.executeBatch([
SQLStatement(sql: "INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
params: [UUID().uuidString, "Write report", userId]),
SQLStatement(sql: "INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
params: [UUID().uuidString, "Update docs", userId]),
])
// affected == 2
// Write transaction: multiple operations atomically
try client.writeTransaction { db in
try db.execute(
sql: "INSERT INTO tasks (id, title, user_id) VALUES (?, ?, ?)",
arguments: [UUID().uuidString, "Review proposal", userId]
)
try db.execute(
sql: "INSERT INTO comments (id, task_id, body, status) VALUES (?, ?, ?, ?)",
arguments: [UUID().uuidString, taskId, "Looks good", "open"]
)
}
// Read transaction: consistent snapshot
let count = try client.readTransaction { db in
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tasks") ?? 0
}
let cancel = client.onChange(tables: ["tasks"]) {
print("tasks table changed")
}
// Later: stop observing
cancel.cancel()
let cancel = client.watch(
"SELECT * FROM tasks ORDER BY created_at DESC",
tables: ["tasks"]
) { rows in
self.tasks = rows
}

The callback fires immediately with the current result, then again whenever the observed tables change. Observation is backed by GRDB’s ValueObservation, which uses SQLite’s sqlite3_update_hook for efficient change detection.

// Start sync: register, fetch schema, begin sync loop
try await client.start()
// Trigger an immediate sync cycle
try await client.syncNow()
// Stop the sync loop
client.stop()
// Close the database (also stops sync)
try client.close()
let cancel = client.onStatusChange { status in
switch status {
case .idle:
print("Idle")
case .syncing:
print("Syncing...")
case .error(let retryAt):
if let retryAt {
print("Error, retrying at \(retryAt)")
} else {
print("Error, no retry scheduled")
}
case .stopped:
print("Stopped")
}
}
let cancel = client.onConflict { event in
print("Conflict on \(event.table) record \(event.recordID)")
print("Client data: \(String(describing: event.clientData))")
print("Server data: \(String(describing: event.serverData))")
}
let cancel = client.onSnapshotRequired { () async -> Bool in
// Prompt the user or decide programmatically
return await promptUser("Full resync needed. Continue?")
}

All errors are represented by the SynchroError enum:

public enum SynchroError: Error {
case notConnected
case schemaNotLoaded
case tableNotSynced(String)
case upgradeRequired(currentVersion: String, minimumVersion: String)
case schemaMismatch(serverVersion: Int64, serverHash: String)
case snapshotRequired
case pushRejected(results: [PushResult])
case networkError(underlying: Error)
case serverError(status: Int, message: String)
case databaseError(underlying: Error)
case invalidResponse(message: String)
case alreadyStarted
case notStarted
}
CaseWhen It Occurs
notConnectedOperation attempted before start() completes registration
schemaNotLoadedSchema has not been fetched from server yet
tableNotSyncedWrite attempted on a table not in the server schema
upgradeRequiredServer rejected the client’s app version (HTTP 426)
schemaMismatchClient schema hash does not match server (HTTP 409)
snapshotRequiredServer indicates a full snapshot is needed
pushRejectedOne or more push records were rejected by the server
networkErrorNetwork connectivity failure
serverErrorServer returned a non-200 HTTP status
databaseErrorSQLite operation failed
invalidResponseServer response could not be decoded
alreadyStartedstart() called when sync is already running
notStartedsyncNow() called before start()