API overview

API overview, Swift

Generated Swift is written the way a Swift engineer would write it by hand: value types, error enums you catch by case, async/await, AsyncSequence and an @Observable store. Swift 6 language mode, strict concurrency, no Objective-C.

undra bindgen writes a Swift package (Sources/<Module>/Generated/*.swift and a Package.swift) that depends only on UndraRuntime. The shapes below come from the playground's generated code and the specification (§10.1).

Reference. Every declaration the playground generates, with its docs, is in the Swift reference.

Loading the core#

Load the core once, before any store is created, through the entry generated for it: Undra and the core's namespace ([core] namespace in undra.toml, by default the core's package name), here UndraPlaygroundCore. It checks the core's schema hash against the one the bindings were generated from and throws a clear error if they differ.

UndraBootstrap.swift
import UndraRuntime
import PlaygroundCore

@MainActor
static func start() throws {
    let adapters = Adapters.platformDefault              // URLSession, Keychain, files, …
        .replacing(PlaygroundNetwork.shared)             // swap one port
    try UndraPlaygroundCore.load(.inproc(adapters: adapters))
}

In debug builds you can attach to the core that undra dev serves instead, with UndraPlaygroundCore.load(.remote(url: url, adapters: adapters)). After loading, every generated type defaults to its entry's core: try Todos() is try Todos(ctx: UndraPlaygroundCore.core). Before the core is loaded, or after shutdown(), that is a shut-down placeholder: constructors throw UndraCallError.unavailable(.closed) instead of stopping the app, and the first use logs what to do. The core links like any static library, with no -force_load, so an app can carry more than one.

Type mapping#

RustSwift
bool, f32, f64Bool, Float, Double
i8 … i64, u8 … u64Int8 … Int64, UInt8 … UInt64
String, BytesString, [UInt8]
Duration, Timestamp, UuidDuration, Date, UUID
Option<T>, Vec<T>T?, [T]
Map<K, V>[K: V]
record, enum, errorstruct, enum, enum conforming to Error

Names follow the platform: Rust snake_case becomes camelCase for methods and fields and PascalCase for types, and enum variants become lowerCamel cases.

Records#

A record is a struct: Sendable, Hashable and Codable, with a memberwise initializer.

swift
/// One item of the to-do list.
public struct Todo: UndraRecord, Sendable, Hashable, Codable {
    public var id: UUID
    public var title: String
    public var done: Bool

    public init(id: UUID, title: String, done: Bool)
}

The wire codec (undraEncode, undraDecode) is generated beside it and is not something you call.

Enums#

A unit enum is a UInt16-backed enum that is also CaseIterable, so it works directly in a Picker. A data enum carries its fields as associated values.

swift
public enum Filter: UInt16, UndraEnum, CaseIterable, Sendable, Codable {
    case all = 0, active = 1, done = 2
}

public enum Figure: UndraEnum, Sendable, Hashable {
    case circle(radius: Double)
    case rect(width: Double, height: Double)
    case label(String)
    case empty
}

Errors#

A Rust #[undra::error] becomes a Swift enum conforming to Error (and LocalizedError, with the message from #[error("…")]). A generated call that can fail is throws (or async throws) and fails with exactly one of three things: its own error, thrown as the enum itself; CancellationError, when the calling task was cancelled; or UndraCallError, for anything else that went wrong with the call. The method's - Throws: documentation names its error.

swift
public enum TodoError: UndraError, Error, Sendable, Hashable {
    case emptyTitle            // "the title cannot be empty"
    // a variant that wraps another error carries it: case http(HttpError)
}

do {
    _ = try await todos.add(title: draft)
} catch TodoError.emptyTitle {
    problem = "Enter a title"
} catch is CancellationError {
    // the view went away; nothing to do
} catch {
    problem = error.localizedDescription   // an UndraCallError: panicked, refused, …
}

UndraCallError is Sendable, Equatable and LocalizedError, so localizedDescription reads well in an alert. Generated Swift never stops the app: no reply, cancellation, lost connection or undecodable byte reaches fatalError, in debug or release.

CaseWhen
.cancelledByCoreThe core cancelled the call: a restore replaced the object it ran on, or the core shut down. Not a CancellationError, because your task was not cancelled and the write did not land.
.panicked(message:backtrace:)The core panicked while running the call. It caught the panic and keeps working.
.refused(reason:)The core would not run the call: the object was closed or replaced by a restore, the call came from inside one of the core's callbacks (E_REENTRANT), or its arguments did not decode.
.unavailable(UndraTransportError)The core cannot be reached: it was shut down or never loaded, or the remote connection closed or timed out. The UndraTransportError says which: .closed, .timedOut or .connectionLost. While undra dev is reconnecting, every call fails with it at once (.connectionLost); so does every call on a core that a rebuild closed for good.
.malformed(String)The core answered with something the bindings cannot read. After a successful schema check this is a bug in Undra; report it with the text.

Commands report instead of throwing. A synchronous method that returns nothing and has no error type, such as todos.toggle(id:), stays non-throwing, so a Button action or a Binding setter calls it directly. If it fails, the runtime logs the failure at error level, passes an UndraUnhandledError to LoadOptions.onError and returns. A store's properties change only from the core's change-sets, so a refused command leaves the screen showing exactly what the core holds. A store that cannot decode a change from the core skips it and reports it the same way.

swift
try UndraPlaygroundCore.load(.inproc(
    onError: { unhandled in
        // "Todos.toggle failed: the Undra core refused the call: …"
        logger.error("\(unhandled.description, privacy: .public)")
    }
))

The handler runs on the thread that made the call (the main actor for a store), so keep it short, and it must not call into Undra. To stop at the failing line in debug builds, install onError: { assertionFailure("\($0)") }.

A lost connection is not an onError event. Over undra dev, a command tapped while the dev server is away fails with .unavailable too, and the connection state (connectionState on the core load returned, or LoadOptions.onConnectionChange) already says so, once. A failure that is the connection being down (.connectionLost) is therefore logged at warning level and not handed to onError; a crash reporter would otherwise get one event per tap for as long as the laptop sleeps. Everything else .unavailable is still reported: a command on a core you shut down yourself, and a timeout.

Typed throws are for ports. A port requirement keeps throws(E), because your app implements it and E tells it which errors the core understands; swift_typed_throws = false in undra.toml emits plain throws there. Calls never use typed throws: Swift's own cancellation (Task.checkCancellation(), Task.sleep) throws an untyped CancellationError, which a throws(E) function cannot pass on.

Objects and functions#

An object is a final class holding a handle into the core. Sync methods that return a value are throws, async methods are async throws, a sync method that returns nothing is a command (it reports instead of throwing, see Errors), and stream methods return an AsyncThrowingStream that ends with the same three failures. A free Rust function is a free Swift function that takes the core last.

swift
public final class Probe: UndraObject, @unchecked Sendable {
    public init(ctx: UndraCore = UndraPlaygroundCore.core) throws   // the constructor `new`
    public func counters() throws -> ProbeCounters         // sync
    public func wait(ms: UInt32) async throws -> UInt32    // async
    public func ticks(count: UInt32) -> AsyncThrowingStream<UInt32, Error>   // stream
    public func reset()                                     // a command
}

public func add(a: Int32, b: Int32, ctx: UndraCore = UndraPlaygroundCore.core) throws -> Int32
public func area(_ figure: Figure, ctx: UndraCore = UndraPlaygroundCore.core) throws -> Double   // throws LabError

Sync methods on an in-process core call straight into the core (the call_sync entry of its table). Streams apply real backpressure: the generated code grants credit as your loop consumes items, and dropping the stream stops it in the core.

Release an object with close(), which is idempotent. deinit is only a backstop for objects that go out of scope without it, so hold an object for as long as you need its state.

Stores#

A store is an @Observable class on the MainActor. Every signal is a public private(set) var, so SwiftUI tracks reads and re-renders when a change-set is applied. Computed signals are read-only properties the core keeps up to date.

swift
@MainActor @Observable
public final class Todos: UndraStore {
    public private(set) var todos: [Todo]
    public private(set) var filter: Filter
    public private(set) var visible: [Todo]        // computed by the core
    public private(set) var remaining: UInt32      // computed by the core

    public init(ctx: UndraCore = UndraPlaygroundCore.core) throws
    public func setFilter(_ f: Filter)                       // a command
    public func add(title: String) async throws -> Todo     // throws TodoError
}

Reading todos.visible is a plain property read: it never calls into the core. The initial values are decoded from the change-set the core emits when the store starts observing during init. Commands such as toggle(id:) do not throw, so a Button calls them without try:

swift
struct TodosScreen: View {
    let todos: Todos
    var body: some View {
        List(todos.visible, id: \.id) { todo in
            Button(todo.title) { todos.toggle(id: todo.id) }
        }
        .toolbar { ToolbarItem(placement: .principal) { Text("\(todos.remaining) left") } }
    }
}

Query handles#

A #[undra::query] becomes a store with five signals: data, status, error, fetching and updatedAt, plus refetch() and invalidate(). Construct one to observe the query; it fetches when the data is missing or stale.

swift
let inbox = try RemoteTodosQueryHandle(list: "inbox")
// inbox.data: [RemoteTodo]?, inbox.status, inbox.fetching, inbox.error, inbox.updatedAt

See Queries & mutations for what the core does behind it.

Ports#

The ten standard ports are implemented by UndraRuntime (URLSession, Keychain, FileManager, NWPathMonitor and so on) and are not generated into your module. A port you define yourself becomes a protocol your app implements and registers with the adapters:

swift
public protocol Http: UndraPort {
    func request(_ req: HttpRequest) async throws(HttpError) -> HttpResponse
}

See Ports & adapters for the defaults and for swapping one.

Threading and lifetime#

Edit this page on GitHub