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 — plus *.deepLink.ts codegen discovery (until it lands, register links via matchFirstDeepLink(links, url)), push sender adapters (APNs / FCM need per-tenant credentials), and native-module bindings (camera, biometrics, secure token storage need a native runtime). @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, and the deep-link declaration shape.

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'

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

// Until `*.deepLink.ts` file discovery lands, register links by hand —
// declaration order wins, so list more-specific patterns first.
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.

Seam — file discovery. Wiring *.deepLink.ts into codegen (so the router auto-collects every declared link) is one additive file, landing after the current release settles. The descriptor shape above is final, so register links via dispatchDeepLink() until then.

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.

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-testedvoltro 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, and a simulator is the only thing that can prove the loop runs under Metro. That is an Expo/EAS CI step — the framework repository has no iOS/Android toolchain, and we say so rather than implying coverage we do not have.