Palbase
Sign inGet started

iOS SDK

Flags

pb.flags is the feature-flag surface of the iOS SDK. Resolution — the Environment-wide default, then any override that applies to this user — happens server-side, so the SDK only ever holds already-resolved values. It keeps them in an observable, always-fresh cache and exposes them through synchronous, non-throwing reads. The cache is observable per key, so reading a flag inside a SwiftUI View body is all it takes to get live UI: when that flag changes, that view re-renders and nothing else does.

Flag definitions live on the Environment, not in your repository. You create them with palbase flags add or from the panel and they take effect immediately — there is no file to commit and no deploy to wait for. See Stack Settings for the writing side and Flags for reading and overriding them from backend code.

Quick example

import SwiftUI
import Palbe

struct TodoListView: View {
    var body: some View {
        List {
            // Reading a flag in `body` registers a per-key observation:
            // this view re-renders when (and only when) THIS flag changes.
            if pb.flags.isEnabled("new_composer") {
                NewTodoComposer()
            } else {
                LegacyTodoField()
            }
        }
    }
}

No setup, no fetching, no loading state in the common case: the SDK hydrates from a disk cache instantly at launch, then syncs in the background — see How sync works.

Reading flags

All reads are synchronous, served from the in-memory cache, and never block or throw. The typed reads take a default that is returned when the flag is missing or cannot be coerced to the requested type.

MethodReturnsNotes
isEnabled(_ key: String, default: Bool = false)BoolBoolean flag
bool(_ key: String, default: Bool = false)BoolAlias of isEnabled
getString(_ key: String, default: String)StringString flag
getInt(_ key: String, default: Int)IntInteger flag
getDouble(_ key: String, default: Double)DoubleDouble flag
getVariant(_ key: String)String?Variant name for a string flag with variants; nil when unset
get(_ key: String)FlagValue?The raw resolved value box, if present
all()[String: FlagValue]Every resolved flag in the current snapshot
let pageSize  = pb.flags.getInt("todo_page_size", default: 25)
let banner    = pb.flags.getString("home_banner_text", default: "")
let listStyle = pb.flags.getVariant("list_style") ?? "compact"

Note: pb.flags is @MainActor — reads come from the main-actor-isolated observable store. SwiftUI bodies and most UI code are already on the main actor, so this is invisible in practice. From a background context, hop first: await MainActor.run { pb.flags.isEnabled("new_composer") }.

Reading pb.flags before anything else has touched pb triggers the SDK's lazy configuration from Palbase-Info.plist. If a store cannot be produced, a fresh empty one is handed back rather than trapping — every read then returns its default, and a view shows the fallback UI instead of crashing.

FlagValue

get(_:) and all() return values as FlagValue, an enum over the types a flag can hold on this platform:

public enum FlagValue: Sendable, Equatable, Codable {
    case bool(Bool)
    case string(String)
    case int(Int)
    case double(Double)
    case null
}

There is no object or array case, and no JSON accessor on FlagsNamespace or FlagsStore. FlagValue carries lenient typed accessors that coerce across compatible representations:

AccessorReturnsCoercion rules
boolValueBool?.bool as-is; .int!= 0; .string"true", "1", "yes", "on" (case-insensitive); otherwise nil
stringValueString?.string as-is; .bool/.int/.double stringified; .nullnil
intValueInt?.int as-is; .double truncated; .bool1/0; .string parsed; otherwise nil
doubleValueDouble?.double as-is; .int widened; .string parsed; otherwise nil

The convenience reads apply the same coercion, so a flag stored as "42" still reads as 42 through getInt.

Warning: palbase flags add accepts --type json, and a JSON object or array has no FlagValue case. The decoder rejects it, and because the snapshot is decoded as one document, a single JSON-valued flag makes the whole snapshot fail to decode — silently. The SDK keeps serving whatever it last held and every read falls back to its default, with no error anywhere. Do not declare json flags on an Environment an iOS app reads.

Observing changes

Three ways to react to a flag change — pick the one that matches your context.

SwiftUI

Read the flag in body, as in the quick example. The backing store is @Observable with per-key tracking: a view re-renders only when a flag it actually read changes. A sync tick that brings no changes invalidates nothing.

Note: all() is the exception — it binds the view to the whole snapshot, so a view reading all() re-renders on any flag change, by design. Prefer single-key reads for per-flag granularity.

To hold the observable store directly — to pass into a view model, say — pb.flags.observable exposes it as a FlagsStore with the same reads:

@MainActor @Observable public final class FlagsStore {
    public private(set) var snapshot: FlagsSnapshot
    public func value(for key: String) -> FlagValue?
    public func bool(_ key: String, default fallback: Bool) -> Bool
    public func string(_ key: String, default fallback: String) -> String
    public func int(_ key: String, default fallback: Int) -> Int
    public func double(_ key: String, default fallback: Double) -> Double
    public var all: [String: FlagValue] { get }
}

Reading through pb.flags.* in a view body is the supported reactive path; the store accessor exists for callers that need a concrete object.

Closure

let unsubscribe = pb.flags.onChange { snapshot in
    print("flags updated to version \(snapshot.syncVersion)")
}

// later
unsubscribe()

onChange returns an Unsubscribe (@Sendable () -> Void). Hold it for as long as you want the observer alive, and call it to stop.

Async stream

for await snapshot in pb.flags.changes {
    applyTheme(snapshot["theme"]?.stringValue ?? "light")
}

changes is an AsyncStream<FlagsSnapshot>; the current snapshot is yielded immediately on subscription, then one per change.

Both observation styles deliver a FlagsSnapshot — an immutable picture of every flag at one sync version:

public struct FlagsSnapshot: Sendable, Equatable {
    public let values: [String: FlagValue]        // resolved values by key
    public let syncVersion: String                // monotonic server version
    public let sources: [String: String]          // per-flag source, e.g. "override" / "default"
    public static let empty
    public subscript(_ key: String) -> FlagValue? // shorthand for values[key]
}

Control: refresh() and ready()

MethodWhat it does
func refresh() asyncForce an immediate full re-fetch from the server
func ready() asyncSuspend until the first snapshot — disk or network — is available

ready() is for a launch decision that should not act on a default:

.task {
    await pb.flags.ready()
    showOnboardingV2 = pb.flags.isEnabled("onboarding_v2")
}

On warm installs ready() resolves near-instantly from the disk cache; only a true first launch waits on the network.

How sync works

You manage none of this — it is the behaviour behind the reads.

  1. Cold start. The last-known snapshot is loaded from disk immediately, so reads are correct before any network I/O.

  2. Snapshot. An authoritative full snapshot is fetched from GET /v1/user-flags/snapshot, sending If-None-Match: <syncVersion> so an unchanged server answers 304 and nothing is re-parsed.

  3. Delta polling. The SDK polls GET /v1/user-flags/delta?since=<syncVersion> every 30 seconds.

  4. Realtime fast path. Flags LISTENS to the shared realtime socket — it is not built into it. While every flags channel the SDK wants has been joined (the server acknowledged the join, not merely that a socket is open), the 30-second poll is suspended: the platform pushes the change and the SDK applies it immediately. The moment any of those channels stops carrying — refused, offline, or newly wanted and not yet acknowledged — polling resumes as the offline fallback. Flag flips land in seconds when the app is live, and nothing is missed when it is not.

    The distinction is not pedantry. Before 0.58.0 the poll was suspended on the SOCKET being up, and a socket whose channels the server had refused looked exactly like a working one: no push, no poll, and nothing anywhere saying so. What the poll waits on now is the acknowledgement, per channel.

  5. Auth transitions. Sign-in, sign-out and token refresh each trigger a full re-snapshot, so per-user overrides appear the moment the user signs in and disappear when they sign out. See Auth.

  6. Backgrounding. Polling pauses when the app enters the background and resumes, with an immediate refresh, on foreground.

A failed fetch is never destructive: a transport failure, a 304, or a snapshot that will not decode all leave the existing cache in place.

Setting overrides

There is no override-writing API on iOS — a client cannot decide its own flags. An override is written on the server, and the sync engine above delivers it.

From the CLI, against the linked Environment:

palbase flags user set <user-id> new_composer --type boolean --value true
palbase flags user list <user-id>
palbase flags user unset <user-id> new_composer

Note: --type says how to serialise what you typed; it is not sent to the server. The declared type lives on the flag definition, and the stack answers 400 type_mismatch when they disagree and 404 key_not_found for a key that was never declared with palbase flags add.

From backend code:

// In your backend (not on iOS):
import { Controller, Post, User, Flags } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

@Controller("/beta")
export class BetaController {
  @Post("/join")
  async join(@User() user: UserT): Promise<void> {
    await Flags.$asService().setOverrideForUser(user.id, "new_composer", true);
  }
}

Flags.$asService() also has clearOverrideForUser, which is how a "leave the beta" route puts the user back on the Environment default. The signed-in device picks either change up through the realtime push, or the next 30-second poll, and any SwiftUI view reading new_composer re-renders.

Flags the platform owns

Keys beginning palbase. are managed by the platform, and only two exist. Any other palbase.* key is refused with 400 reserved_key_prefix, so the namespace cannot be extended from your side.

KeyTypeWhat it does
palbase.debug_consoleboolGates pb.debug.isEnabled, pb.debug.view and startLiveSession() — see Debug Console
palbase.force_updateboolThe platform's kill switch for builds you no longer support. This SDK does not act on it: read it like any other flag with pb.flags.isEnabled("palbase.force_update") and decide what to show

A managed key is a real row on the Environment, not an override-only key. palbase flags add cannot create one — it refuses a dot outright — so declare it from Studio or with a direct PUT /v1/management/flags/{key}. Neither key exists on a fresh Environment, and a per-user override of a key the Environment has never declared answers 404 key_not_found: declare the row first, then open it per user.

Warning: palbase.debug_console fails closed on every Environment. Absent, false, not yet synced, a failed fetch, or an SDK that was never configured all read false. Only an affirmative true opens the console, and you turn it on as a per-user override — there is no Environment where it is on by default, and no build flag that opens it.

Reserved namespace

flags is one of nine namespaces codegen refuses to emit, so a generated method can never collide with pb.flags. The full set is auth, analytics, flags, realtime, notifications, perf, messaging, purchases and debug. A backend controller whose namespace lands on one of them is skipped with a visible comment in the generated file:

// codegen: skipped reserved namespace "flags" (SDK-owned)

See Codegen for how a namespace is derived in the first place.

  • Stack Settingspalbase flags add, flags remove, and per-user overrides from the CLI.
  • Flags (backend) — reading flags and writing overrides from a handler.
  • Flags (web) — the same values in the web SDK.
  • Realtime — the shared socket that powers the flags fast path.
  • Debug Console — what palbase.debug_console unlocks.