Client API — Swift
The Swift client is a full engine, not just the protocol core. Like
@simplysync/engine, it owns a local SQLite database, a typed schema, the HLC
clock, the outbox, a reactive query cache, and the sync loop — so you call
insert / update / query and bind reactive results into SwiftUI, the same way
the React version works. It lives in
ports/swift
(SimplySyncDemo/Engine/, built on the Kit/ protocol port).
This page mirrors the React/TypeScript guide section-for-section. For the on-the-wire protocol and the identity interop boundary, see Native clients. Pure Foundation + CryptoKit +
SQLite3— no third-party dependencies.
Mental model
- The local SQLite database is the source of truth. Reads come from it; writes land in it first, so the app is fully usable offline.
- Every write also produces an encrypted sync event. The engine queues it in a local outbox and pushes it when a relay is configured and reachable.
- Sync is automatic and opportunistic. The engine polls, pushes the outbox,
pulls remote events, decrypts them, and applies them last-writer-wins. You
rarely call
sync()yourself. - Queries are reactive. A
LiveQueryrepublishes its rows when the data it depends on changes — from a local write or an incoming sync.
engine.insert(...) ──▶ local SQLite row ──▶ LiveQuery.rows updates (instant)
└──▶ encrypted envelope ──▶ outbox ──▶ relay (background)
relay ──▶ pull + decrypt ──▶ apply (last-writer-wins) ──▶ LiveQuery.rows updates
Add the engine
Copy SimplySyncDemo/Engine/*.swift and SimplySyncDemo/Kit/*.swift into your
app (the demo's layout), or build them as a local Swift package. The engine is
pure Foundation + Combine + CryptoKit + SQLite3 (in the iOS/macOS SDK), so there
are no external dependencies and it also runs on macOS.
1. Define a schema
A schema is { table: { column: Column } }. Column types come from the engine's
typed DSL and double as runtime validators.
let appSchema = Schema([
"category": [
"id": .id("category"),
"name": .nonEmptyString,
],
"entry": [
"id": .id("entry"),
"description": .nonEmptyString,
"amountCents": .finiteNumber,
"categoryId": .nullable(.id("category")), // nullable foreign key
],
])
You declare id per table. The engine adds three system columns to every
table automatically: createdAt, updatedAt (ISO/HLC strings), and isDeleted
(a 0 | 1 SQLite boolean). You never write CREATE TABLE — the engine derives it.
Built-in column types: .string, .nonEmptyString, .nonEmptyString1000,
.positiveInt, .finiteNumber, .sqliteBoolean, .simpleName, .mnemonic, and
.id("Table"). Wrap any with .nullable(...) to allow null, or
.maxLength(n, ...) to bound a string. Each validates on write and rejects bad
values with a typed message.
2. Create the engine
createSync(deps) takes the platform plumbing once, then returns a factory you
call with your schema and config — exactly like the TS createSync(deps)(schema, config).
let deps = SyncEngineDeps(
makeDriver: { name in try SQLite3Driver(path: dbPath(name)) }, // platform SQLite (§8)
secureStorage: KeychainStorage(), // OS secret store (§8)
reloadApp: { /* re-evaluate app state after restore/reset */ })
let engine = createSync(deps)(appSchema, SyncEngineConfig(
name: "my-app",
transports: [Transport(url: "http://localhost:4100")], // optional relay
syncIntervalMs: 3000)) // poll cadence
await engine.ready // resolves once the DB is open and the owner is loaded
On first run the engine mints a new owner (a fresh sf1_ recovery key, stored via
secureStorage). On later runs it loads the existing one.
3. Writing data
Mutations are synchronous against local SQLite and return a MutationResult. Each
also enqueues an encrypted envelope for sync — you don't manage that. Column values
are [String: SyncValue], and Swift literals work directly.
// insert — the engine generates the id and system columns
let result = engine.insert("entry", [
"description": "Coffee",
"amountCents": -450,
"categoryId": nil,
])
switch result {
case .ok(let id): print(id)
case .failure(let message): print(message)
}
// update — requires the id; updates only the fields you pass
_ = engine.update("entry", ["id": .string(id), "amountCents": -500])
// upsert — insert or update by id
_ = engine.upsert("entry", ["id": .string(id), "description": "Latte"])
Deletes are soft. Set the isDeleted system column and filter it out in your
queries:
_ = engine.update("entry", ["id": .string(id), "isDeleted": sqliteTrue])
Values are validated against the schema, so an invalid write returns
.failure(message:) rather than corrupting the row.
4. Querying data
Build a query once with createQuery from SQL plus the row type to decode into
(any Decodable struct). Double-quote table names so the reactive cache knows
which writes affect the query.
struct EntryRow: Decodable, Identifiable {
let id: String
let description: String
let amountCents: Double
let categoryName: String?
}
let entriesQuery = engine.createQuery("""
select e.id as id, e.description as description, e.amountCents as amountCents,
c.name as categoryName
from "entry" e
left join "category" c on c.id = e.categoryId
where e."isDeleted" = 0
order by e."createdAt" desc
""", as: EntryRow.self)
let rows = engine.loadQuery(entriesQuery) // one-shot read → [EntryRow]
let cached = engine.getQueryRows(entriesQuery) // cached snapshot
5. Reactive queries in SwiftUI
engine.liveQuery(query) returns a LiveQuery — the Swift analog of React's
useQuery. It's an ObservableObject whose rows republishes on the main thread
whenever a local write or an incoming sync changes the data. The engine is itself
an ObservableObject, so engine.syncState drives status UI.
struct Ledger: View {
@ObservedObject var engine: SyncEngine
@StateObject private var entries: LiveQuery<EntryRow>
init(engine: SyncEngine) {
self.engine = engine
_entries = StateObject(wrappedValue: engine.liveQuery(entriesQuery))
}
var body: some View {
VStack {
SyncBadge(state: engine.syncState) // re-renders on sync state change
Button("Add") {
_ = engine.insert("entry", ["description": "Tea", "amountCents": -300])
}
List(entries.rows) { e in // re-renders on any relevant change
Text("\(e.description): \(e.amountCents)")
}
}
}
}
engine.liveQuery(q)→ aLiveQuery<Row>; read.rows. Updates on a local write or an incoming sync.engine.syncState— the currentSyncState(§7), published for SwiftUI.engine.subscribeQuery(q) { … }— the low-level listenerliveQuerywraps.
6. Identity & recovery
A user's whole account derives from one secret. The engine manages it through
secureStorage and exposes the owner:
let owner = await engine.appOwner // AppOwner? (or engine.currentOwner, sync)
owner?.id // public owner id used for relay routing
owner?.recoveryKey // the secret to back up — an sf1_ key or a 24-word phrase
owner?.writeKey // bearer token presented to the relay (a capability)
owner?.mnemonic // the 24-word phrase, if the owner was derived from one
Surface owner.recoveryKey during onboarding so the user can save it. It is the
only thing that can decrypt their data — lose it and the data is unrecoverable
by design. The engine keeps it in the Keychain, never in plain SQLite.
Restore on a new device — boot with the saved secret and sync from scratch:
try await engine.restoreAppOwner(recoveryKey)
// By default this refuses to wipe local data unless the relay confirms the owner
// has data, so a wrong key or an unreachable relay can't destroy what's here.
try await engine.restoreAppOwner(recoveryKey, force: true) // intentional switch
Reset mints a brand-new owner and wipes local data:
await engine.resetAppOwner()
Both call your reloadApp afterward so the UI re-evaluates against the new owner.
7. Connecting a relay & sync state
A relay is optional — without one the app is purely local-first. Configure one
(or several) via transports, at creation or at runtime:
engine.setTransports([Transport(url: "https://relay.example.com")])
Setting transports triggers an immediate sync and starts the background poller. You can still force a sync (e.g. a "Sync now" button):
await engine.sync() // push the outbox, then pull + apply remote events
The engine also syncs opportunistically after local writes. Watch progress with
engine.syncState (getSyncState() for a synchronous read):
SyncState |
Meaning |
|---|---|
.initial |
Idle, nothing synced yet. |
.syncing |
A push/pull is in flight. |
.synced(lastSyncedAt:) |
Up to date. |
.notSynced(error:terminal:status:) |
Failed. |
A terminal failure (auth/quota/bad request — 400/401/403/413) won't fix itself
by retrying the same batch, so the engine backs the poller off (still retrying on
the next explicit sync()). Network and 5xx/429 errors retry at the normal cadence.
With multiple relays, every one receives a full copy of the outbox and
syncState stays .synced while any relay works. For the per-relay truth —
which relay is failing, and how full each one is against its per-owner storage
quota — use getRelayStatuses() (one GET /v1/status probe per relay):
for s in await engine.getRelayStatuses() {
print(s.url, s.reachable, "\(s.usedBytes ?? 0)/\(s.quotaBytes ?? 0) bytes")
}
For Android emulators the React guide notes
http://10.0.2.2:4100; iOS simulators reach your Mac athttp://localhost:4100.
8. Platform drivers
The engine is platform-agnostic: you inject a SQLite driver and a secret store.
SqliteDriver — a thin synchronous wrapper over the platform's SQLite:
protocol SqliteDriver: AnyObject {
func exec(_ sql: String, _ params: [SQLiteValue]) throws
func select(_ sql: String, _ params: [SQLiteValue]) throws -> [SQLiteRow]
func transaction<T>(_ body: () throws -> T) throws -> T
}
SQLite3Driver(path:) is the bundled libsqlite3 implementation (":memory:" for
tests).
SecureStorage — stores the recovery secret in the OS secret store:
protocol SecureStorage: AnyObject {
func getItem(_ key: String) -> String?
func setItem(_ key: String, _ value: String)
func removeItem(_ key: String)
}
KeychainStorage() is the default; InMemorySecureStorage() is for tests.
Engine API reference
| Member | Signature | Notes |
|---|---|---|
createSync |
(SyncEngineDeps) -> (Schema, SyncEngineConfig) -> SyncEngine |
Wire deps once, then build. |
engine.ready |
await engine.ready |
Resolves when DB open + owner loaded. |
engine.insert |
(String, [String: SyncValue]) -> MutationResult |
Generates id + system columns. |
engine.update |
(String, [String: SyncValue]) -> MutationResult |
Requires id; updates passed fields. |
engine.upsert |
(String, [String: SyncValue]) -> MutationResult |
Insert or update by id. |
engine.createQuery |
(String, [SQLiteValue] = [], as: Row.Type) -> Query<Row> |
Reusable typed query. |
engine.loadQuery |
(Query<Row>) -> [Row] |
One-shot read. |
engine.getQueryRows |
(Query<Row>) -> [Row] |
Cached snapshot. |
engine.liveQuery |
(Query<Row>) -> LiveQuery<Row> |
Reactive result (useQuery analog). |
engine.subscribeQuery |
(Query<Row>, () -> Void) -> () -> Void |
Low-level reactive primitive. |
engine.appOwner |
await -> AppOwner? |
{ id, recoveryKey, writeKey, mnemonic? }. |
engine.restoreAppOwner |
(String, force: Bool) async throws |
Restore from a key/phrase. |
engine.resetAppOwner |
() async |
New owner, wipes local data. |
engine.setTransports |
([Transport]) -> Void |
Set relays; triggers sync + polling. |
engine.sync |
() async |
Push outbox, pull + apply. |
engine.syncState / getSyncState() |
SyncState |
Reactive + synchronous status. |
engine.getRelayStatuses |
() async -> [RelayStatus] |
Per-relay health + storage usage (live GET /v1/status probe per relay). |
Differences from the React engine
- Raw SQL, not Kysely.
createQuerytakes a SQL string + aDecodablerow type instead of a typed query builder. The reactive cache (table extraction, refresh-on-change) works the same way. LiveQuery/ObservableObject, not hooks. SwiftUI observesLiveQuery.rowsandengine.syncStateinstead ofuseQuery/useSyncState.- Not ported: encrypted DB export/import (snapshot), legacy import, binary blobs, and the fluent index builder. The protocol, schema, mutations, queries, identity, relay sync, and LWW are all here.
- Identity interoperates by
sf1_key and phrase — Swift uses the same SLIP-21 labels as the TS protocol ("SimplySync"); see the interop boundary.
Common pitfalls
- Quote table names in SQL (
from "entry") so the reactive cache can tell which writes refresh a query. - Filter
isDeleted. Soft-deleted rows stay in the table; addwhere "isDeleted" = 0to queries that should hide them. - Make row fields match what you insert. A non-optional
Boolfield over a column you leftNULLwill fail to decode and the row is dropped — provide the value or make the field optional. - Keep column names lowercase-ASCII so canonical-JSON ordering matches the other clients (see parity rules).
- Don't hard-delete. Use soft deletes so a delete can lose to a later edit and propagate to other devices.
Next: Native clients (the cross-language map & interop boundary) · Sync & the relay (the wire protocol the engine speaks).