# Local-first & Mobile

> "@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath."



---

<!-- source: en/local-first/overview.md -->
## Local-first & CRDTs

_"@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath."_

`@voltro/local-first` is the framework's foundation for **offline-capable,
multiplayer, convergent** apps: edit while disconnected, see other peers' cursors,
and reconcile without losing work when the network returns. The `.` entry is
**pure and browser-safe** — no `effect`, no `node:*` — so a route or component
imports it directly. The React wrappers live behind `@voltro/local-first/react`
(React is an optional peer, so the pure path never pulls it in).

> **What ships today**: the pure CRDT text merge, the offline sync-queue reducer,
> the connection-lifecycle state machine, the conflict policy, the
> [`crdtText()` database column](#the-crdttext-database-column) (with its
> authoritative server-side merge on the write path), the
> [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
> transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
> binding for a collaborative text field — [presence/awareness](#presence--awareness)
> via `usePresence`, [durable IndexedDB persistence](#durable-persistence), and the
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
> two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
> provisioned infra (a broker at scale) plus the two app-specific tags
> `useCrdtText` is pointed at — and the sync **engine** (a locally queryable
> database, automatic mirroring of `localFirst()` tables, partial replication),
> which is planned and not yet built. Today `localFirst()` is a declaration the
> tooling discovers, not an auto-synced local database.

## CRDT text: `crdtText` + `mergeCrdtStates`

A CRDT (Conflict-free Replicated Data Type) text field can be edited by many
peers offline and always converges to the same result. `crdtText()` builds one
(Yjs-backed behind the `CrdtBackend` abstraction), and `mergeCrdtStates()` is
the heart of the package — it converges two encoded states into one,
deterministically and order-independently.

```ts
import { crdtText, mergeCrdtStates, decodeCrdtText } from '@voltro/local-first'

// A field, edited offline. `insert`/`delete` mutate and return the same handle.
const doc = crdtText('Hello').insert(5, ', world')
const state = doc.encode() // the wire/storage form: a CrdtState

// Converge two peers' encoded states — order-independent, no lost edits.
const remote = crdtText('Hello').insert(5, ' there').encode()
const merged = mergeCrdtStates(state, remote)
decodeCrdtText(merged) // the plain-string view; both edits survive
```

`mergeCrdtStates` is deterministic (`decodeCrdtText(mergeCrdtStates(a, b))`
equals the same for `(b, a)`), idempotent (re-merging a contained state is a
no-op), and treats `emptyCrdtState()` as identity. The backend is a parameter on
every function (defaulting to Yjs), so a later swap to Loro touches no call site.

## The `crdtText()` database column

`@voltro/database` ships a `crdtText()` **column type** for CRDT-managed fields.
It needs no special DDL — to the declarative differ it is an ordinary nullable
`bytes` column (BYTEA / BLOB / LONGBLOB / VARBINARY), so it plans and round-trips
on every dialect like any other:

```ts
import { table, id, text, crdtText } from '@voltro/database'

export const documents = table('documents', {
  id: id(),
  title: text(),
  body: crdtText(), // CRDT-managed field — stored as the encoded state (bytes)
})
```

The row type is `Uint8Array | null` (the encoded CRDT state); decode it to a
string with `decodeCrdtText()`, and produce writes with a `crdtText()` handle's
`.encode()`. The merge is **authoritative and server-side**: the runtime folds an
incoming update into the stored state with `mergeCrdtStates` on the write path
before writing, then the reactive engine broadcasts the merged result — which is
what makes concurrent edits converge without a last-write-wins loser.

## The `localFirst` table mixin

`localFirst()` **marks a table as local-first** — mirrored to the client, synced
bi-directionally, and (for its `crdtText()` fields) converged via CRDT merge. It
adds no column; it is a property the framework reflects on.

```ts
import { table, id, text, crdtText, localFirst } from '@voltro/database'

export const documents = table('documents', {
  id: id(),
  title: text(),
  body: crdtText(),
}).with(localFirst()) // opt this table into local-first sync + persistence
```

Discovery needs no codegen change — a marker mixin rides `.with()` like any
column type. `isLocalFirst(table)` and `localFirstTables(schema)` are pure
helpers, and the runtime's schema registry reflects it as `hasLocalFirst(table)`
(beside `crdtColumns(table)`), which is the signal a client-sync-set builder
reads. A local-first table may also carry plain columns — those sync
last-write-wins via the [conflict policy](#conflict-policy-for-non-crdt-fields).

## The `SyncClient`: bi-directional wire

`createSyncClient({ transport })` maps the offline sync queue onto a transport:
a local edit merges optimistically and queues; reconnect drains it to the server
with retry; incoming merged state folds back via the CRDT — and concurrent edits
converge. It invents **no** transport of its own — the `SyncTransport` is two
functions an app binds to its **existing** wire:

- `push` — deliver a queued CRDT write. Bound to a [`useMutation`](/docs/data/mutations)
  that writes the `crdtText()` column (the server folds it authoritatively).
- `onRemoteState` — receive merged state. Bound to the reactive
  [`useSubscription`](/docs/data/queries) that already streams the row.

```ts
import { createSyncClient } from '@voltro/local-first'

const sync = createSyncClient({
  transport: {
    kind: 'sync-transport',
    push: (write) => runMutation('documents.setBody', write.payload),
    onRemoteState: (handler) =>
      subscribeRow('documents', (row) =>
        handler({ table: 'documents', id: row.id, column: 'body', state: row.body }),
      ),
  },
  adapter: durablePersistence, // optional — survives a reload
})

// A local edit: merges locally at once, queues, drains when online.
sync.enqueue({ table: 'documents', id: 'd1', column: 'body', update: doc.encode() })
sync.getText({ table: 'documents', id: 'd1', column: 'body' }) // the merged view
```

Everything below the two transport functions — the drain loop, retry/attempt
counting, optimistic local merge, durable persistence — is in the client and
tested against an in-memory dispatcher that mirrors the server's merge.

## A collaborative text field: `useCrdtText`

`useCrdtText` is the React binding over that wire — one `crdtText()` cell, bound
to the mutation that writes it and the reactive query that streams it:

```tsx
import { useMutation, useSubscription } from '@voltro/client'
import { useCrdtText } from '@voltro/local-first/react'

function BodyEditor({ id }: { id: string }) {
  const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
  const save = useMutation<{ id: string; body: Uint8Array }>('app', 'documents.setBody')

  const body = useCrdtText({
    cell: { table: 'documents', id, column: 'body' },
    remote: row.data?.body ?? null,             // what the server currently holds
    push: (w) => save.mutate({ id: w.id, body: w.update }),  // deliver a local edit
  })

  return (
    <>
      <textarea value={body.text} onChange={(e) => body.setText(e.target.value)} />
      {body.synced ? null : <em>saving… ({body.outstanding})</em>}
    </>
  )
}
```

The hook owns one `SyncClient` per `(table, id, column)` cell — created and
closed with the component — re-renders on a local edit, an ack or incoming
merged state, and folds the streamed row back in. It returns `text`,
`insert(index, text)`, `delete(index, length)`, `setText(next)`, the encoded
`state`, `outstanding` / `synced`, and `setOnline`.

<Callout type="warn">
**`setText` is a span diff, and that is the whole point.** A `<textarea>` hands
you the entire new string, so the obvious binding is "clear the document,
insert the new text" — a delete-all/insert-all, which is exactly the
last-write-wins behaviour a CRDT is chosen to prevent: two people typing in
different paragraphs each erase the other's, and the text looks right on
whichever peer typed last. `useCrdtText` diffs the common prefix and suffix and
emits ONE delete plus ONE insert, so an edit outside the changed span survives.
The diff is exported as `crdtTextEdit(before, after)` if you drive the document
yourself.
</Callout>

Two things stay yours to name, because nothing can derive them: **which
mutation** writes the column and **which query** streams the row. Voltro
generates no per-table CRUD surface, so the hook takes those two as `push` and
`remote` — the same shape as `usePresence`'s injected channel. Everything under
them (client lifecycle, optimistic merge, offline queue, bounded retry, durable
persistence, the edit encoding) is framework code.

The descriptors on the other end declare the column as bytes-over-base64 —
`Uint8Array` in the handler, a base64 string on the wire:

```ts
import { Schema } from 'effect'

// documents.byId output (and documents.setBody input)
body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
```

### Known cost limits of `crdtText()` today

Two amplification effects are worth knowing before you put a `crdtText()` column
on a hot editing path — both are per-keystroke costs, and both are real today:

- **Wire amplification downstream.** A subscription delta carries the row's
  columns, and for a CRDT column that is the merged **full state** (base64) —
  every keystroke ships the whole document to every subscriber of the query,
  not the one-edit update. Keep the streamed query's projection narrow (don't
  project `body` into a list view), or subscribe to the document row alone.
- **Undo/row-history capture.** Server-side capture (the undo log — default-on
  outside production — and `plugin-row-history`'s row history, where enabled)
  snapshots the row per mutation, so per-keystroke mutations write a
  full-state blob per keystroke into those tables. Point them away from
  CRDT-heavy tables, or batch edits before pushing.

Both limits are on the framework's roadmap (incremental delivery and
CRDT-aware capture); until then they are costs to design around, not bugs to
report.

## Presence & awareness

`usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
(cursor, name, selection) and returns everyone else's — the multiplayer cursors
of a collaborative editor. Presence is **ephemeral and high-frequency**, so it
rides a pub/sub channel, never Postgres CDC or a table.

```tsx
import { usePresence } from '@voltro/local-first/react'

function Editor({ documentId, channel }) {
  const { presence, others, setPresence } = usePresence(
    documentId,
    { cursor: 0, name: 'Ada' },
    { channel },
  )
  // render `others` as remote cursors; update on selection change:
  const onSelect = (cursor: number) => setPresence({ cursor, name: 'Ada' })
  return <Cursors others={others} />
}
```

The `PresenceChannel` is the **same dumb string-payload pub/sub shape** as the
framework broker (`@voltro/plugin-broadcast`), so a runtime binding forwards
straight onto the app's provisioned broker — in-memory locally,
Redis/NATS at scale (both already shipped). `createInMemoryPresenceChannel()` is
the local/test transport. Join/leave, announce-back discovery, cursor
propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps.

> **Two different hooks share the name `usePresence`, and they are not
> interchangeable.** THIS one (`@voltro/local-first/react`) is peer-to-peer
> awareness over a pub/sub channel — `usePresence(roomId, self, { channel })` →
> `{ presence, others, setPresence }` — for high-frequency cursor/selection
> state that must never touch the database.
> [`@voltro/plugin-presence`](/docs/plugins/presence)'s is a server-backed
> roster — `usePresence(channel, options)` → the list of members whose heartbeat
> is fresh, plus a `useTyping` indicator, through the app's own rpc. Reach for
> the plugin for "who is here"; reach for this one for "where is their cursor".

## The offline sync queue

`useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
offline are queued, and a transport drains them when connectivity returns. Use it
directly for fine-grained UI, or let the [`SyncClient`](#the-syncclient-bi-directional-wire)
drive it for you.

```tsx
import { useSyncQueue, useConnectionStatus } from '@voltro/local-first/react'

function SaveIndicator() {
  const queue = useSyncQueue()
  const { status } = useConnectionStatus(queue.outstanding)
  // `outstanding` counts pending + in-flight (0 means synced).
  return status === 'synced' ? null : <span>Saving… ({status})</span>
}
```

## Connection status

`useConnectionStatus(outstanding)` observes the connection lifecycle for the
local-first layer — network up/down plus reconnect confirmation — and folds the
number of unsynced writes into a display `status` of `offline | syncing |
synced`, so `synced` means online **and** drained. It feeds the machine the two
signals a browser can observe (`navigator.onLine` + the `online`/`offline`
events); confirmed round-trips (`confirm()`/`confirmFailed()`) are left to the
caller, so the hook never invents a server ping.

> This is the local-first connection machine, distinct from `@voltro/client`'s
> RPC-error-derived [`useConnectionStatus`](/docs/ui/client-utilities/use-connection-status) —
> a different package with a different signal source.

## Durable persistence

Local CRDT state and the offline queue should survive a reload. Everything above
storage speaks the `PersistenceAdapter` contract, so the backing swaps freely:

- `createInMemoryPersistence()` — ephemeral (lost on reload); the test/default.
- `createIndexedDbPersistence()` — **durable**, over the browser's own
  IndexedDB. No WASM, no added dependency; the IDB implementation is injectable,
  so it is tested against a fake backend that survives a reopen.

```ts
import { createIndexedDbPersistence } from '@voltro/local-first'

const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
const sync = createSyncClient({ transport, adapter }) // state now survives reload
```

## Conflict policy for non-CRDT fields

CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
like `title` needs a policy. `conflictPolicy()` declares one per field; the
default everywhere is last-write-wins, and any field you do not name falls back
to it, so a policy never has to enumerate every column.

```ts
import { conflictPolicy } from '@voltro/local-first'

const policy = conflictPolicy({
  title: 'lastWriteWins',
  // A custom resolver MUST converge: both peers pick the same winner.
  tags: (local, remote) => (remote.updatedAt >= local.updatedAt ? remote.value : local.value),
})

policy.resolveRecord(
  { title: { value: 'Draft', updatedAt: 1 } },
  { title: { value: 'Final', updatedAt: 2 } },
) // → { title: 'Final' }
```

The one property that matters is **convergence**: `lastWriteWins` breaks an
exact `updatedAt` tie on a stable, symmetric key (writer id, then the value's
string form), so two peers agree regardless of which side each calls "local".

## What's shipped vs. a runtime seam

The framework code for local-first is built and tested end to end against
in-memory transports. What remains is not un-built framework — it is the thin
binding to **provisioned infrastructure**, sitting behind interfaces the tested
code already speaks:

| Runtime seam | What it binds | Why it's a binding, not code |
| --- | --- | --- |
| **Two app-specific tags** | Which mutation writes the `crdtText()` column, and which reactive query streams the row, in [`useCrdtText`](#a-collaborative-text-field-usecrdttext). | Voltro generates no per-table CRUD surface, so there is nothing to derive them from. The client lifecycle, optimistic merge, offline queue, retry, persistence and edit encoding all ship. |
| **Presence channel → a broker at scale** | The `PresenceChannel` to a provisioned Redis/NATS broker. | It's a network hop over an already-shipped broker; the awareness logic ships and is tested over the in-memory channel. |
| **wa-sqlite / Turso adapter** *(optional)* | A SQL durable adapter for cross-tab queries, behind `PersistenceAdapter`. | IndexedDB is the durable default today; a SQL backing is a sibling factory, nothing above it changes. |



---

<!-- source: en/react-native/overview.md -->
## 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`](/docs/react-native/overview#building-the-client-on-react-native)
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.

```ts
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:

```ts
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.

```ts
// 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:

```ts
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](/docs/templates/mobile-app).

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.

```ts
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.

```ts
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):

```ts
// 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.

## Deep links: `defineDeepLink` + the matcher

`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.

```ts
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:

```ts
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.

```tsx
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)`:

```tsx
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-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, 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. |
