import { ApiHandle } from '@voltro/client'; import { ClientDescriptorMap } from '@voltro/client'; import { MountedApi } from '@voltro/client'; import { ResolvableHeaders } from '@voltro/client'; import { ResolvedClient } from '@voltro/client'; import { StoreStorageProvider } from '@voltro/client'; import { SupervisorHandle } from '@voltro/client'; /** * The async storage this adapter writes through to — `AsyncStorage` from * `@react-native-async-storage/async-storage` satisfies it as-is, as does a * thin wrapper over `expo-secure-store`. * * It is a STRUCTURAL type on purpose: this package does not depend on any * storage library, so an app can pass whichever one it already has (or a fake, * which is how this is tested). */ export declare interface AsyncKeyValueStorage { readonly getItem: (key: string) => Promise; readonly setItem: (key: string, value: string) => Promise; readonly removeItem: (key: string) => Promise; /** Optional: used by `hydrate()` when no explicit key list is given. */ readonly getAllKeys?: () => Promise>; /** Optional: a batched read, used when available. */ readonly multiGet?: (keys: ReadonlyArray) => Promise>; } export declare interface AsyncStoragePersistence { /** * Load the persisted keys into memory. Await this BEFORE rendering; install * the provider afterwards. * * Idempotent: a second call re-reads, which is what a "reload from disk" * would need. Safe to call concurrently — the in-flight promise is shared. */ readonly hydrate: () => Promise; /** True once `hydrate()` has completed at least once. */ readonly hydrated: () => boolean; /** Install this with `setStoreStorage()` from `@voltro/client`. */ readonly provider: StoreStorageProvider; /** * Resolve when every queued write has settled. For tests, and for an app that * wants to flush before backgrounding — not required for correctness, since * writes are queued per key and the last one wins. */ readonly flush: () => Promise; /** Keys currently held in memory. Diagnostics; not a stable ordering. */ readonly keys: () => ReadonlyArray; } export declare interface AsyncStoragePersistenceOptions { readonly storage: AsyncKeyValueStorage; /** * Which keys to hydrate. Give this when your storage holds more than Voltro's * stores — hydrating a device's entire key space to serve four store keys is * a slow boot for no benefit. * * Omitted, the adapter asks `getAllKeys()`; if the storage has no * `getAllKeys`, omitting this is an ERROR at `hydrate()` rather than a silent * empty hydration, because an empty hydration looks exactly like a first run. */ readonly keys?: ReadonlyArray; /** Called when a write-through fails. Default: `console.warn`. */ readonly onError?: (key: string, error: unknown) => void; } export declare type BackgroundSyncAction = { readonly type: 'foreground'; } | { readonly type: 'background'; } | { readonly type: 'syncStarted'; readonly at: number; } | { readonly type: 'syncSucceeded'; readonly at: number; } | { readonly type: 'syncFailed'; readonly at: number; readonly error: string; } | { readonly type: 'reset'; }; export declare const backgroundSyncReducer: (state: BackgroundSyncState, action: BackgroundSyncAction) => BackgroundSyncState; export declare interface BackgroundSyncState { readonly status: BackgroundSyncStatus; /** Whether the app is currently foregrounded (drives interval + focus sync). */ readonly isForeground: boolean; /** When the last SUCCESSFUL sync completed, epoch ms. `undefined` until one does. */ readonly lastSyncAt: number | undefined; /** When the last sync ATTEMPT started, epoch ms — the interval clock reads this. */ readonly lastAttemptAt: number | undefined; /** The last error message, set on failure, cleared on the next success/start. */ readonly lastError: string | undefined; /** Successful syncs so far. */ readonly successCount: number; /** Consecutive failures with no success since — 0 when healthy. */ readonly failureCount: number; } export declare type BackgroundSyncStatus = 'idle' | 'syncing' | 'success' | 'error'; /** The browser's `navigator.onLine` + its events. The default, and the reason * the same screen still behaves under jsdom or in a web preview. */ export declare const browserOnlineSource: OnlineSource; /** * Build a React-Native persistence adapter over an async key-value storage. * * ```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) * ``` */ export declare const createAsyncStoragePersistence: (options: AsyncStoragePersistenceOptions) => AsyncStoragePersistence; /** * The descriptor a `*.deepLink.ts` file default-exports. `_tag` marks it for * the (future) discovery walk without relying on structural guessing. */ export declare interface DeepLinkDescriptor { readonly _tag: 'VoltroDeepLink'; readonly pattern: Pattern; readonly handler: DeepLinkHandler; } /** A handler for a matched deep link. May be async (navigation can await). */ export declare type DeepLinkHandler = (params: DeepLinkParams) => void | Promise; /** * Substitute captured params back into a pattern, producing a concrete path. * * `deepLinkHref('/orders/:id', { id: '42' })` → `'/orders/42'`. This is the * inverse of {@link matchDeepLink} and it exists because a DERIVED deep link * (one the generator built from an Expo Router screen) navigates to the very * path it matched — the screen's own route. * * It lives here rather than being emitted into the generated module on purpose: * generated code that carries logic is logic nothing tests. A missing param * would silently produce `/orders/` (a different, probably-existing route), so * it throws instead. */ export declare const deepLinkHref: (pattern: string, params: Readonly>) => string; /** * The params a pattern yields, inferred from its `:name` segments. * * `'/orders/:id'` → `{ id: string }`, `'/t/:tenant/u/:user'` → * `{ tenant: string; user: string }`, a param-less pattern → * `Record`. This is what makes `handler: ({ id }) => …` * type-check against exactly the params the pattern declares. */ export declare type DeepLinkParams = Pattern extends `${string}:${infer Param}/${infer Rest}` ? { readonly [K in Param | keyof DeepLinkParams<`/${Rest}`>]: string; } : Pattern extends `${string}:${infer Param}` ? { readonly [K in Param]: string; } : Record; /** The default constructor: React Native's global `WebSocket`. Exported so the * refusal below is testable — an untestable error message is one nobody has * ever read. */ export declare const defaultWebSocketConstructor: (url: string, protocols?: string | ReadonlyArray) => WebSocket; /** * Declare a deep link. * * ```ts * export default defineDeepLink({ * pattern: '/orders/:id', * handler: ({ id }) => navigateTo(`/orders/${id}`), * }) * ``` */ export declare const defineDeepLink: (config: DefineDeepLinkConfig) => DeepLinkDescriptor; export declare interface DefineDeepLinkConfig { readonly pattern: Pattern; readonly handler: DeepLinkHandler; } /** * The status mapping, pure and standalone (mirrors `@voltro/client`'s): * - browser says offline → `offline` (a real signal for "no", weak for "yes"), * - otherwise any outstanding failure → `degraded`, * - else `connected`. No fake "connecting" ping. */ export declare const deriveConnectionStatus: (online: boolean, failureCount: number) => MobileConnectionStatus; /** The set of platforms, as a value (for validation / `` lists). */ export declare const DEVICE_PLATFORMS: ReadonlyArray; /** The push transport a device is reachable on. */ export declare type DevicePlatform = 'ios' | 'android' | 'web'; /** * The normalised row, ready to upsert into `_voltro_devices`. `locale` and * `timezone` are resolved (never `undefined`); `registeredAt` is the moment we * built this. `userId` / `tenantId` are NOT here on purpose — they are the * server's to stamp from the authenticated request, never trusted from the * client (mirrors how API-key metadata is merged UNDER the framework's claims). */ export declare interface DeviceRegistration { readonly deviceToken: string; readonly platform: DevicePlatform; readonly locale: string; readonly timezone: string; readonly appVersion: string | null; readonly metadata: Readonly> | null; readonly registeredAt: number; } /** * Ambient facts the resolver reads for the locale/timezone defaults. Injectable * so it is pure + testable — in a real RN/web runtime the defaults come from * the device, in a test you pass them explicitly. */ export declare interface DeviceRegistrationEnv { readonly locale?: string; readonly timezone?: string; readonly now?: () => number; } /** * What the app hands us at registration time. `deviceToken` and `platform` are * the only two the caller MUST supply — the OS gives it both. `locale` and * `timezone` are filled from the running environment when omitted (see * {@link resolveDeviceRegistration}); `appVersion` and `metadata` are optional * tags the app may attach. */ export declare interface DeviceRegistrationInput { readonly deviceToken: string; readonly platform: DevicePlatform; readonly locale?: string; readonly timezone?: string; readonly appVersion?: string; readonly metadata?: Readonly>; } /** The framework table name devices are stored in — shared with `./schema`. */ export declare const DEVICES_TABLE = "_voltro_devices"; /** * The transport that actually persists a device. The app supplies it — usually * a generated mutation caller (`api.registerDevice`) or a plain `fetch` — so * this package stays free of any transport/codegen coupling. It receives the * fully-resolved row and returns whatever the server sends back. */ export declare type DeviceUpsert = (registration: DeviceRegistration) => Promise; /** * Match `path` against the table and RUN the winning handler. Returns the * captured params, or `null` when nothing matched. * * This exists because `matchFirstDeepLink` alone is not usable for the thing it * is for. Its descriptors come from a HETEROGENEOUS array, so `Pattern` erases * to `string`, and `DeepLinkParams` is `Record` — the * handler it hands back rejects the params it hands back with it. Every caller * had to cast, and the template's did not, which nothing noticed because no * harness typechecked the template. * * The erasure happens ONCE, here, where it is defensible: at runtime a matched * pattern's params ARE `Record`, and the per-pattern type is a * derivation of a literal the caller no longer has. `runDeepLink` keeps the * fully-typed single-descriptor path for a caller that does. */ export declare const dispatchDeepLink: (descriptors: ReadonlyArray, path: string) => Record | null; /** * Subscribe to "app returned to the foreground". Returns an unsubscribe. * * The app supplies this — on RN, wrap `AppState.addEventListener('change', …)` * and call `onForeground` when it flips to `'active'`; on web the default below * uses `visibilitychange` + `focus`. Injectable so the hook is testable without * a real app lifecycle. */ export declare type ForegroundSubscribe = (onForeground: () => void, onBackground: () => void) => () => void; export declare const initialBackgroundSyncState: BackgroundSyncState; /** Narrow an untrusted string to a {@link DevicePlatform}. */ export declare const isDevicePlatform: (value: unknown) => value is DevicePlatform; /** * Match a `pattern` against a `path`, returning the captured params or `null` * when it does not match. Pure — no navigation, no side effects. * * ```ts * matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' } * matchDeepLink('/orders/:id', '/orders/42/edit') // → null * matchDeepLink('/health', '/health') // → {} * ``` * * A `*` segment matches exactly one segment without capturing; a trailing `*` * is not a catch-all (keep the model boringly predictable — one pattern * segment matches one path segment). */ export declare const matchDeepLink: (pattern: Pattern, path: string) => DeepLinkParams | null; /** * First descriptor whose pattern matches `path`, with its captured params. * Declaration order wins, so list more-specific patterns first — which is why * the generated table puts hand-written `*.deepLink.ts` descriptors ahead of * the router-derived ones. */ export declare const matchFirstDeepLink: (descriptors: ReadonlyArray, path: string) => { readonly descriptor: DeepLinkDescriptor; readonly params: Record; } | null; /** One api this app talks to. `group` and `descriptors` come from the api's * generated rpc surface — on mobile, from the file `voltro codegen` writes * into the app (`.framework/mobileApis.generated.ts`). */ export declare interface MobileApiBinding { readonly name: string; readonly group: MountedApi['group']; readonly descriptors: ClientDescriptorMap; /** `ws(s)://host:port/ws`. On a device this is NOT localhost — see * {@link resolveDevWsUrl}. */ readonly wsUrl: string; readonly headers?: ResolvableHeaders | undefined; readonly recovery?: MountedApi['recovery']; } export declare interface MobileConnectionControls { /** Report that a call failed — bumps `failureCount`, flips to `degraded`. */ readonly reportFailure: () => void; /** Report a known-good round trip — clears `degraded`. */ readonly reportSuccess: () => void; } export declare interface MobileConnectionState { readonly status: MobileConnectionStatus; /** Reachability as reported by the configured {@link OnlineSource}. */ readonly online: boolean; /** Consecutive reported failures with no success since. 0 when healthy. */ readonly failureCount: number; readonly lastFailureAt: number | undefined; } export declare type MobileConnectionStatus = 'connected' | 'degraded' | 'offline'; /** The subset of the NetInfo module used. `fetch()` seeds the first value, * `addEventListener` reports changes and returns an unsubscribe. */ export declare interface NetInfoLike { readonly addEventListener: (listener: (state: NetInfoState) => void) => () => void; readonly fetch?: () => Promise; } /** * Reachability from `@react-native-community/netinfo` (or Expo's re-export). * * ```ts * import NetInfo from '@react-native-community/netinfo' * const status = useMobileConnectionStatus({ onlineSource: netInfoOnlineSource(NetInfo) }) * ``` * * `isInternetReachable` is only believed when it is a BOOLEAN. NetInfo reports * `null` while its reachability probe is still outstanding, and treating that * as `false` makes an app flash "offline" for a moment on every cold start and * on every network change — so a `null` falls back to `isConnected`, which is * the link-layer answer and is available immediately. */ export declare const netInfoOnlineSource: (netInfo: NetInfoLike) => OnlineSource; /** The shape of `@react-native-community/netinfo`'s state that matters here. * Structural, so this package depends on no native module. */ export declare interface NetInfoState { readonly isConnected: boolean | null; /** `null` while the probe is still out — see the treatment below. */ readonly isInternetReachable?: boolean | null; } /** * The mobile client posture. Spread into the client config on RN: * * ```ts * createClient({ ...offlineFirstDefaults, url }) * ``` */ export declare interface OfflineFirstDefaults { /** Local-first is ON by default on mobile (opt-out), OFF on web (opt-in). */ readonly localFirst: boolean; /** Apply mutations optimistically before the server confirms. */ readonly optimistic: boolean; /** Sync when the app returns to the foreground. */ readonly syncOnForeground: boolean; /** Background sync cadence while foregrounded, ms. */ readonly syncIntervalMs: number; /** Surface sync/connection status in the UI (mobile shows it prominently). */ readonly showSyncStatus: boolean; /** Retry backoff schedule for a failed sync, ms. */ readonly retryBackoffMs: ReadonlyArray; } export declare const offlineFirstDefaults: OfflineFirstDefaults; /** * A source of reachability. `subscribe` reports every change and returns an * unsubscribe; `read` gives the value to start from. * * Injected rather than detected, because detection is exactly what went wrong: * a `typeof navigator === 'undefined' ? true : …` fallback answers "online" on * every platform that does not implement the API it is testing for. */ export declare interface OnlineSource { readonly read: () => boolean; readonly subscribe: (onChange: (online: boolean) => void) => () => void; } /** * Register (or re-register, on token rotation) the current device. * * ```ts * await registerDevice( * (row) => api.mutate('registerDevice', row), * { deviceToken, platform: 'ios' }, * ) * ``` * * Idempotent by construction: the `_voltro_devices` unique key is * `(platform, token)`, so calling this again with the same token updates the * existing row (locale/timezone/lastSeen) 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. */ export declare const registerDevice: (upsert: DeviceUpsert, input: DeviceRegistrationInput, env?: DeviceRegistrationEnv) => Promise; /** * Turn a raw {@link DeviceRegistrationInput} into a {@link DeviceRegistration}. * * Locale/timezone precedence: explicit input → injected env → ambient runtime → * the `en` / `UTC` floor. Pure given an `env` (including `now`), so the same * input always produces the same row in a test. */ export declare const resolveDeviceRegistration: (input: DeviceRegistrationInput, env?: DeviceRegistrationEnv) => DeviceRegistration; /** * Turn a dev-machine host into a ws URL a DEVICE can reach. * * The trap this exists for: `localhost` on a phone is the PHONE. An app pointed * at `ws://localhost:4000/ws` in development connects to nothing, times out, * and retries forever — which reads as a broken framework rather than a wrong * host. Expo already knows the LAN address of the machine running Metro * (`expo-constants`' `hostUri`, e.g. `192.168.1.20:8081`), so pass that in and * this swaps in the api's port. * * A simulator is the exception — it shares the host's loopback — but using the * LAN address works there too, so there is no branch to get wrong. */ export declare const resolveDevWsUrl: (hostUri: string | undefined, port: number, path?: string) => string; /** * Match `path` against a descriptor and, on a hit, invoke its handler with the * captured params. Returns the params (so callers know it matched) or `null`. */ export declare const runDeepLink: (descriptor: DeepLinkDescriptor, path: string) => DeepLinkParams | null; /** * Should a trigger run a sync right now? Pure — the single place the timing * policy lives, so the hook and any test agree: * * - not while one is already in flight (single-flight), * - not while backgrounded (a background tick is the OS's job, not ours), * - not before `intervalMs` has elapsed since the last ATTEMPT, * - not when disabled. * * A `force` trigger (a manual `sync()` call) bypasses only the interval gate — * it still respects single-flight and enabled, because running two syncs at * once or after teardown is never what the caller meant. */ export declare const shouldSync: (state: BackgroundSyncState, now: number, options: ShouldSyncOptions, force?: boolean) => boolean; export declare interface ShouldSyncOptions { /** Minimum ms between attempts; `0` disables the interval gate. */ readonly intervalMs: number; /** Whether syncing is enabled at all. */ readonly enabled: boolean; } /** * Connect every api and keep them connected. * * ```ts * const supervisor = startMobileApis({ * apis: mobileApis(wsUrl), * onChange: (clients, initialized) => setState({ clients, initialized }), * }) * // on unmount: * supervisor.dispose() * ``` * * `reconnect()` on the returned handle forces a fresh socket for every api — * call it after a sign-in or a tenant switch so the connection re-resolves who * it is. The supervisor blanks caches across that swap on purpose: the next * subject may be entitled to less than the previous one. */ export declare const startMobileApis: (options: StartMobileApisOptions) => SupervisorHandle; export declare interface StartMobileApisOptions { readonly apis: ReadonlyArray; /** Called whenever the live client set changes: initial connect, a reconnect * swap, teardown. Mirror it into state and render once `initialized`. */ readonly onChange: (clients: ReadonlyMap, initialized: boolean) => void; /** * How to construct a WebSocket. Defaults to the global one React Native * provides. Injected so this module can be tested without a socket — and so * an app on a runtime with a different WebSocket can say so. */ readonly webSocketConstructor?: (url: string, protocols?: string | ReadonlyArray) => WebSocket; /** Override the retry schedule (default: 500ms doubling, capped at 5s). */ readonly retryDelayMs?: (attempt: number) => number; } /** * Turn the supervisor's live client set into the `apis` map the client provider * takes. * * This lives in the package rather than in the template because it is where the * two halves meet — the BINDING (name, descriptors, url) and the live * connection — and a template copy would be an untested one. An api that has not * connected yet is simply absent from the map; the hooks render their loading * state, which is the honest answer while a phone is on a train. */ export declare const toApiHandles: (apis: ReadonlyArray, clients: ReadonlyMap) => ReadonlyMap; /** * Register a periodic + foreground-triggered sync callback. * * ```tsx * const { status, lastSyncAt, sync } = useBackgroundSync( * () => api.refetchAll(), * { intervalMs: 60_000, syncOnForeground: true }, * ) * ``` * * `onSync` may be async — a rejection is captured into `status: 'error'` + * `lastError`, a resolution into `status: 'success'` + `lastSyncAt`. The * callback is read through a ref, so passing a fresh closure each render does * not reset the timer. */ export declare const useBackgroundSync: (onSync: () => void | Promise, options?: UseBackgroundSyncOptions) => UseBackgroundSyncResult; export declare interface UseBackgroundSyncOptions { /** Run the sync on this cadence while foregrounded. `0` disables the interval. */ readonly intervalMs?: number; /** Run the sync when the app returns to the foreground. Default `true`. */ readonly syncOnForeground?: boolean; /** Master switch — when `false`, no trigger runs. Default `true`. */ readonly enabled?: boolean; /** Override the foreground signal source (RN passes an `AppState` wrapper). */ readonly subscribeForeground?: ForegroundSubscribe; /** Injectable clock, for tests. Default `Date.now`. */ readonly now?: () => number; } export declare interface UseBackgroundSyncResult extends BackgroundSyncState { /** Trigger a sync by hand. Bypasses the interval gate; still single-flight. */ readonly sync: () => void; } export declare const useMobileConnectionStatus: (options?: UseMobileConnectionStatusOptions) => MobileConnectionState & MobileConnectionControls; /** * Observe mobile connection health. * * ```tsx * const { status, reportFailure, reportSuccess } = useMobileConnectionStatus() * {status !== 'connected' && } * ``` * * Standalone by design (see the file header): it derives `offline` from the * injected {@link OnlineSource}, and `degraded` from failures the app reports — * it does not reach into any transport. Coming back online clears the failure * count, on the same reasoning as the web hook: those failures were the offline * window itself. */ export declare interface UseMobileConnectionStatusOptions { /** Where "online" comes from. Defaults to the browser source; pass * `netInfoOnlineSource(NetInfo)` on a device. */ readonly onlineSource?: OnlineSource; } /** * Wrap a WebSocket constructor so the supervisor hears what the socket does. * * Exported because it is the ONE piece of this module that can be tested * without a device: the socket itself is constructed lazily, deep inside the * Effect socket layer, when the client first connects — so a test that asserts * "a socket was created" asserts nothing until there is a real connection to * make. What CAN be pinned here is that a socket, once created, reports open, * close and error onward. Without those three the retry loop is never told * anything and a dropped connection stays dropped, silently, forever. * * RN needs no connect-timeout shim (web has one): a failed connect on a device * fires `error` and then `close`. */ export declare const wireSocket: (construct: (url: string, protocols?: string | ReadonlyArray) => WebSocket, onOpen: () => void, onIssue: (kind: "close" | "error" | "connect-timeout") => void) => (url: string, protocols?: string | ReadonlyArray) => WebSocket; export { }