The StitchDB Swift client for iOS, macOS, tvOS, watchOS, visionOS and Linux: database, auth, storage, realtime, Edge Functions, errors, push, purchases, organizations, flags, jobs, metering, collaborative documents and serverless SQL.
#Install
Swift 6.1 (Xcode 16.3) or newer, with Swift Package Manager. In Xcode: File → Add Package Dependencies… and paste https://github.com/stitchdb/stitchdb-swift.git. Or in Package.swift:
dependencies: [
.package(url: "https://github.com/stitchdb/stitchdb-swift.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "StitchDB", package: "stitchdb-swift")]),
]The current version is 1.4.0.
#Create a client
import StitchDB
let stitchdb = StitchClient(
stitchURL: URL(string: "https://<project-ref>.p.stitchdb.com")!,
stitchKey: "<anon-key>"
)Create one client and share it. The session is kept in the keychain on Apple platforms; pass options: StitchClientOptions(auth: .init(storage: ...)) to store it elsewhere (required on Linux).
#Database
struct Todo: Codable, Identifiable {
var id: Int?
var task: String
var isComplete: Bool = false
enum CodingKeys: String, CodingKey { case id, task, isComplete = "is_complete" }
}
// Select with filters, order and paging
let todos: [Todo] = try await stitchdb.from("todos")
.select("id, task, is_complete")
.eq("is_complete", value: false)
.order("created_at", ascending: false)
.range(from: 0, to: 9)
.execute().value
// Insert (returning the row), update, upsert, delete
let created: Todo = try await stitchdb.from("todos").insert(Todo(task: "Ship it")).select().single().execute().value
try await stitchdb.from("todos").update(["is_complete": true]).eq("id", value: created.id!).execute()
try await stitchdb.from("todos").upsert(Todo(id: 1, task: "Ship it today")).execute()
try await stitchdb.from("todos").delete().eq("id", value: 1).execute()
// Count, functions
let count = try await stitchdb.from("todos").select("*", head: true, count: .exact).execute().count
let open: Int = try await stitchdb.rpc("count_open_todos", params: ["owner": userId]).execute().valueFilters: eq, neq, gt, gte, lt, lte, like, ilike, in, contains, textSearch, or("status.eq.open,priority.gt.3"), not, match. See Filtering.
#Auth
try await stitchdb.auth.signUp(email: "[email protected]", password: "a-strong-password")
let session = try await stitchdb.auth.signIn(email: "[email protected]", password: "a-strong-password")
let user = try await stitchdb.auth.user()
// Magic link / one-time code
try await stitchdb.auth.signInWithOTP(email: "[email protected]", redirectTo: URL(string: "myapp://login-callback"))
try await stitchdb.auth.verifyOTP(email: "[email protected]", token: "123456", type: .email)
// OAuth (Apple platforms: opens ASWebAuthenticationSession and returns signed in)
try await stitchdb.auth.signInWithOAuth(provider: .github, redirectTo: URL(string: "myapp://login-callback"))
try await stitchdb.auth.update(user: UserAttributes(data: ["display_name": .string("Ada")]))
try await stitchdb.auth.resetPasswordForEmail("[email protected]")
try await stitchdb.auth.signOut()
for await (event, session) in stitchdb.auth.authStateChanges {
// .initialSession, .signedIn, .signedOut, .tokenRefreshed, .userUpdated, ...
}Magic links and OAuth return through your app's URL scheme: add it to the redirect URLs and pass the URL to stitchdb.auth.session(from: url) in .onOpenURL. Admin calls (auth.admin.createUser(attributes:), listUsers(), deleteUser(id:)) need a client with the service_role key, on a server only.
#Storage
let bucket = stitchdb.storage.from("avatars")
try await bucket.upload("user-1/avatar.png", data: imageData, options: FileOptions(contentType: "image/png", upsert: true))
let data = try await bucket.download(path: "user-1/avatar.png")
let publicURL = try bucket.getPublicURL(path: "user-1/avatar.png") // public buckets
let signedURL = try await bucket.createSignedURL(path: "user-1/avatar.png", expiresIn: 60) // private
let files = try await bucket.list(path: "user-1")
try await bucket.move(from: "user-1/avatar.png", to: "user-1/old.png")
try await bucket.remove(paths: ["user-1/old.png"])#Realtime
// Database changes
let channel = stitchdb.channel("todos-changes")
let inserts = channel.postgresChange(InsertAction.self, schema: "public", table: "todos")
try await channel.subscribeWithError()
Task { for await insert in inserts { print(insert.record) } }
// Broadcast
let room = stitchdb.channel("room-1")
let cursors = room.broadcastStream(event: "cursor")
try await room.subscribeWithError()
Task { for await message in cursors { print(message) } }
try await room.broadcast(event: "cursor", message: ["x": 10, "y": 20])
// Presence
let presence = room.presenceChange()
Task { for await change in presence { print(change.joins, change.leaves) } }
try await room.track(["user": "ada"])
await stitchdb.removeChannel(room)#Edge Functions
struct Hello: Decodable { let message: String }
let hello: Hello = try await stitchdb.functions.invoke(
"hello-world",
options: FunctionInvokeOptions(body: ["name": "Ada"])
)#Errors
do {
try await stitchdb.from("todos").insert(["task": ""]).execute()
} catch let error as PostgrestError {
print(error.code ?? "", error.message, error.hint ?? "")
} catch let error as AuthError {
print(error.errorCode, error.message)
} catch {
print(error)
}Each service throws its own error type: PostgrestError, AuthError (with an errorCode such as .invalidCredentials), StorageError and FunctionsError (.httpError(code:data:), .relayError). See Errors.
#Hosted sign-in page
// Opens the page in a web authentication session and returns signed in
let session = try await stitchdb.auth.signInWithHostedPage(
redirectTo: URL(string: "myapp://login-callback")!,
view: .signUp // optional: .signIn, .signUp, .magicLink or .forgot
)
// Or only the URL, for your own browser flow
let url = try stitchdb.auth.getHostedPageURL(redirectTo: URL(string: "myapp://login-callback")!, lang: "de")The hosted sign-in page has sign-in, sign-up, e-mail links and codes, password reset and two-step codes, with no auth screens to build. The sdbl_ code it sends back is exchanged by session(from:). On platforms without ASWebAuthenticationSession, pass your own launchFlow.
When the project turns on its built-in sign-up check, signUp and signInWithOTP solve it (on a background task) and send the request once more. There is no widget to add. They also add the device's language as locale to the user metadata (unless it has locale, language or lang), so auth e-mails go out in the user's language.
#Push notifications
// AppDelegate: application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
try await stitchdb.push.register(.apns(deviceToken, topics: ["news"]))
try await stitchdb.push.subscribe(topic: "offers", device: .apns(deviceToken))
// The in-app inbox
let inbox = try await stitchdb.push.inbox(unread: true)
try await stitchdb.push.markAllRead()
let stop = try await stitchdb.push.onNotification { note in showBanner(note.title) }
// Server side, with the service key
try await admin.push.send(PushMessage(userID: id, title: "Your order shipped", data: ["order": "42"]))Methods: register, unregister, subscribe, unsubscribe, inbox, markRead, markAllRead, deleteNotifications, onNotification, config, and with the service key send and status. Devices: .apns(_:) (token as Data) or .apns(token:) (hex), .fcm(token:), .web(endpoint:p256dh:auth:). Errors are PushError. In these examples admin is a client created with the service_role key, on a server only. See Push notifications.
#In-app purchases
// After a StoreKit 2 purchase (or for each update in Transaction.updates)
let result = try await product.purchase()
if case .success(let verification) = result { try await stitchdb.purchases.sync(verification) }
if try await stitchdb.purchases.hasEntitlement("pro") { unlockPro() }Methods: sync (a StoreKit VerificationResult, a PurchaseInput, or sync(signedTransaction:) with the JWS on Linux), me, hasEntitlement, config, and with the service key refresh(userID:). See In-app purchases.
#Organizations
let team = try await stitchdb.orgs.create(name: "Acme")
try await stitchdb.orgs.invite(team.id, email: "[email protected]", role: "admin")
let mine = try await stitchdb.orgs.list()
try await stitchdb.orgs.setActive(team.id) // the session's org_id / org_role claims are now for AcmeMethods: create, update, delete, list, members, invite, invites, revokeInvite, acceptInvite(token:), setRole, removeMember, leave, transferOwnership, setActive. Send the header x-org-id (OrgsClient.headerName) to act in another organization for one request. See Organizations.
#Realtime message history
let room = stitchdb.channel("room:42") { $0.history = RealtimeHistoryConfig(since: Date().addingTimeInterval(-3600)) }
let messages = room.onBroadcast(event: "chat") { message in show(message) } // keep the token
try await room.subscribeWithError()
try await room.publish(event: "chat", payload: ["text": "hi"]) // stored, then broadcastAfter every join the missed messages come first (with meta.replayed), in order, then the live ones. See Message history.
#Search
let hits = try await stitchdb.search("articles", "how do refunds work", options: SearchOptions(limit: 10, mode: .hybrid))
for row in hits.data { print(row) } // best first; hits.scores in the same orderPass as: Article.self to decode rows into your type. Modes: .hybrid, .keyword, .semantic; note says when the search fell back. See Search.
#Feature flags
try await stitchdb.flags.load(attributes: ["app_version": "2.4.1"])
if stitchdb.flags.isOn("new-checkout") { showNewCheckout() }
let limit = stitchdb.flags.get("upload-limit-mb", default: 10)
let token = stitchdb.flags.onChange { keys in refresh(keys) }Methods: load, refresh, setAttributes, get, value, isOn, detail, all, onChange, stop, and with the service key evaluate. Values are kept on the device. See Feature flags.
#Background jobs
// Service key, on your server
let run = try await admin.jobs.start("export", payload: ["user_id": .string(id)], options: JobStartOptions(mode: .long))
let now = try await admin.jobs.get(run.id)
let logs = try await admin.jobs.logs(run.id)Methods: start (JobStartOptions: idempotency key, delay, run time, attempts, mode), get, list, cancel, retry, logs. See Background jobs.
#Usage metering
try await stitchdb.metering.record("exports") // signed in: meters marked "apps may record"
let usage = try await admin.metering.usage(customer: org.id, group: .month)
let csv = try await admin.metering.exportCSV(group: .day)Methods: record, recordMany, usage, customers, exportCSV. See Usage metering.
#Collaborative documents
let collab = stitchdb.collab(yjs: MyYrsAdapter())
let room = try await collab.open("plan-2026", document: myDoc, create: true, title: "Plan 2026")
room.setPresence(["name": "Ann", "cursor": 4])
let token = room.onPresence { peers in drawCursors(peers) }
try await room.snapshot(label: "Before the review")
try await collab.share("plan-2026", userID: bo, role: "editor")Yjs documents kept in sync between everyone who has them open. The SDK carries Yjs updates as bytes: plug in any Yjs implementation through CollabYjs (mergeUpdates, makeDocument) and CollabDocument (applyUpdate, encodeStateAsUpdate, observeUpdates). Rooms also have snapshots, loadSnapshot, onSynced, onStatus, onError and close; the client has list, members, unshare and close. See Collaborative documents.
#GraphQL
let answer = await stitchdb.graphql("query { todosCollection(first: 5) { edges { node { id task } } } }")
if let error = answer.error { print(error.message) }
let data = try answer.decode(as: MyData.self)graphql never throws: failures are in error. It also takes variables and operationName. See GraphQL.
#Status page
let page = try await stitchdb.status.get()
if page.overall != "operational" { showBanner(page.overallLabel) }
try await stitchdb.status.subscribe(email: "[email protected]")badgeURL(component:label:) gives a status badge image. See Status page.
#Serverless SQL
let sql = try StitchSQL(connection: ProcessInfo.processInfo.environment["STITCHDB_SQL_URL"]!)
let rows = try await sql.query("select * from todos where owner = $1 limit 20", [.string(userID)])
try await sql.transaction([
SqlStatement("update stock set left = left - 1 where sku = $1", [.string(sku)]),
SqlStatement("insert into orders (sku) values ($1)", [.string(sku)]),
])
let db = try await sql.connect() // interactive transactions over a WebSocket session
try await db.transaction { tx in try await tx.query("update accounts set balance = balance - 1 where id = $1", [1]) }
db.close()Methods: query (rows, or as: T.self), execute (the full result), transaction and connect; a SqlSession adds begin, commit, rollback and onNotice. Errors are SqlError. See Serverless driver.