Перейти к основному содержимому

Offline-first запись

Полевой пользователь (бурильщик, мастер смены) работает там, где связи нет или она рвётся. Сменный рапорт нельзя терять и нельзя дублировать. Этот рецепт показывает, как включить offline-устойчивую запись по ADR-0049: мутация офлайн паузится (а не падает), очередь персистится на диск и доигрывается строго по порядку при возврате связи или после рестарта приложения.

Все write-хуки сменного рапорта (useCreateShiftReport, useAdd*, useSubmitShiftReport, useRejectShiftReport из @mineflow/client-react) уже offline-ready из коробки. Включить нужно только персист + replay-defaults (один раз в провайдере) и, по желанию, UI-индикатор очереди.

примечание

Approve и reject-after-approve — это саги: они поллят статус и требуют связи, поэтому остаются online-only. Их в offline-домен не включают. Про саги — отдельный рецепт «Async-саги».

Как это работает: три кита

Offline-устойчивость держится на трёх вещах. Менять их по отдельности нельзя — выпадет любой из них, и гарантия ломается.

  1. Стабильный Idempotency-Key в variables. Ключ генерируется один раз при энкью мутации и кладётся в её variables. Он переживает рестарт (персистится вместе с мутацией) и переиспользуется на каждой попытке replay. Бэкенд дедуплицирует повтор по этому ключу — поэтому проиграть одну и ту же запись дважды безопасно.
  2. Общий scope. Все операции домена сериализуются TanStack Query строго FIFO (через MutationCache), потому что у них один scope.id. Без этого create → addAssetUsage → submit проигрались бы конкурентно, и submit ушёл бы раньше, чем рапорт создан.
  3. networkMode: 'online' (дефолт TanStack Query). Офлайн mutationFn не вызывается — мутация переходит в paused, а не в error. Авто-resume происходит по onlineManager, когда связь вернулась.
warning

Эти три вещи работают только вместе. Если задать мутации networkMode: 'always', она не будет паузиться — упадёт офлайн. Если у операций разный scope — replay пойдёт не по порядку. Если Idempotency-Key нестабилен (генерится в mutationFn) — повтор после рестарта создаст дубль.

Механику целиком инкапсулирует объект shiftReportDomain — инстанциация фабрики createOfflineDomain. Вызовы эндпоинтов в нём — единственный источник истины: их используют и live-хуки (useCreateShiftReport и т.д.), и replay восстановленных из персиста мутаций.

Шаг 1. Передавай стабильный idempotencyKey в vars

Сгенерируй ключ один раз — при энкью операции — и положи его в vars. Не генерируй его внутри обработчика submit на каждый рендер.

import { useCreateShiftReport, useAddShiftAssetUsage } from '@mineflow/client-react';

function NewShiftReport({ objectId, shiftDate, shift }) {
const create = useCreateShiftReport();
const addAsset = useAddShiftAssetUsage();

const onStart = () => {
// crypto.randomUUID на web; на RN — react-native-get-random-values.
const idempotencyKey = crypto.randomUUID();
create.mutate({ body: { objectId, shiftDate, shift }, idempotencyKey });
};

// отдельный ключ на каждую следующую операцию
const onAddAsset = (reportId: string, assetId: string) =>
addAsset.mutate({
id: reportId,
body: { assetId },
idempotencyKey: crypto.randomUUID(),
});

// …
}
подсказка

Один логический write = один стабильный ключ на всё время его жизни в очереди. Если пользователь нажал «добавить технику» один раз, у этой операции один ключ — даже если она паузилась, восстанавливалась после рестарта и проигрывалась повторно.

Полный список write-хуков рапорта и формы их vars — в гайде @mineflow/client-react § write-lifecycle, типы тел (AddShiftAssetUsageBody, AddEntryVars и т.д.) — в API-референсе.

Шаг 2. Включи персист + replay в провайдере

Persist и replay настраиваются один раз — внутри <MineflowProvider> (нужны и QueryClient, и API-клиент). Эталон — apps/mobile/src/app/providers/offline-gate.tsx. Ниже разобранная по частям версия для React Native.

import { shiftReportDomain, useMineflowClient } from '@mineflow/client-react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { onlineManager, useQueryClient, type Mutation } from '@tanstack/react-query';
import {
persistQueryClientRestore,
persistQueryClientSave,
persistQueryClientSubscribe,
} from '@tanstack/react-query-persist-client';
import { useEffect } from 'react';
import { AppState } from 'react-native';

// Персистим И поставленные в очередь (paused), И упавшие (error) записи — данные
// полевого пользователя нельзя терять. У саг нет mutationKey → они исключены.
function shouldDehydrateMutation(m: Mutation): boolean {
return (m.state.isPaused || m.state.status === 'error') && Array.isArray(m.options.mutationKey);
}

export function OfflineGate({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const client = useMineflowClient();

// (1) replay-defaults: восстановленные из персиста мутации (без mutationFn —
// только key + variables) узнают, как себя проиграть. registerDefaults
// идемпотентен, поэтому повторный прогон безопасен.
useEffect(() => {
shiftReportDomain.registerDefaults(queryClient, client);
}, [queryClient, client]);

// (2) restore + persist + resume — ровно один раз (queryClient стабилен).
useEffect(() => {
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
const opts = {
queryClient,
persister,
maxAge: Infinity, // write-outbox НЕ должен протухать (см. предупреждение ниже)
buster: 'v2',
dehydrateOptions: { shouldDehydrateMutation },
};

let active = true;
let unsubscribePersist = () => {};
void persistQueryClientRestore(opts).then(() => {
if (!active) return;
unsubscribePersist = persistQueryClientSubscribe(opts);
void queryClient.resumePausedMutations(); // доиграть очередь после старта
});

// backstop: пинаем очередь, когда связь вернулась
const unsubscribeOnline = onlineManager.subscribe(() => {
if (onlineManager.isOnline()) void queryClient.resumePausedMutations();
});

// flush при уходе в фон — переживёт kill в окне throttle персистера
const appStateSub = AppState.addEventListener('change', (s) => {
if (s === 'background' || s === 'inactive') void persistQueryClientSave(opts);
});

return () => {
active = false;
unsubscribePersist();
unsubscribeOnline();
appStateSub.remove();
};
}, [queryClient]);

return <>{children}</>;
}

Дальше — оборачивай детей <MineflowProvider> в <OfflineGate> (он должен рендериться внутри провайдера):

<MineflowProvider /* … */>
<OfflineGate>{children}</OfflineGate>
</MineflowProvider>
warning

registerDefaults зовётся ДО resumePausedMutations. Восстановленные из персиста мутации несут только mutationKey + variables, у них нет mutationFn. Без зарегистрированных через registerDefaults дефолтов resume не будет знать, как их проиграть.

warning

Два отдельных useEffect — это намеренно. Defaults зависят от API-клиента (могут перезапуститься при смене клиента — это безопасно, setMutationDefaults идемпотентен). А restore/subscribe/resume зависит только от стабильного queryClient и выполняется ровно один раз: смена идентичности клиента (например, когда дорезолвился Keycloak discovery) не должна перегидрировать персист — у гидрации нет per-mutation дедупа, иначе каждая запись попадёт в очередь дважды.

примечание

maxAge: Infinity для write-очереди обязателен. Read-кэш остаётся свежим через staleTime/refetch, но если рабочий был офлайн дольше дефолтного maxAge (24 ч), single-blob персистер при следующем запуске выбросит весь блоб — и потеряет все накопленные записи.

Шаг 3. Покажи очередь и дай ретрай

UI-индикатор строится на трёх хуках. useIsOnline — текущая «онлайновость» (по onlineManager). shiftReportDomain.useOutbox() — сводка очереди. shiftReportDomain.useRetryFailed() — повтор упавших.

import { shiftReportDomain, useIsOnline } from '@mineflow/client-react';

function OutboxBanner() {
const online = useIsOnline();
const { paused, pending, error, total } = shiftReportDomain.useOutbox();
const retry = shiftReportDomain.useRetryFailed();

if (online && total === 0) return null; // всё отправлено, баннер скрыт

return (
<Banner onPress={error > 0 ? retry : undefined}>
{!online && `Офлайн · в очереди: ${paused}`}
{pending > 0 && `Отправка: ${pending}`}
{error > 0 && `Не отправлено: ${error} · нажмите для повтора`}
</Banner>
);
}

useOutbox() возвращает OutboxStatus:

ПолеЗначение
pausedпоставлены в очередь, ждут связи (офлайн)
pendingвыполняются прямо сейчас
errorупали (4xx/5xx после исчерпания попыток) — требуют внимания
totalвсего незавершённых (paused + pending + error)
подсказка

resumePausedMutations (из шага 2) сам доигрывает только paused-мутации. Упавшие (error) он не трогает — для них нужен явный useRetryFailed(). Re-execute переиспользует те же variables (тот же стабильный Idempotency-Key), поэтому повтор идемпотентен на бэке.

Полные сигнатуры useOutbox/useRetryFailed/useIsOnline — в API-референсе.

Свой offline-домен для другого агрегата

shiftReportDomain — это инстанциация фабрики createOfflineDomain. Тот же паттерn нужен любому домену, которому требуется офлайн-запись (например, SCM-движения). Соберёшь свой домен — получишь тот же набор registerDefaults / useOutbox / useRetryFailed:

import { createOfflineDomain } from '@mineflow/client-react';
import { queryKeys } from '@mineflow/client-react';
import { unwrap, type MineflowClient } from '@mineflow/client-core';

const fuelOps = {
issue: async (c: MineflowClient, v: { body: IssueFuelBody; idempotencyKey?: string }) =>
unwrap(
await c.POST('/api/v1/scm/fuel/issuances', {
body: v.body,
params: { header: { 'Idempotency-Key': v.idempotencyKey ?? '' } },
}),
),
// …другие операции
};

export const fuelDomain = createOfflineDomain({
scope: 'scm-fuel-write', // уникальный scope.id → строгий FIFO внутри домена
invalidateKey: queryKeys.assets.all, // ключ инвалидации списков домена на успехе
mutationKeyPrefix: ['scm', 'fuel'], // префикс, по которому фильтруется outbox
ops: fuelOps,
mutationKeys: { issue: ['scm', 'fuel', 'issue'] }, // ключи совпадают с ops
});

Дальше используй fuelDomain.ops / fuelDomain.mutationKeys / fuelDomain.scope в live-хуках через useDomainMutation, а fuelDomain.registerDefaults / useOutbox / useRetryFailed — для replay и UI (так же, как с рапортами). Сигнатуры createOfflineDomain и OfflineDomainConfig — в API-референсе.

warning

mutationKeyPrefix должен быть началом каждого ключа в mutationKeys, иначе useOutbox/useRetryFailed не увидят мутации домена. И каждый ключ в mutationKeys должен совпадать у live-хука и у registerDefaults — иначе replay не найдёт дефолт.

Чек-лист

  • idempotencyKey генерируется один раз при энкью и кладётся в vars (стабилен на всё время жизни операции).
  • <OfflineGate> рендерится внутри <MineflowProvider>.
  • registerDefaults вызывается до resumePausedMutations.
  • persistQueryClient* настроен с maxAge: Infinity и shouldDehydrateMutation, который пропускает paused + error и только мутации с mutationKey.
  • UI показывает очередь (useOutbox/useIsOnline) и даёт ручной ретрай упавших (useRetryFailed).

Связанные материалы