@krymskyimaksym/react-api-client 2.0.0-beta.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -290,28 +290,163 @@ const httpClient = {
290
290
  };
291
291
  ```
292
292
 
293
- ## `mutate` vs `mutateAsync`
293
+ ## Backend integrations
294
294
 
295
- `useMutation` возвращает оба варианта вызова намеренно с разными типами.
295
+ С версии 2.0 пакет полностью backend-agnostic. Контракт «как
296
+ разговариваем с бекендом» описывается одним объектом `responseAdapter`
297
+ в `configureApiClient`. Без него — встроенный Laravel-fallback
298
+ (совместимо с 1.x).
299
+
300
+ Готовые адаптеры экспортируются из core:
301
+ `laravelAdapter`, `jsonApiAdapter`, `graphqlAdapter`, `problemJsonAdapter`,
302
+ `plainAdapter`.
303
+
304
+ Дополнительно — module augmentation `Register['responseShape']`
305
+ переключает **типы** так же, как адаптер переключает **runtime**.
306
+
307
+ ### Laravel (default)
308
+
309
+ ```ts
310
+ import { configureApiClient, laravelAdapter } from '@krymskyimaksym/react-api-client';
311
+
312
+ configureApiClient({ httpClient, responseAdapter: laravelAdapter });
313
+ // или просто:
314
+ configureApiClient({ httpClient });
315
+ // useFetch отдаёт ResponseWrapper<T> = { status, message?, errors?, ...T }
316
+ // { status: false } → throw ApiError
317
+ ```
318
+
319
+ ### Plain REST (Express, Fastify, Nest)
320
+
321
+ ```ts
322
+ import { configureApiClient, plainAdapter } from '@krymskyimaksym/react-api-client';
323
+
324
+ // Типы: переключаем DataOf<T> = T (без обёртки)
325
+ declare module '@krymskyimaksym/react-api-client' {
326
+ interface Register {
327
+ responseShape: 'plain';
328
+ }
329
+ }
330
+
331
+ configureApiClient({ httpClient, responseAdapter: plainAdapter });
332
+ // useFetch<User>() возвращает User напрямую. Ошибки только по HTTP-статусам.
333
+ ```
334
+
335
+ ### JSON:API
336
+
337
+ ```ts
338
+ import { configureApiClient, jsonApiAdapter } from '@krymskyimaksym/react-api-client';
339
+
340
+ declare module '@krymskyimaksym/react-api-client' {
341
+ interface Register {
342
+ responseShape: 'jsonapi';
343
+ }
344
+ }
345
+
346
+ configureApiClient({ httpClient, responseAdapter: jsonApiAdapter });
347
+ // unwrap: r.data → useFetch<User>() возвращает User
348
+ // { errors: [...] } → throw ApiError с detail из первого error
349
+ ```
350
+
351
+ ### GraphQL (любой клиент под капотом)
352
+
353
+ ```ts
354
+ import { configureApiClient, graphqlAdapter } from '@krymskyimaksym/react-api-client';
355
+
356
+ declare module '@krymskyimaksym/react-api-client' {
357
+ interface Register {
358
+ responseShape: 'graphql';
359
+ }
360
+ }
361
+
362
+ configureApiClient({ httpClient, responseAdapter: graphqlAdapter });
363
+ // unwrap: r.data → useFetch<Viewer>() возвращает Viewer
364
+ // { errors: [...] } → throw ApiError({ errors: GraphQLErrors[] })
365
+ ```
366
+
367
+ ### RFC 7807 problem+json
296
368
 
297
369
  ```ts
298
- const { mutate, mutateAsync } = api.useMutation();
370
+ configureApiClient({ httpClient, responseAdapter: problemJsonAdapter });
371
+ // 2xx — identity, никакой бизнес-логики поверх HTTP.
372
+ // >=400 + Content-Type: application/problem+json → ApiError с title/type.
373
+ ```
374
+
375
+ ### Свой адаптер
376
+
377
+ ```ts
378
+ import type { ResponseAdapter } from '@krymskyimaksym/react-api-client';
379
+ import { ApiError } from '@krymskyimaksym/react-api-client';
380
+
381
+ const myAdapter: ResponseAdapter = {
382
+ unwrap: r => (r as { payload: unknown }).payload,
383
+ isBusinessError: r => (r as { ok?: boolean }).ok === false,
384
+ toError: (r, http) => new ApiError({
385
+ message: (r as { error?: string }).error ?? `HTTP ${http}`,
386
+ status: http,
387
+ raw: r,
388
+ }),
389
+ };
390
+ ```
391
+
392
+ ## Companion packages
299
393
 
300
- // Анти-паттерн: mutate возвращает void, не Promise.
301
- // `await` отдаст undefined; try/catch не сработает.
302
- await mutate({ id: 1 });
394
+ | Package | What |
395
+ |---|---|
396
+ | [`@krymskyimaksym/react-api-client-devtools`](https://www.npmjs.com/package/@krymskyimaksym/react-api-client-devtools) | DevTools UI: `<CacheDebugScreen>` for RN, `<CacheDevtoolsPanel>` for Web |
397
+ | [`@krymskyimaksym/eslint-plugin-react-api-client`](https://www.npmjs.com/package/@krymskyimaksym/eslint-plugin-react-api-client) | ESLint rules: `no-await-mutate` (autofix), `no-non-serializable-params`, `require-query-key-when-endpoint-is-fn` |
303
398
 
304
- // Правильно: для последовательной логики или try/catch — mutateAsync.
399
+ ## React Native
400
+
401
+ Пакет не зависит от `react-native`, поэтому `focusManager` /
402
+ `onlineManager` в RN-приложениях нужно подключать вручную при старте:
403
+
404
+ ```tsx
405
+ import { AppState } from 'react-native';
406
+ import NetInfo from '@react-native-community/netinfo';
407
+ import { focusManager, onlineManager } from '@krymskyimaksym/react-api-client';
408
+
409
+ // При запуске приложения (например, в App.tsx)
410
+ AppState.addEventListener('change', state => {
411
+ focusManager.setFocused(state === 'active');
412
+ });
413
+
414
+ // Опционально — NetInfo (если установлен в проекте)
415
+ NetInfo.addEventListener(s => onlineManager.setOnline(!!s.isConnected));
416
+ ```
417
+
418
+ После этого опции `refetchOnFocus`, `refetchOnAppActive`,
419
+ `refetchOnReconnect` в `useFetch` начнут работать.
420
+
421
+ ## `mutateAsync` vs `mutate`
422
+
423
+ `useMutation` возвращает два варианта вызова — намеренно с разными
424
+ типами. **Рекомендуемое имя по умолчанию — `mutateAsync`.**
425
+
426
+ ```tsx
427
+ // ✅ Рекомендуемый паттерн: достаём mutateAsync первым.
428
+ const { mutateAsync, isLoading } = api.useMutation();
429
+
430
+ // Для последовательной логики или try/catch — mutateAsync (Promise).
305
431
  try {
306
432
  const result = await mutateAsync({ id: 1 });
307
433
  } catch (e) {
308
434
  // обработка
309
435
  }
310
436
 
311
- // Правильно: для fire-and-forget (кнопка с onClick) — mutate.
437
+ // Для fire-and-forget (кнопка с onClick) — тоже mutateAsync с void:
438
+ <Button onPress={() => { void mutateAsync({ id: 1 }); }} />;
439
+
440
+ // `mutate` остаётся доступным как короткий fire-and-forget без await.
441
+ const { mutate } = api.useMutation();
312
442
  <Button onPress={() => mutate({ id: 1 })} />;
313
443
  ```
314
444
 
445
+ ❌ **Анти-паттерн:** `await mutate(...)` — `mutate` возвращает `void`,
446
+ `await` отдаст `undefined`, `try/catch` не сработает. ESLint-плагин
447
+ `@krymskyimaksym/eslint-plugin-react-api-client` ловит это правилом
448
+ `no-await-mutate` (с autofix → `mutateAsync`).
449
+
315
450
  Если включён `throwOnError: true` — для критичных мутаций используй
316
451
  `mutateAsync` и обёртку `try/catch`, иначе ошибка не будет
317
452
  поймана в caller'е.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,92 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
 
4
+ /**
5
+ * Унифицированная ошибка API. Кидается из `executeRequest` (а значит,
6
+ * из `fetch`/`mutate`/хуков) только когда в `configureApiClient` включён
7
+ * `throwOnError: true`. По умолчанию поведение пакета не меняется —
8
+ * ошибки приходят как `{ status: false }` (см. CHANGELOG, Фаза 3.5).
9
+ */
10
+ type ApiErrorInit<E = unknown> = {
11
+ message: string;
12
+ status: number;
13
+ code?: string;
14
+ errors?: E;
15
+ isNetworkError?: boolean;
16
+ isUnauthorized?: boolean;
17
+ isValidationError?: boolean;
18
+ raw?: unknown;
19
+ };
20
+ declare class ApiError<E = unknown> extends Error {
21
+ readonly status: number;
22
+ readonly code?: string;
23
+ readonly errors?: E;
24
+ readonly isNetworkError: boolean;
25
+ readonly isUnauthorized: boolean;
26
+ readonly isValidationError: boolean;
27
+ readonly raw: unknown;
28
+ constructor(init: ApiErrorInit<E>);
29
+ }
30
+ /**
31
+ * Конвертация произвольного throw'нутого значения в `ApiError`.
32
+ * Покрывает 4 кейса:
33
+ * - `ApiError` → passthrough
34
+ * - axios-like `{ response: { status, data } }` → ApiError с полями из data
35
+ * - `Error` (сетевая ошибка, таймаут) → ApiError(isNetworkError, status: 0)
36
+ * - неизвестный объект → ApiError(message: 'Unknown error', status: 0)
37
+ */
38
+ declare function toApiError(thrown: unknown): ApiError;
39
+ /**
40
+ * Для 2xx ответа, в теле которого Laravel-style `{ status: false }`.
41
+ * Возвращает ApiError(status: 200, ...). Вызывается из executeRequest
42
+ * только при `throwOnError: true`.
43
+ */
44
+ declare function businessErrorToApiError(response: unknown): ApiError;
45
+
46
+ /**
47
+ * Описывает «как мы общаемся с бекендом»: как распаковывать успешный
48
+ * ответ, как детектить бизнес-ошибку в 2xx-ответе и как конвертировать
49
+ * сырое тело в `ApiError`.
50
+ *
51
+ * Передаётся один раз в `configureApiClient({ responseAdapter })`.
52
+ * Все три поля опциональны: дефолты дают Laravel-поведение (обратная
53
+ * совместимость с 1.x / 2.0).
54
+ */
55
+ type ResponseAdapter<TRaw = unknown, TUnwrapped = unknown> = {
56
+ /**
57
+ * Распаковать 2xx-ответ в данные для caller'а. Вызывается ровно один
58
+ * раз сразу после получения тела, ДО записи в кэш и ДО `select`.
59
+ * По умолчанию — identity.
60
+ */
61
+ unwrap?: (raw: TRaw) => TUnwrapped;
62
+ /**
63
+ * Является ли 2xx-ответ бизнес-ошибкой (Laravel `{status: false}`,
64
+ * GraphQL `{ errors: [...] }`, и т.п.). Если true — пакет вызовет
65
+ * `toError` и кинет `ApiError` вместо resolve.
66
+ * По умолчанию — `(r) => r?.status === false` (Laravel).
67
+ */
68
+ isBusinessError?: (raw: TRaw) => boolean;
69
+ /**
70
+ * Сконвертировать сырое тело в `ApiError`. Вызывается для:
71
+ * - HTTP ≥ 400
72
+ * - 2xx + `isBusinessError === true`
73
+ * - сетевая ошибка / таймаут (`httpStatus = 0`)
74
+ *
75
+ * По умолчанию — текущий маппинг (`message`/`code`/`errors`).
76
+ */
77
+ toError?: (raw: unknown, httpStatus: number) => ApiError;
78
+ };
79
+ /** Laravel-стиль: `{ status: false, message, errors }`. Default. */
80
+ declare const laravelAdapter: ResponseAdapter;
81
+ /** JSON:API. `unwrap` отдаёт `r.data`, ошибки — массив в `r.errors`. */
82
+ declare const jsonApiAdapter: ResponseAdapter;
83
+ /** GraphQL. `unwrap` отдаёт `r.data`, ошибки в `r.errors`. */
84
+ declare const graphqlAdapter: ResponseAdapter;
85
+ /** RFC 7807 problem+json. Бизнес-ошибок в 2xx не бывает — только HTTP. */
86
+ declare const problemJsonAdapter: ResponseAdapter;
87
+ /** Plain REST: никаких бизнес-ошибок в 2xx, identity unwrap. */
88
+ declare const plainAdapter: ResponseAdapter;
89
+
4
90
  type QueryKey = readonly unknown[];
5
91
  /**
6
92
  * Стабильная сериализация ключа кэша.
@@ -80,7 +166,7 @@ declare class QueryCache {
80
166
  private ensureEntry;
81
167
  getState<T>(key: QueryKey): QueryState<T> | undefined;
82
168
  getData<T>(key: QueryKey): T | undefined;
83
- setData<T>(key: QueryKey, updater: T | ((prev: T | undefined) => T)): void;
169
+ setData<T>(key: QueryKey, updater: T | ((prev: T | undefined) => T)): T;
84
170
  /**
85
171
  * Подписка на изменения ключа. Возвращает unsubscribe.
86
172
  * Подписка останавливает GC таймер; отписка — запускает его обратно.
@@ -106,10 +192,16 @@ declare class QueryCache {
106
192
  */
107
193
  refetchQueries(predicate: QueryKey | ((key: QueryKey) => boolean)): Promise<void>;
108
194
  /**
109
- * Отменяет «привязку» inflight-промиса к ключу. Сам HTTP-запрос
110
- * продолжит исполняться (executeRequest не использует AbortSignal),
111
- * но его результат больше не попадёт в кэш и не уведомит подписчиков.
112
- * Полезно при размонтировании / при переключении страниц.
195
+ * Отменяет inflight-запрос: пробрасывает abort через `AbortSignal`
196
+ * в `queryFn` (т.е. в `executeRequest` `httpClient`) и отвязывает
197
+ * результат от ключа.
198
+ *
199
+ * Если `httpClient` уважает `signal` (`fetch` нативный, axios v1+
200
+ * с `signal`, и т.п.) — HTTP-запрос реально прерывается. Если
201
+ * игнорирует — поведение деградирует: запрос продолжит исполняться,
202
+ * но его ответ уже не попадёт в кэш и подписчиков не уведомит.
203
+ *
204
+ * Полезно при размонтировании / переключении страниц / logout'е.
113
205
  */
114
206
  cancelQueries(predicate: QueryKey | ((key: QueryKey) => boolean)): void;
115
207
  /**
@@ -155,15 +247,42 @@ declare class QueryClient {
155
247
  readonly cache: QueryCache;
156
248
  constructor(cache?: QueryCache);
157
249
  getQueryData<T>(key: QueryKey): T | undefined;
158
- setQueryData<T>(key: QueryKey, updater: T | ((prev: T | undefined) => T)): void;
250
+ /**
251
+ * Точечно патчит данные под ключом. Возвращает новое значение —
252
+ * удобно для «пропатчил → передал дальше».
253
+ */
254
+ setQueryData<T>(key: QueryKey, updater: T | ((prev: T | undefined) => T)): T;
159
255
  fetchQuery<T>(key: QueryKey, queryFn: QueryFn<T>, options?: FetchOptions): Promise<T>;
160
256
  invalidateQueries(predicate: QueryKey | ((key: QueryKey) => boolean)): void;
161
257
  removeQueries(predicate: QueryKey | ((key: QueryKey) => boolean)): void;
162
258
  cancelQueries(predicate: QueryKey | ((key: QueryKey) => boolean)): void;
259
+ /**
260
+ * Отменяет inflight-мутации через `AbortSignal`. Без аргумента — все.
261
+ * С префиксом QueryKey или функцией-предикатом — только матчинг
262
+ * (scope мутации = первый ключ из её `invalidateKeys`, если задан массив).
263
+ *
264
+ * Если `httpClient` уважает `signal` — HTTP-запрос реально прерывается;
265
+ * иначе результат отменённой мутации просто не повлияет на UI
266
+ * (`useMutation` останется в текущем состоянии).
267
+ *
268
+ * Типичный кейс — logout: `client.cancelMutations()` перед очисткой
269
+ * сессии, чтобы поздние ответы не сработали.
270
+ */
271
+ cancelMutations(predicate?: QueryKey | ((scope: QueryKey | undefined) => boolean)): void;
163
272
  refetchQueries(predicate: QueryKey | ((key: QueryKey) => boolean)): Promise<void>;
164
273
  /**
165
- * Кладёт запрос в кэш, не пробрасывая ошибки. Подходит для оптимистичной
166
- * подгрузки следующего экрана при наведении / долгом тапе.
274
+ * Кладёт запрос в кэш, не пробрасывая ошибки. Подходит для
275
+ * оптимистичной подгрузки следующего экрана при наведении / долгом тапе.
276
+ *
277
+ * Поведение:
278
+ * - **`staleTime`**: учитывается. Если данные ещё свежие — запрос не
279
+ * отправляется, promise резолвится сразу.
280
+ * - **`inflight`**: если по ключу уже идёт запрос, prefetch присоединяется
281
+ * к нему (dedupe). Не создаёт второй HTTP-вызов.
282
+ * - **Подписчики**: если на ключ подписан `useFetch`, успешный prefetch
283
+ * обновит его `data` (через notify подписчиков). При ошибке — статус
284
+ * подписчика тоже обновится (`error`).
285
+ * - **Возвращаемый promise**: всегда успешный — ошибки не пробрасываются.
167
286
  */
168
287
  prefetchQuery<T>(key: QueryKey, queryFn: QueryFn<T>, options?: FetchOptions): Promise<void>;
169
288
  }
@@ -180,6 +299,32 @@ type ResponseWrapper<DataType, ErrorsType = unknown> = {
180
299
  message?: string;
181
300
  errors?: ErrorsType;
182
301
  } & DataType;
302
+ /**
303
+ * Module augmentation для отвязки публичных типов от Laravel-обёртки.
304
+ *
305
+ * Пользователь дополняет этот интерфейс в своём проекте:
306
+ *
307
+ * ```ts
308
+ * declare module '@krymskyimaksym/react-api-client' {
309
+ * interface Register {
310
+ * responseShape: 'plain'; // 'laravel' | 'jsonapi' | 'graphql' | 'plain'
311
+ * }
312
+ * }
313
+ * ```
314
+ *
315
+ * По умолчанию (Register пустой) — `DataOf<T> = ResponseWrapper<T>`,
316
+ * совместимо с 1.x / 2.0.
317
+ */
318
+ interface Register {
319
+ }
320
+ /**
321
+ * Условный тип «форма данных», возвращаемая `fetch` / `useFetch` /
322
+ * `useMutation`. Зависит от `Register['responseShape']`. По умолчанию
323
+ * — `ResponseWrapper<T>` (back-compat).
324
+ */
325
+ type DataOf<T, E = unknown> = Register extends {
326
+ responseShape: infer S;
327
+ } ? S extends 'plain' ? T : S extends 'laravel' ? ResponseWrapper<T, E> : S extends 'jsonapi' ? T : S extends 'graphql' ? T : ResponseWrapper<T, E> : ResponseWrapper<T, E>;
183
328
  type UseFetchOptions<T, TSelected = T> = {
184
329
  /**
185
330
  * При `false`:
@@ -212,8 +357,19 @@ type UseFetchOptions<T, TSelected = T> = {
212
357
  * `['__endpoint__', endpointString, params]`.
213
358
  */
214
359
  queryKey?: readonly unknown[];
215
- /** Селектор результата — пересчитывается мемоизированно. */
360
+ /**
361
+ * Селектор результата — пересчитывается мемоизированно.
362
+ * Изменение исходных `data` запускает `select` заново; чтобы избежать
363
+ * лишних ререндеров при равных, но новых по ссылке объектах — задай
364
+ * `selectIsEqual`.
365
+ */
216
366
  select?: (data: T) => TSelected;
367
+ /**
368
+ * Сравнение результатов `select` для предотвращения ререндеров.
369
+ * По умолчанию — `Object.is` (референсное равенство).
370
+ * Передавай `shallowEqual`/`deepEqual` для structural-сравнения.
371
+ */
372
+ selectIsEqual?: (a: TSelected, b: TSelected) => boolean;
217
373
  onSuccess?: (data: T) => void;
218
374
  onError?: (error: Error) => void;
219
375
  };
@@ -224,7 +380,7 @@ type UseFetchResult<T> = {
224
380
  error: Error | null;
225
381
  refetch: () => Promise<void>;
226
382
  };
227
- type UsePaginateOptions<T> = {
383
+ type UsePaginateOptions<T, TData = unknown[], TSelected = TData> = {
228
384
  enabled?: boolean;
229
385
  initialPage?: number;
230
386
  initialLimit?: number;
@@ -238,10 +394,17 @@ type UsePaginateOptions<T> = {
238
394
  keepPreviousData?: boolean;
239
395
  /** Кастомный префикс ключа кэша. По умолчанию — endpoint + serialized params. */
240
396
  queryKey?: readonly unknown[];
397
+ /**
398
+ * Селектор поверх массива страницы. По умолчанию — identity.
399
+ * Применяется к уже извлечённому массиву `TData`.
400
+ */
401
+ select?: (data: TData) => TSelected;
402
+ /** Сравнение результатов `select` для предотвращения ререндеров. По умолчанию `Object.is`. */
403
+ selectIsEqual?: (a: TSelected, b: TSelected) => boolean;
241
404
  onSuccess?: (data: T) => void;
242
405
  onError?: (error: Error) => void;
243
406
  };
244
- type UsePaginateResult<TData extends unknown[]> = {
407
+ type UsePaginateResult<TData> = {
245
408
  data: TData;
246
409
  currentPage: number;
247
410
  totalPages: number | null;
@@ -338,17 +501,32 @@ type ApiClientConfig = {
338
501
  throwOnError?: boolean;
339
502
  /** Опциональный логгер для отладки. */
340
503
  logger?: ApiClientLogger;
504
+ /**
505
+ * Описывает форму ответа бекенда: как unwrap'ить успех, как
506
+ * детектить бизнес-ошибку в 2xx, как конвертировать в ApiError.
507
+ * Если не задан — встроенный laravel-fallback (`{ status: false }`
508
+ * как признак бизнес-ошибки). См. `adapters.ts`.
509
+ */
510
+ responseAdapter?: ResponseAdapter;
511
+ /**
512
+ * Дефолтный `staleTime` для всех хуков `useFetch` / `usePaginate`.
513
+ * Если хук задал свой `staleTime` — он перекрывает этот дефолт.
514
+ * Значение по умолчанию `0` (старое поведение — данные сразу stale,
515
+ * каждый mount = новый запрос). Поднимай до `5_000`–`30_000`, чтобы
516
+ * избежать лишних refetch'ей при mount в среднем SPA-сценарии.
517
+ */
518
+ defaultStaleTime?: number;
341
519
  };
342
520
  type ApiClientReturn<ResponseType, RequestParamsType, ErrorResponseType = unknown> = {
343
- fetch: (params?: RequestParamsType) => Promise<ResponseWrapper<ResponseType, ErrorResponseType>>;
344
- useFetch: <TSelected = ResponseWrapper<ResponseType, ErrorResponseType>>(params?: RequestParamsType, options?: UseFetchOptions<ResponseWrapper<ResponseType, ErrorResponseType>, TSelected>) => UseFetchResult<TSelected>;
521
+ fetch: (params?: RequestParamsType) => Promise<DataOf<ResponseType, ErrorResponseType>>;
522
+ useFetch: <TSelected = DataOf<ResponseType, ErrorResponseType>>(params?: RequestParamsType, options?: UseFetchOptions<DataOf<ResponseType, ErrorResponseType>, TSelected>) => UseFetchResult<TSelected>;
345
523
  };
346
524
  type ApiMutationReturn<ResponseType, RequestParamsType, ErrorResponseType = unknown> = {
347
- mutate: (params?: RequestParamsType) => Promise<ResponseWrapper<ResponseType, ErrorResponseType>>;
348
- useMutation: <TContext = unknown>(options?: UseMutationOptions<ResponseWrapper<ResponseType, ErrorResponseType>, RequestParamsType, TContext>) => UseMutationResult<ResponseWrapper<ResponseType, ErrorResponseType>, RequestParamsType>;
525
+ mutate: (params?: RequestParamsType) => Promise<DataOf<ResponseType, ErrorResponseType>>;
526
+ useMutation: <TContext = unknown>(options?: UseMutationOptions<DataOf<ResponseType, ErrorResponseType>, RequestParamsType, TContext>) => UseMutationResult<DataOf<ResponseType, ErrorResponseType>, RequestParamsType>;
349
527
  };
350
528
  type ApiPaginateReturn<ResponseType, RequestParamsType, DataArrayType extends unknown[], ErrorResponseType = unknown> = {
351
- usePaginate: (params?: Omit<RequestParamsType, 'page' | 'limit'>, options?: UsePaginateOptions<ResponseWrapper<ResponseType, ErrorResponseType>>) => UsePaginateResult<DataArrayType>;
529
+ usePaginate: <TSelected = DataArrayType>(params?: Omit<RequestParamsType, 'page' | 'limit'>, options?: UsePaginateOptions<DataOf<ResponseType, ErrorResponseType>, DataArrayType, TSelected>) => UsePaginateResult<TSelected>;
352
530
  };
353
531
 
354
532
  /**
@@ -375,48 +553,6 @@ declare function getConfig(): ApiClientConfig;
375
553
  */
376
554
  declare function isConfigured(): boolean;
377
555
 
378
- /**
379
- * Унифицированная ошибка API. Кидается из `executeRequest` (а значит,
380
- * из `fetch`/`mutate`/хуков) только когда в `configureApiClient` включён
381
- * `throwOnError: true`. По умолчанию поведение пакета не меняется —
382
- * ошибки приходят как `{ status: false }` (см. CHANGELOG, Фаза 3.5).
383
- */
384
- type ApiErrorInit<E = unknown> = {
385
- message: string;
386
- status: number;
387
- code?: string;
388
- errors?: E;
389
- isNetworkError?: boolean;
390
- isUnauthorized?: boolean;
391
- isValidationError?: boolean;
392
- raw?: unknown;
393
- };
394
- declare class ApiError<E = unknown> extends Error {
395
- readonly status: number;
396
- readonly code?: string;
397
- readonly errors?: E;
398
- readonly isNetworkError: boolean;
399
- readonly isUnauthorized: boolean;
400
- readonly isValidationError: boolean;
401
- readonly raw: unknown;
402
- constructor(init: ApiErrorInit<E>);
403
- }
404
- /**
405
- * Конвертация произвольного throw'нутого значения в `ApiError`.
406
- * Покрывает 4 кейса:
407
- * - `ApiError` → passthrough
408
- * - axios-like `{ response: { status, data } }` → ApiError с полями из data
409
- * - `Error` (сетевая ошибка, таймаут) → ApiError(isNetworkError, status: 0)
410
- * - неизвестный объект → ApiError(message: 'Unknown error', status: 0)
411
- */
412
- declare function toApiError(thrown: unknown): ApiError;
413
- /**
414
- * Для 2xx ответа, в теле которого Laravel-style `{ status: false }`.
415
- * Возвращает ApiError(status: 200, ...). Вызывается из executeRequest
416
- * только при `throwOnError: true`.
417
- */
418
- declare function businessErrorToApiError(response: unknown): ApiError;
419
-
420
556
  /**
421
557
  * Возвращает число активных (inflight) запросов в кэше.
422
558
  * Полезно для глобального индикатора загрузки в шапке.
@@ -432,6 +568,63 @@ declare function useIsFetching(predicate?: QueryKey | ((key: QueryKey) => boolea
432
568
  */
433
569
  declare function useIsMutating(predicate?: QueryKey | ((scope: QueryKey | undefined) => boolean)): number;
434
570
 
571
+ /** Опции для низкоуровневого `useQuery`. */
572
+ type UseQueryOptions<T, TSelected = T> = {
573
+ enabled?: boolean;
574
+ refetchOnMount?: boolean;
575
+ refetchOnFocus?: boolean;
576
+ refetchOnAppActive?: boolean;
577
+ refetchOnReconnect?: boolean;
578
+ staleTime?: number;
579
+ gcTime?: number;
580
+ pollingInterval?: number;
581
+ select?: (data: T) => TSelected;
582
+ selectIsEqual?: (a: TSelected, b: TSelected) => boolean;
583
+ };
584
+ type UseQueryResult<T> = {
585
+ data: T | null;
586
+ isLoading: boolean;
587
+ isRefetching: boolean;
588
+ error: Error | null;
589
+ refetch: () => Promise<void>;
590
+ };
591
+ /**
592
+ * Низкоуровневый аналог `useFetch` поверх произвольного `queryFn`.
593
+ * Имя совпадает с TanStack Query для discoverability — поведение
594
+ * аналогично `useFetch`, но без обёртки `apiClient(endpoint, ...)`.
595
+ *
596
+ * @example
597
+ * const { data } = useQuery(
598
+ * ['orders', orderId],
599
+ * ({ signal }) => fetchOrder(orderId, signal),
600
+ * { staleTime: 30_000 },
601
+ * );
602
+ */
603
+ declare function useQuery<T, TSelected = T>(queryKey: QueryKey, queryFn: QueryFn<T>, options?: UseQueryOptions<T, TSelected>): UseQueryResult<TSelected>;
604
+
605
+ /**
606
+ * Пакетное чтение нескольких ключей из кэша. Хук подписан на каждый
607
+ * ключ и перерендерится, когда любое из значений изменится.
608
+ *
609
+ * Возвращает массив значений того же порядка, что и `keys`. Если ключа
610
+ * нет в кэше — соответствующий элемент `undefined`.
611
+ *
612
+ * Не делает запросов — только читает. Если данных нужно «загрузить» —
613
+ * используй `useFetch`/`useQuery` отдельно.
614
+ *
615
+ * @example
616
+ * const [orders, chats, tasks] = useQueriesData<
617
+ * [Board, ChatsUnread, TasksNew]
618
+ * >([
619
+ * ['orders', 'board'],
620
+ * ['chats', 'unread'],
621
+ * ['tasks', 'new'],
622
+ * ]);
623
+ */
624
+ declare function useQueriesData<T extends readonly unknown[]>(keys: readonly QueryKey[]): {
625
+ [K in keyof T]: T[K] | undefined;
626
+ };
627
+
435
628
  /**
436
629
  * Storage-адаптер. Совместим с AsyncStorage и expo-secure-store:
437
630
  * { getItem(key): Promise<string|null>, setItem(key, value): Promise<void>,
@@ -622,4 +815,4 @@ declare function apiPaginate<ResponseType extends {
622
815
  totalExtractor?: (response: ResponseType) => number;
623
816
  }): ApiPaginateReturn<ResponseType, RequestParamsType, TData, ErrorResponseType>;
624
817
 
625
- export { type ApiClientConfig, type ApiClientLogger, ApiClientProvider, type ApiClientProviderProps, ApiError, type ApiErrorInit, type CacheEntrySnapshot, type DehydratedQuery, type DehydratedState, type FetchOptions, type IHttpClient, type PersistOptions, type PersistStorage, QueryCache, QueryClient, type QueryFn, type QueryKey, type QueryState, type QueryStatus, type ResponseWrapper, type UseFetchOptions, type UseFetchResult, type UseMutationOptions, type UseMutationResult, type UsePaginateOptions, type UsePaginateResult, apiMutation, apiPaginate, businessErrorToApiError, configureApiClient, apiClient as default, focusManager, getConfig, getQueryClient, hashQueryKey, inspectCache, invalidateAll, isConfigured, matchQueryKey, onlineManager, persistQueryClient, setQueryClient, summarizeCache, toApiError, useIsFetching, useIsMutating, useQueryClient };
818
+ export { type ApiClientConfig, type ApiClientLogger, ApiClientProvider, type ApiClientProviderProps, ApiError, type ApiErrorInit, type CacheEntrySnapshot, type DataOf, type DehydratedQuery, type DehydratedState, type FetchOptions, type IHttpClient, type PersistOptions, type PersistStorage, QueryCache, QueryClient, type QueryFn, type QueryKey, type QueryState, type QueryStatus, type Register, type ResponseAdapter, type ResponseWrapper, type UseFetchOptions, type UseFetchResult, type UseMutationOptions, type UseMutationResult, type UsePaginateOptions, type UsePaginateResult, type UseQueryOptions, type UseQueryResult, apiMutation, apiPaginate, businessErrorToApiError, configureApiClient, apiClient as default, focusManager, getConfig, getQueryClient, graphqlAdapter, hashQueryKey, inspectCache, invalidateAll, isConfigured, jsonApiAdapter, laravelAdapter, matchQueryKey, onlineManager, persistQueryClient, plainAdapter, problemJsonAdapter, setQueryClient, summarizeCache, toApiError, useIsFetching, useIsMutating, useQueriesData, useQuery, useQueryClient };