React Native

@voltro/react-native — the credential-free mobile plumbing: registerDevice + the _voltro_devices table, defineDeepLink + its matcher, useBackgroundSync, and offline-first client defaults + connection status.

The React-client bindings are import-safe in React Native — every DOM touch in @voltro/client is typeof window-guarded, so nothing crashes at import — and the client runtime now builds on RN: @voltro/client's buildApiRuntime constructs the ApiHandle (runtime + subscription cache + rpc client) over a WebSocket you inject, so RN passes its own globalThis.WebSocket and gets the same client stack the web app uses, without pulling in @voltro/web.

Still open before the loop is proven end-to-end on a device: the device boot itself — everything here is unit-tested without a simulator, so booting a real Metro runtime is the remaining verification — and the push sender adapters (APNs / FCM need per-tenant credentials). @voltro/react-native ships the mobile-specific plumbing around that, limited to the parts that need no per-tenant credentials and no native runtime: device registration, background-sync scheduling, offline-first defaults, a connection-status surface, the deep-link declaration shape (discovered by voltro mobile codegen), and the Expo platform adapter that hands the native modules you already have — secure store, AppState, Linking, notifications, updates, biometrics — to @voltro/client as one injected object.

The OS half — defineBackgroundSyncTask

useBackgroundSync runs while the app is in the foreground. For a closed app to catch up, the OS has to run the sync, and it does so on its own terms: best effort, throttled, no guarantee — iOS decides when and whether a background fetch runs, Android honours the minimum interval loosely. defineBackgroundSyncTask binds the same sync to expo-background-fetch + expo-task-manager, both passed in structurally (the package imports neither):

// src/backgroundSync.ts — module scope, because expo-task-manager wants defineTask there
import * as TaskManager from 'expo-task-manager'
import * as BackgroundFetch from 'expo-background-fetch'
import { defineBackgroundSyncTask } from '@voltro/react-native'

export const syncTask = defineBackgroundSyncTask({
  taskManager: TaskManager,
  backgroundFetch: BackgroundFetch,
  run: async () => { const pulled = await sync(); return pulled > 0 ? 'new-data' : 'no-data' },
  minimumIntervalSeconds: 15 * 60,   // Android's hint; iOS ignores it
  onOutcome: (outcome, error) => { if (outcome === 'failed') reportFailure(error) },
})

// after login, or when the user opts in
await syncTask.register()            // idempotent
await syncTask.availability()        // 'available' | 'denied' | 'restricted' | 'unknown'

The task body maps run's outcome to the fetch result the OS expects and turns a throw into 'failed' — a background run never crashes the app, and every outcome reaches onOutcome so the sync banner shows the truth. runNow() executes the same body by hand. This is built and unit-tested against fakes; an OS actually scheduling it on a device is the runner proof still open in the programme plan.

Building the client on React Native

startMobileApis() connects every api your app declares and keeps them connected — over the same supervisor the web client uses, not a mobile copy of it: exponential backoff, generation tracking, and the stale-seed gate that must never carry one subject's rows into the next one's screens.

import { startMobileApis, toApiHandles } from '@voltro/react-native'
import { mobileApis } from './.framework/mobileApis.generated'   // from `voltro codegen`

const apis = mobileApis(() => 'ws://192.168.1.20:4000/ws')

const supervisor = startMobileApis({
  apis,
  onChange: (clients) => setHandles(toApiHandles(apis, clients)),
})
// later: supervisor.dispose() — or supervisor.reconnect() after a sign-in

buildApiRuntime() from @voltro/client is the layer underneath, if you want one connection without supervision:

import { buildApiRuntime } from '@voltro/client'

const built = await buildApiRuntime({
  name: 'app',
  wsUrl: 'ws://192.168.1.20:4000/ws',        // your machine's LAN address
  group: rpcGroup,                            // from codegen
  // RN provides a global WebSocket; the web client injects a tracked one.
  webSocketConstructor: (url, protocols) => new globalThis.WebSocket(url, protocols as string[]),
})
// built = { runtime, cache, client, errorBus } → an ApiHandle for the provider

The api binding is generated

voltro codegen reads the app's voltro.mobile.ts and writes .framework/mobileApis.generated.ts — which apis this app talks to, and where each one's rpc group and descriptors come from. The template's pnpm ios / pnpm start scripts run it, so there is no separate step.

// voltro.mobile.ts
export default {
  apis: { app: { package: '@acme/api' } },
}

Only the BINDING is generated. The procedure types ride the import of the api package's own rpcGroup, so a schema change needs no regeneration here.

localhost on a phone is the phone

The ws URL is a runtime parameter, never baked into the generated file. A device pointed at ws://localhost:4000/ws connects to itself, times out and retries forever — which reads as a broken framework rather than a wrong host. resolveDevWsUrl() takes the LAN host Expo already knows:

import Constants from 'expo-constants'
import { resolveDevWsUrl } from '@voltro/react-native'

const wsUrl = process.env.EXPO_PUBLIC_API_WS_URL
  ?? resolveDevWsUrl(Constants.expoConfig?.hostUri, 4000)

Scaffold a mobile app. voltro create-project acme --api=api-backend --mobile (or voltro add-app mobile --template mobile-app) scaffolds an Expo app that consumes your api with the same typed hooks. Expo owns Metro (expo start / expo run:ios), not voltro dev. See the mobile-app template.

The package root is RN-safe — no node:*, no @voltro/database, and React is reached only through the hooks (an optional peer). The _voltro_devices table declaration is server-side and lives at @voltro/react-native/schema.

Persisted stores on a device

defineStore({ persist }) reads during RENDER, and a render cannot await — so a device's async storage cannot back it directly. createAsyncStoragePersistence() hydrates the keys into memory once, then serves reads from memory and writes through asynchronously.

import AsyncStorage from '@react-native-async-storage/async-storage'
import { setStoreStorage } from '@voltro/client'
import { createAsyncStoragePersistence } from '@voltro/react-native'

const persistence = createAsyncStoragePersistence({ storage: AsyncStorage })

await persistence.hydrate()             // BEFORE the first render
setStoreStorage(persistence.provider)

hydrate() must be awaited before rendering. An app that renders first shows empty state and flickers into the saved state a frame later; the template holds the Expo splash screen until it resolves. Writes to one key coalesce per tick, so a store written on every keystroke costs one round trip, and a failed write is reported through onError rather than thrown into the set() that caused it.

A storage with no getAllKeys() and no explicit keys: [...] is a REFUSAL, not an empty hydration — an empty cache is indistinguishable from a first run, which is the hardest persistence bug there is to attribute.

Device registration

A device is registered after the OS issues its push token (APNs on iOS, FCM on Android, Web Push on web). registerDevice() normalises a raw input into a row and upserts it through whatever transport the app already has — a generated mutation caller or a plain fetch — so the package stays free of transport coupling.

import { registerDevice } from '@voltro/react-native'

// `userId`/`tenantId` are stamped SERVER-side from the authenticated request —
// never trusted from the client. `locale`/`timezone` default from the device.
await registerDevice(
  (row) => api.mutate('registerDevice', row),
  { deviceToken, platform: 'ios' },
)

Registration is idempotent: the row is stored in _voltro_devices with a unique key of (platform, token), so re-registering the same token updates the row in place instead of inserting a duplicate. A rotated token is a new registration; reaping the stale one is the sender adapter's job (a seam), not the client's.

The _voltro_devices table

The table declaration is a server-side entry — it imports the @voltro/database column DSL, so it is deliberately off the RN-safe root. Contribute it to your schema and it migrates like any framework table (the _voltro_* prefix rides the declarative differ on voltro dev / voltro db apply, on every dialect):

// schema/devices.ts — add the framework device table to your app's schema.
export { devicesTable } from '@voltro/react-native/schema'

It carries tenant + user scope, platform, token, locale, timezone, optional appVersion/metadata, and a lastSeenAt rotation clock. It is unique on (platform, token) and indexed on userId — the hot read path for fanning a push out to every device of a user.

defineDeepLink({ pattern, handler }) is the descriptor a deep-link file declares; its pure matcher turns /orders/:id + /orders/42 into { id: '42' }. The params are inferred from the :name segments, so handler type-checks against exactly the params the pattern declares.

import { defineDeepLink } from '@voltro/react-native' // re-exported from @voltro/client

export default defineDeepLink({
  pattern: '/orders/:id',
  handler: ({ id }) => navigateTo(`/orders/${id}`),
})

The matcher is pure — no navigation, no side effects — and normalises scheme + host away, so a universal link, an App Link, and a custom-scheme URL all match the same path-only pattern:

import { dispatchDeepLink, matchDeepLink } from '@voltro/react-native'
import orderLink from './orders.deepLink'

matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' }
matchDeepLink('/orders/:id', '/orders/42/edit') // → null

// `voltro mobile codegen` builds the table from the Expo Router tree plus every
// `*.deepLink.ts`; a hand-built one works the same — declaration order wins.
const params = dispatchDeepLink([orderLink], 'myapp://orders/42')
params?.id // '42' — and the winning handler has already run

Use dispatchDeepLink for a TABLE, runDeepLink for one descriptor. matchFirstDeepLink also exists and only inspects: because a table is a heterogeneous array, the descriptor it returns has an erased pattern, so its handler's declared params (Record<string, never>) reject the params returned alongside it. Matching and invoking in two steps therefore does not typecheck — which is why the dispatching version exists rather than being left to every caller to cast around.

The primitive itself lives in @voltro/client — one link table is read by the mobile app, a desktop shell, the web router and the push handler — and @voltro/react-native re-exports it. voltro mobile codegen derives a link per Expo Router screen (app/orders/[id].tsx → /orders/:id) and lets a *.deepLink.ts override the derived link for the same pattern; voltro mobile links writes the apple-app-site-association / assetlinks.json files from the same table. voltro mobile doctor checks the Expo config (scheme, bundle ids, the expo-updates URL), the platform-adapter dependencies, the api binding, and deep-link drift against the sibling web app: a deep link with no web route is a push notification that opens a screen on the phone and a 404 in the browser.

The platform adapter

@voltro/client reads what it needs from the shell it runs in through ONE injected object, the PlatformAdapter: the WebSocket constructor, the persisted-store backing, a secure token store, reachability, foreground/ background lifecycle, inbound URLs, and — where the shell has them — notifications, an update channel and biometrics. createExpoPlatform fills it in from the Expo modules you pass (nothing is imported; a test drives it with fakes), hydrates the persisted stores, and installPlatform makes it current:

import * as SecureStore from 'expo-secure-store'
import * as Linking from 'expo-linking'
import AsyncStorage from '@react-native-async-storage/async-storage'
import NetInfo from '@react-native-community/netinfo'
import { AppState, Platform } from 'react-native'
import { installPlatform } from '@voltro/client'
import { createExpoPlatform } from '@voltro/react-native'

const { adapter } = await createExpoPlatform({
  os: Platform.OS,
  netInfo: NetInfo,
  appState: AppState,
  linking: Linking,
  secureStore: SecureStore,
  storage: AsyncStorage,
  // optional, when the app has them: notifications (expo-notifications),
  // updates (expo-updates), localAuthentication (expo-local-authentication)
})
installPlatform(adapter) // BEFORE the first render — it also installs the store backing

App code then branches on capabilities, never on a platform name:

import { usePlatform } from '@voltro/client'

const { platform, capabilities, secureStorage } = usePlatform()
capabilities.push          // an Expo push token is available
capabilities.biometrics    // expo-local-authentication was passed and is enrolled
platform.kind              // 'ios' | 'android' | 'web' | 'macos' | …

What only one shell can do is not on the core contract; it sits under adapter.extensions and is read with useShellExtension('expo') (or 'tauri', 'electron'), which answers null on every other shell. Using an extension ties the app to that shell — by declaration, not by surprise.

secureStorage is where the session value from native login belongs — expo-secure-store on a device, never AsyncStorage. Every adapter passes the same conformance suite (platformAdapterConformance from @voltro/testing/platform), so the contract above is executable, not prose.

On-device SQL for local-first

@voltro/local-first has two durable contracts — PersistenceAdapter (CRDT state, the sync queue, pins) and KvStore (the query mirror's get/put/del/ prefix) — and createExpoSqliteLocalFirst implements BOTH over one expo-sqlite database. It is a KV/blob backing, not a query engine: the client's query surface is (tag, input) and predicates never exist client-side, so four small tables are the whole schema. The database is injected (the async subset of expo-sqlite's API), which is also how the unit test drives the same statements through Node's own SQLite.

import * as SQLite from 'expo-sqlite'
import { createExpoSqliteLocalFirst } from '@voltro/react-native'

const db = await SQLite.openDatabaseAsync('voltro.db')
const { persistence, kv } = await createExpoSqliteLocalFirst(db)
// hand `persistence` to createSyncClient / crdtText bindings, `kv` to the query mirror

Background sync

useBackgroundSync(onSync, options) owns three triggers — an interval timer, a "returned to foreground" subscription, and a manual sync() — over a pure shouldSync policy (single-flight, foreground-gated, interval-gated). The OS background-fetch registration itself stays the app's; this hook is only the interval/foreground state machine.

import { useBackgroundSync } from '@voltro/react-native'

function SyncIndicator() {
  const { status, lastSyncAt, sync } = useBackgroundSync(
    () => api.refetchAll(),
    { intervalMs: 60_000, syncOnForeground: true },
  )

  return <button onClick={sync}>Sync ({status})</button>
}

onSync may be async — a rejection is captured into status: 'error' + lastError, a resolution into status: 'success' + lastSyncAt.

Offline-first defaults + connection status

offlineFirstDefaults is the mobile posture as a value you spread into your client config: local-first ON, optimistic mutations, sync-on-foreground, a 5-minute cadence, and a retry backoff schedule. useMobileConnectionStatus() surfaces a connected | degraded | offline status.

Where "online" comes from is injected, not detected. The default source reads navigator.onLine, which React Native does not have — so on a device it answers "online" forever, airplane mode included. Pass netInfoOnlineSource(NetInfo):

import NetInfo from '@react-native-community/netinfo'
import { netInfoOnlineSource, offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'

// Module scope: an inline call is a new object every render, and the hook would
// resubscribe on each one.
const onlineSource = netInfoOnlineSource(NetInfo)

const config = { ...offlineFirstDefaults, url }

function ConnectionPill() {
  const { status, reportFailure, reportSuccess } = useMobileConnectionStatus({ onlineSource })
  return <span data-status={status}>{status}</span>
}

isInternetReachable is believed only when it is a boolean. NetInfo reports null while its probe is outstanding, and reading that as false flashes "offline" on every cold start and every network change — so a null falls back to the link-layer isConnected.

LAN dev pairing — voltro mobile pair

A phone cannot reach this machine's localhost. voltro mobile pair prints, for every running api, the link a dev shell opens to take it over — the api's LAN address (the registry's loopback host swapped for the first non-internal IPv4, or --host <ip>), its name, and the dev inspect token the runtime registry published:

voltro-dev://pair?api=http%3A%2F%2F192.168.1.20%3A4000&name=main&token=…

Turn it into a QR with any generator, or paste it. On the device, the link is the contract — parsePairingLink(url) from @voltro/client returns { api, name, token? } or null for anything else (a loopback api included), applyPairing(storage, pairing) keeps it in the platform's storage (platform.storage('local'), or any get/set store) under voltro:pairing, and readPairing(storage) hands it to the boot that builds the api config:

// the Expo dev client's Linking listener, or the Tauri dev window's deep-link handler
const pairing = parsePairingLink(url)
if (pairing) await applyPairing(platform.storage('local')!, pairing)
// at boot
const pairing = await readPairing(platform.storage('local')!)
const apiUrl = pairing?.api ?? env.EXPO_PUBLIC_API_URL

The token is the DEV inspect token, never a session: pairing reaches the running dev api and its inspect surface; the app logs in as it always does. voltro dev binds the api to 0.0.0.0 so the LAN address answers. The link lives in voltro mobile pair rather than in voltro dev's boot banner on purpose: the banner is one line per app and the link is long; run the command when you need it.

Updates and store builds — expo-updates on the feed, voltro mobile ci

app.config.ts points expo-updates at the api's release feed (/releases/<app>/expo/manifest, EXPO_PUBLIC_UPDATES_URL overriding it per build) with runtimeVersion: { policy: 'appVersion' }, so a native change never receives a bundle built for another binary; channels, percent rollout, rollback and the forced-update gate are the ones @voltro/plugin-releases runs for desktop and PWA too. eas.json carries the development / preview / production profiles on the internal / beta / stable channels.

voltro mobile ci writes .github/workflows/mobile-release.yml with two jobs: ota exports the JS bundle (expo export), creates the release on the feed and uploads the bundles with voltro release; store builds the native binaries on EAS Build with the production profile (EXPO_TOKEN) and, when the repository holds the store credentials (APPLE_API_KEY_CONTENT, APPLE_API_KEY, APPLE_API_ISSUER, VOLTRO_STORE_IDENTIFIER; GOOGLE_PLAY_SERVICE_ACCOUNT_JSON), uploads them with eas submit to TestFlight and the Play internal track — the credentials become files the runner alone holds, the submit profile in eas.json is pointed at them for that run only, nothing is committed. --app, --channel and --profile change the feed app, the landing channel and the profile. A self-hosted macOS runner is the alternative the header names. voltro mobile doctor warns when updates.url is not a feed manifest url. Built and pinned by tests; a run against a store needs an EAS account and store credentials, which are yours.

What's shipped vs. a seam

This package ships the credential-free plumbing above. The parts that need external credentials or a native runtime are flagged as deliberate seams — not built here:

Seam Why it is not in this package
APNs / FCM sender adapters Need per-tenant Apple Developer / Firebase credentials — genuinely external, managed via provider provisioning. Registration stores the token; sending to it is the seam.
Native module bindings (camera, biometrics, secure token storage) Need a native runtime this TS package cannot provide.
Swift / Kotlin SDK generators Built + golden-tested — voltro build api --target swift|kotlin emits a native SDK package. What is deferred is compiling the emitted package (swiftc / Gradle): that is a mobile-CI step, no cross-language toolchain lives in the framework repo.
Universal-links / App-Links file automation (apple-app-site-association, assetlinks.json) A deployment-layer concern, not a client primitive.
*.deepLink.ts codegen discovery One additive file after the release settles; the descriptor shape is final, so register links via dispatchDeepLink() today.
Booting on a device Everything above is unit-tested without a simulator; the framework's device proof workflow then boots the mobile-app template on an iOS simulator and an Android emulator and drives the reactive loop with Maestro (green 2026-09-26). Real hardware is your Expo/EAS step.