@ryuzaki13/react-foundation-lib 1.4.1 → 1.4.3

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.
Files changed (73) hide show
  1. package/README.md +154 -100
  2. package/dist/chunks/{formatters-CBgrBeSA.js → formatters-D33Uh3si.js} +7 -8
  3. package/dist/chunks/{formatters-CBgrBeSA.js.map → formatters-D33Uh3si.js.map} +1 -1
  4. package/dist/chunks/odata-service-CWkHASdw.js +2211 -0
  5. package/dist/chunks/odata-service-CWkHASdw.js.map +1 -0
  6. package/dist/chunks/{validate-BW-7BZy_.js → validate-DyzojM0w.js} +2 -2
  7. package/dist/chunks/{validate-BW-7BZy_.js.map → validate-DyzojM0w.js.map} +1 -1
  8. package/dist/error-report/index.js +2 -2
  9. package/dist/excel/index.js +20 -21
  10. package/dist/excel/index.js.map +1 -1
  11. package/dist/formatters/index.js +4 -5
  12. package/dist/{odata → odata-service}/buildSeparatedArrays.d.ts +1 -1
  13. package/dist/odata-service/buildSeparatedArrays.d.ts.map +1 -0
  14. package/dist/{odata/types.d.ts → odata-service/collectionTypes.d.ts} +1 -1
  15. package/dist/odata-service/collectionTypes.d.ts.map +1 -0
  16. package/dist/{odata → odata-service}/dateUtils.d.ts +1 -1
  17. package/dist/odata-service/dateUtils.d.ts.map +1 -0
  18. package/dist/{odata → odata-service}/findCollectionPairs.d.ts +1 -1
  19. package/dist/odata-service/findCollectionPairs.d.ts.map +1 -0
  20. package/dist/odata-service/index.d.ts +5 -0
  21. package/dist/odata-service/index.d.ts.map +1 -1
  22. package/dist/odata-service/index.js +2 -2
  23. package/dist/{odata/store.d.ts → odata-service/odataCollectionStore.d.ts} +1 -1
  24. package/dist/odata-service/odataCollectionStore.d.ts.map +1 -0
  25. package/dist/query-client/index.js.map +1 -1
  26. package/dist/query-client/queryClient.d.ts +1 -3
  27. package/dist/query-client/queryClient.d.ts.map +1 -1
  28. package/package.json +1 -5
  29. package/src/bounded-copy-stack/README.mdx +257 -0
  30. package/src/context-menu/README.mdx +326 -0
  31. package/src/currency/README.mdx +155 -0
  32. package/src/date-segments/README.mdx +319 -0
  33. package/src/error/README.mdx +95 -0
  34. package/src/error-report/README.mdx +612 -0
  35. package/src/excel/README.mdx +449 -0
  36. package/src/fingerprint/README.mdx +100 -0
  37. package/src/form/README.mdx +101 -0
  38. package/src/foundationLib.mdx +70 -54
  39. package/src/hooks/README.mdx +988 -0
  40. package/src/http/README.mdx +126 -0
  41. package/src/media/README.mdx +1853 -0
  42. package/src/notifications/README.mdx +323 -0
  43. package/src/number-scale/README.mdx +318 -0
  44. package/src/odata-service/README.mdx +2916 -0
  45. package/src/presets/README.mdx +136 -0
  46. package/src/pwa/README.mdx +394 -0
  47. package/src/query-client/README.mdx +2500 -0
  48. package/src/range-output/README.mdx +161 -0
  49. package/src/seo/README.mdx +279 -0
  50. package/src/session-storage/README.mdx +125 -0
  51. package/src/string-comparison/README.mdx +96 -0
  52. package/src/table/README.mdx +3106 -0
  53. package/src/three-scene/README.mdx +248 -0
  54. package/src/types/README.mdx +172 -0
  55. package/src/utils/README.mdx +1724 -0
  56. package/src/validators/README.mdx +239 -0
  57. package/src/virtualizer/README.mdx +138 -0
  58. package/src/xml/README.mdx +149 -0
  59. package/dist/chunks/dateUtils-B5V5aXB-.js +0 -847
  60. package/dist/chunks/dateUtils-B5V5aXB-.js.map +0 -1
  61. package/dist/chunks/odata-service-B6TxBSLm.js +0 -1277
  62. package/dist/chunks/odata-service-B6TxBSLm.js.map +0 -1
  63. package/dist/odata/buildSeparatedArrays.d.ts.map +0 -1
  64. package/dist/odata/dateUtils.d.ts.map +0 -1
  65. package/dist/odata/findCollectionPairs.d.ts.map +0 -1
  66. package/dist/odata/index.d.ts +0 -6
  67. package/dist/odata/index.d.ts.map +0 -1
  68. package/dist/odata/index.js +0 -102
  69. package/dist/odata/index.js.map +0 -1
  70. package/dist/odata/store.d.ts.map +0 -1
  71. package/dist/odata/types.d.ts.map +0 -1
  72. package/src/pwa/README.md +0 -638
  73. package/src/query-client/README.md +0 -565
@@ -0,0 +1,2500 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/Infrastructure/Query Client" />
4
+
5
+ # Query Client
6
+
7
+ Модуль `@ryuzaki13/react-foundation-lib/query-client` предоставляет общую инфраструктуру TanStack Query для browser-приложения:
8
+
9
+ - создаёт настроенный `QueryClient`;
10
+ - подключает глобальную обработку query/mutation errors;
11
+ - сохраняет только явно разрешённые query в IndexedDB;
12
+ - разделяет публичный persistent cache и данные текущей серверной сессии;
13
+ - сбрасывает session-scoped query при смене пользователя или авторизации;
14
+ - синхронизирует cache data и invalidation между вкладками;
15
+ - предоставляет низкоуровневые helpers для optimistic update и rollback.
16
+
17
+ README рассчитан на разработчика без доступа к исходному коду и с минимальным знанием JavaScript, TypeScript и TanStack Query. Здесь описаны установка, инициализация, жизненный цикл данных, точное поведение функций, ограничения browser API и полный публичный API.
18
+
19
+ > Ранее optimistic update был описан отдельным документом. Теперь весь смысл, примеры, ограничения и API находятся только на этой странице.
20
+
21
+ ## Содержание
22
+
23
+ - [Назначение и границы](#назначение-и-границы)
24
+ - [Установка и импорт](#установка-и-импорт)
25
+ - [Обязательные build-time constants](#обязательные-build-time-constants)
26
+ - [Основные понятия TanStack Query](#основные-понятия-tanstack-query)
27
+ - [Как выбрать cache strategy](#как-выбрать-cache-strategy)
28
+ - [Быстрый старт](#быстрый-старт)
29
+ - [Создание QueryClient](#создание-queryclient)
30
+ - [Query metadata](#query-metadata)
31
+ - [Настройка project adapter](#настройка-project-adapter)
32
+ - [IndexedDB persistence](#indexeddb-persistence)
33
+ - [Session-scoped query](#session-scoped-query)
34
+ - [Синхронизация между вкладками](#синхронизация-между-вкладками)
35
+ - [Optimistic update](#optimistic-update)
36
+ - [Безопасность и сериализация](#безопасность-и-сериализация)
37
+ - [Browser, SSR и lifecycle](#browser-ssr-и-lifecycle)
38
+ - [Тестирование consumer-кода](#тестирование-consumer-кода)
39
+ - [Полная карта API](#полная-карта-api)
40
+ - [Частые вопросы](#частые-вопросы)
41
+
42
+ ## Назначение и границы
43
+
44
+ ### Что делает модуль
45
+
46
+ ```text
47
+ queryOptions/useQuery/useMutation
48
+ │
49
+ ▼
50
+ QueryClient
51
+ / │ \
52
+ / │ \
53
+ IndexedDB Broadcast session reset
54
+ persistence channels и optimistic cache
55
+ ```
56
+
57
+ Модуль управляет инфраструктурным поведением TanStack Query cache. Он знает, как создать client, где хранить opt-in query, как передать cache event другой вкладке и как откатить optimistic patch.
58
+
59
+ ### Что модуль не делает
60
+
61
+ Модуль не является transport или бизнес-слоем:
62
+
63
+ - не выполняет `fetch` сам по себе;
64
+ - не знает URL backend;
65
+ - не добавляет SSO credentials и X-CSRF token;
66
+ - не строит OData path, `$filter` или payload;
67
+ - не создаёт query keys конкретной entity;
68
+ - не решает, какие данные безопасно сохранять;
69
+ - не показывает notification пользователю;
70
+ - не заменяет `QueryClientProvider`;
71
+ - не создаёт singleton автоматически.
72
+
73
+ Используйте правильного владельца:
74
+
75
+ | Задача | Владелец |
76
+ | -------------------------------------------------- | ------------------------------------------------------ |
77
+ | QueryClient, persistence, broadcast, session reset | `@ryuzaki13/react-foundation-lib/query-client` |
78
+ | HTTP/OData transport и operation | `@ryuzaki13/react-foundation-api/odata` или `/http` |
79
+ | Query key и query options сущности | entity/feature API consumer-проекта |
80
+ | UI loading/error/empty state | component/feature consumer-проекта |
81
+ | Service Worker HTTP cache | `@ryuzaki13/react-foundation-lib/pwa` и host SW policy |
82
+
83
+ ### Три разных cache-механизма
84
+
85
+ Не смешивайте:
86
+
87
+ | Механизм | Что хранит | Где живёт |
88
+ | -------------------- | ----------------------------------------- | ------------------------------ |
89
+ | Query cache | Backend snapshot и состояние query | В памяти текущей вкладки |
90
+ | Query persistence | Выбранные уже полученные query data | IndexedDB |
91
+ | Service Worker cache | HTTP response/request на transport-уровне | Cache Storage в Service Worker |
92
+
93
+ Broadcast не является четвёртым хранилищем. Он только передаёт изменения между уже работающими вкладками.
94
+
95
+ ## Установка и импорт
96
+
97
+ Установите foundation-пакет и совместимые peer dependencies:
98
+
99
+ ```bash
100
+ npm install @ryuzaki13/react-foundation-lib
101
+ npm install react @tanstack/react-query \
102
+ @tanstack/query-persist-client-core \
103
+ @tanstack/query-broadcast-client-experimental
104
+ ```
105
+
106
+ Публичный импорт:
107
+
108
+ ```ts
109
+ import { createQueryClient, persistedQueryMeta, sessionScopedQueryMeta } from "@ryuzaki13/react-foundation-lib/query-client";
110
+ ```
111
+
112
+ Типы импортируйте через `import type`:
113
+
114
+ ```ts
115
+ import type { AppQueryMeta, OptimisticContext, QueryBroadcastOptions } from "@ryuzaki13/react-foundation-lib/query-client";
116
+ ```
117
+
118
+ Неверные импорты:
119
+
120
+ ```ts
121
+ // Корневой entrypoint пакета не опубликован.
122
+ import { createQueryClient } from "@ryuzaki13/react-foundation-lib";
123
+
124
+ // Внутреннее устройство dist не является публичным API.
125
+ import { createQueryClient } from "@ryuzaki13/react-foundation-lib/dist/query-client/queryClient";
126
+
127
+ // Это локальный alias чужого приложения, а не package contract.
128
+ import { createQueryClient } from "@/shared/lib";
129
+ ```
130
+
131
+ Модуль распространяется как ESM. Используйте `import`, а не CommonJS `require`.
132
+
133
+ ## Обязательные build-time constants
134
+
135
+ Entry point использует constants, которые host-сборка должна определить:
136
+
137
+ ```ts
138
+ declare const __APP_ID__: string;
139
+ declare const __DEV__: boolean;
140
+ declare const __REACT_QUERY_PERSISTENCE_BUSTER__: string;
141
+ ```
142
+
143
+ Они нужны для namespacing IndexedDB и cache busting.
144
+
145
+ ### Пример Vite define
146
+
147
+ ```ts
148
+ import { defineConfig } from "vite";
149
+
150
+ export default defineConfig(({ mode }) => ({
151
+ define: {
152
+ __APP_ID__: JSON.stringify("sales-app"),
153
+ __DEV__: JSON.stringify(mode === "development"),
154
+ __REACT_QUERY_PERSISTENCE_BUSTER__: JSON.stringify("sales-app.query-cache.v1")
155
+ }
156
+ }));
157
+ ```
158
+
159
+ Это compile-time replacement. Не нужно писать:
160
+
161
+ ```ts
162
+ window.__APP_ID__ = "sales-app";
163
+ ```
164
+
165
+ Такое присваивание не заменяет Vite `define`.
166
+
167
+ `REACT_QUERY_PERSISTENCE_BUSTER` вычисляется на верхнем уровне модуля из `__REACT_QUERY_PERSISTENCE_BUSTER__`. Поэтому buster должен быть определён уже при сборке/import entrypoint, даже если persistence позже не включится из-за отсутствия IndexedDB.
168
+
169
+ ### Как выбирать buster
170
+
171
+ Buster — строковая версия формата persistent cache:
172
+
173
+ ```text
174
+ sales-app.query-cache.v1
175
+ sales-app.query-cache.v2
176
+ ```
177
+
178
+ Повышайте версию, когда старые записи несовместимы с новым приложением:
179
+
180
+ - изменилась структура persisted data;
181
+ - изменился mapping ответа;
182
+ - изменился смысл query key;
183
+ - старый cache нельзя безопасно использовать.
184
+
185
+ Не меняйте buster при каждой сборке без причины: это уничтожит пользу persistence.
186
+
187
+ ## Основные понятия TanStack Query
188
+
189
+ ### Query
190
+
191
+ Query — описание чтения backend data:
192
+
193
+ ```ts
194
+ const options = {
195
+ queryKey: ["products", { company: "1000" }],
196
+ queryFn: fetchProducts
197
+ };
198
+ ```
199
+
200
+ Query содержит key, функцию загрузки, состояние, data, timestamps и metadata.
201
+
202
+ ### Query key
203
+
204
+ Query key — identity данных в cache:
205
+
206
+ ```ts
207
+ const productsKey = ["products", { company: "1000" }] as const;
208
+ ```
209
+
210
+ Все параметры, влияющие на результат, должны входить в key. Иначе разные запросы могут записать данные в одну cache entry.
211
+
212
+ Хороший key:
213
+
214
+ - стабилен;
215
+ - сериализуем;
216
+ - не содержит token, password или session secret;
217
+ - различает все варианты результата;
218
+ - принадлежит owner-у query.
219
+
220
+ ### QueryClient
221
+
222
+ `QueryClient` управляет query/mutation cache:
223
+
224
+ ```ts
225
+ queryClient.getQueryData(queryKey);
226
+ queryClient.setQueryData(queryKey, nextData);
227
+ queryClient.invalidateQueries({ queryKey });
228
+ queryClient.cancelQueries({ queryKey });
229
+ ```
230
+
231
+ В SPA обычно создают один экземпляр на всё приложение и передают в `QueryClientProvider`.
232
+
233
+ ### Observer и active query
234
+
235
+ Query считается active, когда у неё есть активный observer — обычно смонтированный `useQuery` с `enabled !== false`.
236
+
237
+ Query может оставаться в cache без component-подписки. Тогда она inactive.
238
+
239
+ Различие важно для:
240
+
241
+ - `refetchType`;
242
+ - session reset;
243
+ - garbage collection;
244
+ - фонового обновления.
245
+
246
+ ### Fresh, stale и invalidated
247
+
248
+ - Fresh: данные моложе `staleTime`.
249
+ - Stale: данные можно показывать, но TanStack Query может обновить их.
250
+ - Invalidated: client явно отметил query устаревшей.
251
+
252
+ Stale не означает «удалена». Data может продолжать отображаться до refetch.
253
+
254
+ ### `staleTime` и `gcTime`
255
+
256
+ `staleTime` отвечает за свежесть.
257
+
258
+ `gcTime` отвечает за время хранения неактивной query в памяти до garbage collection.
259
+
260
+ Они не задают срок хранения IndexedDB. Persistent срок отдельно задаёт `REACT_QUERY_PERSISTENCE_MAX_AGE`.
261
+
262
+ ### Query data — backend state
263
+
264
+ Не копируйте backend snapshot одновременно в Query cache, Zustand и React state. Обычно:
265
+
266
+ - backend data живёт в Query cache;
267
+ - draft формы — в React/form state;
268
+ - URL filters — в Router search;
269
+ - устойчивые пользовательские preferences — в Zustand/store.
270
+
271
+ ## Как выбрать cache strategy
272
+
273
+ Перед созданием query ответьте на вопросы:
274
+
275
+ | Вопрос | Решение |
276
+ | ----------------------------------------------------------- | ----------------------------------------------------- |
277
+ | Нужны данные только пока открыта вкладка? | Обычная query без `persistedQueryMeta`. |
278
+ | Данные публичные, тяжёлые и могут пережить reload? | Рассмотреть `persistedQueryMeta`. |
279
+ | Данные зависят от пользователя, роли или server session? | `sessionScopedQueryMeta`. |
280
+ | Нужно передать обновление другой открытой вкладке? | Broadcast installation и подходящее cache event. |
281
+ | UI должен измениться до ответа mutation? | Optimistic update с обязательным rollback policy. |
282
+ | Server response авторитетен и уже содержит новое состояние? | `setQueryData`/`onSuccessOptimistic`. |
283
+ | После mutation нужен свежий server snapshot? | Scoped `invalidateQueries` с выбранным `refetchType`. |
284
+
285
+ `persistedQueryMeta` и `sessionScopedQueryMeta` взаимоисключающие. Session data не должна переживать смену пользователя.
286
+
287
+ ## Быстрый старт
288
+
289
+ ### Bootstrap
290
+
291
+ Инициализация выполняется один раз до обычного render приложения:
292
+
293
+ ```tsx
294
+ import { StrictMode } from "react";
295
+ import { createRoot } from "react-dom/client";
296
+ import { QueryClientProvider } from "@tanstack/react-query";
297
+
298
+ import {
299
+ configureQueryPersistenceProjectAdapter,
300
+ createQueryClient,
301
+ installReactQueryBroadcast,
302
+ installSessionScopedQueryReset,
303
+ setBroadcastFn
304
+ } from "@ryuzaki13/react-foundation-lib/query-client";
305
+
306
+ configureQueryPersistenceProjectAdapter({
307
+ persistenceBuster: "sales-app.query-cache.v1",
308
+ resolveSystemIdentifier: () => "production:300"
309
+ });
310
+
311
+ const queryClient = createQueryClient({
312
+ onQueryError: (error, query) => {
313
+ console.error("Query завершилась ошибкой", query.queryKey, error);
314
+ },
315
+ onMutationError: (error) => {
316
+ console.error("Mutation завершилась ошибкой", error);
317
+ }
318
+ });
319
+
320
+ const cacheBroadcast = installReactQueryBroadcast(queryClient, {
321
+ queriesChannel: "sales-app:query-data",
322
+ eventsChannel: "sales-app:query-events"
323
+ });
324
+
325
+ setBroadcastFn(cacheBroadcast.broadcast);
326
+
327
+ const sessionReset = installSessionScopedQueryReset(queryClient, {
328
+ channelName: "sales-app:session-query-reset"
329
+ });
330
+
331
+ createRoot(document.getElementById("root")!).render(
332
+ <StrictMode>
333
+ <QueryClientProvider client={queryClient}>
334
+ <App />
335
+ </QueryClientProvider>
336
+ </StrictMode>
337
+ );
338
+ ```
339
+
340
+ В обычной SPA эти installations живут до закрытия страницы. В тесте, story, microfrontend или явно размонтируемом shell вызовите cleanup:
341
+
342
+ ```ts
343
+ cacheBroadcast.cleanup();
344
+ sessionReset.cleanup();
345
+ setBroadcastFn(() => undefined);
346
+ queryClient.clear();
347
+ ```
348
+
349
+ ### Обычная query
350
+
351
+ ```ts
352
+ import { queryOptions, useQuery } from "@tanstack/react-query";
353
+
354
+ export function productsQueryOptions(company: string) {
355
+ return queryOptions({
356
+ queryKey: ["products", { company }] as const,
357
+ queryFn: () => fetchProducts(company)
358
+ });
359
+ }
360
+
361
+ export function useProducts(company: string) {
362
+ return useQuery(productsQueryOptions(company));
363
+ }
364
+ ```
365
+
366
+ Она использует default options созданного client, но не сохраняется в IndexedDB без явной meta.
367
+
368
+ ## Создание QueryClient
369
+
370
+ ### `createQueryClient`
371
+
372
+ ```ts
373
+ function createQueryClient(options: QueryBroadcastOptions): QueryClient;
374
+ ```
375
+
376
+ Несмотря на старый комментарий «создаёт singleton», функция не кеширует экземпляр. Каждый вызов возвращает новый `QueryClient`:
377
+
378
+ ```ts
379
+ const first = createQueryClient({});
380
+ const second = createQueryClient({});
381
+
382
+ console.log(first === second); // false
383
+ ```
384
+
385
+ Singleton — ответственность host bootstrap:
386
+
387
+ ```ts
388
+ export const queryClient = createQueryClient({});
389
+ ```
390
+
391
+ Не вызывайте `createQueryClient` внутри React component. Иначе render может создать новый cache и потерять предыдущие данные.
392
+
393
+ ### Default query options
394
+
395
+ ```ts
396
+ {
397
+ staleTime: 10 * 60 * 1000,
398
+ gcTime: 30 * 60 * 1000,
399
+ throwOnError: false,
400
+ retry: false,
401
+ persister: /* только если доступен IndexedDB */
402
+ }
403
+ ```
404
+
405
+ | Option | Default | Смысл |
406
+ | -------------- | -------- | -------------------------------------------------------------------- |
407
+ | `staleTime` | 10 минут | Данные считаются fresh после успеха. |
408
+ | `gcTime` | 30 минут | Неактивная query остаётся в памяти до GC. |
409
+ | `throwOnError` | `false` | Query error не пробрасывается в render/Error Boundary автоматически. |
410
+ | `retry` | `false` | Автоматический повтор query отключён. |
411
+
412
+ Конкретная query может переопределить defaults:
413
+
414
+ ```ts
415
+ useQuery({
416
+ queryKey,
417
+ queryFn,
418
+ staleTime: Infinity
419
+ });
420
+ ```
421
+
422
+ ### Default mutation options
423
+
424
+ ```ts
425
+ {
426
+ throwOnError: false,
427
+ retry: false
428
+ }
429
+ ```
430
+
431
+ Mutation не повторяется автоматически. Это особенно важно для create/update/delete: повтор небезопасной операции может создать дубль.
432
+
433
+ ### Глобальные error callbacks
434
+
435
+ ```ts
436
+ type QueryBroadcastOptions = {
437
+ onQueryError?: (error: DefaultError, query: Query<unknown, unknown, unknown>) => void;
438
+
439
+ onMutationError?: (
440
+ error: DefaultError,
441
+ variables: unknown,
442
+ onMutateResult: unknown,
443
+ mutation: Mutation<unknown, unknown, unknown>,
444
+ context: MutationFunctionContext
445
+ ) => Promise<unknown> | unknown;
446
+ };
447
+ ```
448
+
449
+ Callbacks устанавливаются в `QueryCache` и `MutationCache`. Они подходят для общей диагностики, auth recovery и единой error policy.
450
+
451
+ Не помещайте в log:
452
+
453
+ - access/session tokens;
454
+ - raw credentials;
455
+ - персональные payloads;
456
+ - секреты из mutation variables;
457
+ - полный cache dump.
458
+
459
+ Локальный `onError` mutation/query и global cache callback — разные уровни. Локальный handler может выполнить rollback/UI recovery, а глобальный — записать diagnostics. Не считайте один автоматической заменой другого.
460
+
461
+ ### Persistence подключается автоматически
462
+
463
+ `createQueryClient` вызывает `createReactQueryPersister`.
464
+
465
+ - Если `indexedDB` доступен, `persisterFn` становится default query persister.
466
+ - Если `indexedDB` отсутствует, client создаётся без persister.
467
+ - Даже с persister сохраняются только opt-in query.
468
+
469
+ ## Query metadata
470
+
471
+ ### `AppQueryMeta`
472
+
473
+ Модуль расширяет TanStack Query `Register.queryMeta`:
474
+
475
+ ```ts
476
+ type AppQueryMeta = {
477
+ readonly persist?: boolean;
478
+ readonly sessionScoped?: boolean;
479
+ } & Record<string, unknown>;
480
+ ```
481
+
482
+ После импорта package TypeScript понимает эти fields в `queryOptions`, `useQuery` и Query object:
483
+
484
+ ```ts
485
+ queryOptions({
486
+ queryKey: ["dictionary"],
487
+ queryFn: fetchDictionary,
488
+ meta: {
489
+ persist: true,
490
+ source: "dictionary"
491
+ }
492
+ });
493
+ ```
494
+
495
+ `Record<string, unknown>` разрешает consumer-проекту добавлять собственную metadata. Infrastructure этого модуля читает только `persist` и `sessionScoped`.
496
+
497
+ ### `persistedQueryMeta`
498
+
499
+ ```ts
500
+ const persistedQueryMeta = { persist: true } as const;
501
+ ```
502
+
503
+ Применение:
504
+
505
+ ```ts
506
+ import { queryOptions } from "@tanstack/react-query";
507
+ import { persistedQueryMeta } from "@ryuzaki13/react-foundation-lib/query-client";
508
+
509
+ export function dictionaryQueryOptions(dictionaryId: string) {
510
+ return queryOptions({
511
+ queryKey: ["dictionary", dictionaryId] as const,
512
+ queryFn: () => fetchDictionary(dictionaryId),
513
+ meta: persistedQueryMeta,
514
+ staleTime: 8 * 60 * 60 * 1000
515
+ });
516
+ }
517
+ ```
518
+
519
+ Meta только разрешает persistence. Она не гарантирует, что IndexedDB доступен и запись уже создана.
520
+
521
+ ### `sessionScopedQueryMeta`
522
+
523
+ ```ts
524
+ const sessionScopedQueryMeta = { sessionScoped: true } as const;
525
+ ```
526
+
527
+ Используйте для data, содержимое или доступность которых зависит от server session, пользователя или роли:
528
+
529
+ ```ts
530
+ queryOptions({
531
+ queryKey: ["current-user", "permissions"] as const,
532
+ queryFn: fetchCurrentPermissions,
533
+ meta: sessionScopedQueryMeta
534
+ });
535
+ ```
536
+
537
+ При смене сессии host обязан вызвать `resetSessionScopedQueries`.
538
+
539
+ ### Meta нельзя совмещать
540
+
541
+ Неверно:
542
+
543
+ ```ts
544
+ meta: {
545
+ persist: true,
546
+ sessionScoped: true
547
+ }
548
+ ```
549
+
550
+ `shouldPersistQuery` всё равно не сохранит такую query, потому что session scope имеет приоритет безопасности.
551
+
552
+ Используйте один смысл:
553
+
554
+ - публичный/безопасный долгоживущий справочник → `persistedQueryMeta`;
555
+ - auth/session dependent data → `sessionScopedQueryMeta`;
556
+ - обычные transient data → meta не нужна.
557
+
558
+ ## Настройка project adapter
559
+
560
+ ### Зачем нужен adapter
561
+
562
+ Foundation-пакет не знает имя SAP system, client, tenant или окружение конкретного приложения. `QueryPersistenceProjectAdapter` позволяет host передать устойчивый namespace и собственный buster:
563
+
564
+ ```ts
565
+ type QueryPersistenceProjectAdapter = {
566
+ persistenceBuster?: string;
567
+ resolveSystemIdentifier?: () => string | null | undefined;
568
+ };
569
+ ```
570
+
571
+ ### `configureQueryPersistenceProjectAdapter`
572
+
573
+ ```ts
574
+ configureQueryPersistenceProjectAdapter({
575
+ persistenceBuster: "sales-app.query.v2",
576
+ resolveSystemIdentifier: () => {
577
+ return "sap-prd:300";
578
+ }
579
+ });
580
+ ```
581
+
582
+ Вызов заменяет весь текущий adapter. Это global module state, а не merge:
583
+
584
+ ```ts
585
+ configureQueryPersistenceProjectAdapter({
586
+ persistenceBuster: "v2"
587
+ });
588
+
589
+ // Предыдущий resolveSystemIdentifier больше не установлен.
590
+ ```
591
+
592
+ ### Порядок инициализации
593
+
594
+ Настройте adapter до первого `createQueryClient` или `createIndexedDbQueryStorage`:
595
+
596
+ ```ts
597
+ configureQueryPersistenceProjectAdapter(adapter);
598
+ const queryClient = createQueryClient({});
599
+ ```
600
+
601
+ Иначе storage/persister уже получит default namespace и buster.
602
+
603
+ ### `getQueryPersistenceProjectAdapter`
604
+
605
+ ```ts
606
+ const adapter = getQueryPersistenceProjectAdapter();
607
+ ```
608
+
609
+ Возвращает текущий object по ссылке. Не мутируйте его. Для изменения вызывайте `configureQueryPersistenceProjectAdapter` с новым object.
610
+
611
+ ### Выбор system identifier
612
+
613
+ Правило по умолчанию:
614
+
615
+ ```text
616
+ development: dev:{APP_ID}
617
+
618
+ production:
619
+ adapter.resolveSystemIdentifier() → если вернулась непустая строка
620
+ иначе {APP_ID}:{window.location.hostname}
621
+
622
+ server-like runtime без window:
623
+ {APP_ID}:server
624
+ ```
625
+
626
+ Примеры:
627
+
628
+ ```text
629
+ dev:sales-app
630
+ sap-prd:300
631
+ sales-app:portal.company.local
632
+ sales-app:server
633
+ ```
634
+
635
+ Identifier становится именем IndexedDB database и prefix persisted query keys. Он должен разделять окружения, между которыми нельзя переиспользовать data.
636
+
637
+ Resolver может быть вызван больше одного раза при создании storage и persister. Он должен быть чистым и возвращать одинаковый identifier в рамках одного application runtime.
638
+
639
+ Не включайте в identifier username, token или персональные данные. Для пользовательских данных persistence вообще не должна использоваться.
640
+
641
+ ### Выбор buster
642
+
643
+ При создании persister используется:
644
+
645
+ ```text
646
+ adapter.persistenceBuster ?? REACT_QUERY_PERSISTENCE_BUSTER
647
+ ```
648
+
649
+ Пустая строка считается явным adapter value, потому что используется `??`, а не truthy-проверка. На практике задавайте непустую версионированную строку.
650
+
651
+ ## IndexedDB persistence
652
+
653
+ ### Зачем IndexedDB
654
+
655
+ IndexedDB подходит для больших асинхронных browser data лучше, чем `localStorage`:
656
+
657
+ - операции асинхронные;
658
+ - значения хранятся через structured clone;
659
+ - можно хранить object без ручного JSON stringify;
660
+ - storage не блокирует основной поток синхронным API `localStorage`;
661
+ - объём обычно лучше подходит тяжёлым справочникам.
662
+
663
+ Persistence не означает, что любой большой payload автоматически безопасен. Объём, чувствительность и свежесть остаются решением consumer-а.
664
+
665
+ ### Opt-in правило
666
+
667
+ ```ts
668
+ function shouldPersistQuery(query: Pick<Query, "meta">): boolean;
669
+ ```
670
+
671
+ Результат `true` только когда:
672
+
673
+ ```ts
674
+ query.meta?.persist === true && query.meta.sessionScoped !== true;
675
+ ```
676
+
677
+ Примеры:
678
+
679
+ | Meta | Сохраняется |
680
+ | ---------------------------------------- | ----------- |
681
+ | `undefined` | Нет |
682
+ | `{ persist: false }` | Нет |
683
+ | `{ persist: true }` | Да |
684
+ | `{ sessionScoped: true }` | Нет |
685
+ | `{ persist: true, sessionScoped: true }` | Нет |
686
+
687
+ ### `createIndexedDbQueryStorage`
688
+
689
+ ```ts
690
+ const storage = createIndexedDbQueryStorage<MyValue>({
691
+ dbName: "sales-query-cache",
692
+ storeName: "queries",
693
+ indexedDB: customFactory
694
+ });
695
+ ```
696
+
697
+ Options structurally имеют форму:
698
+
699
+ ```ts
700
+ type Options = {
701
+ dbName?: string;
702
+ storeName?: string;
703
+ indexedDB?: IDBFactory;
704
+ };
705
+ ```
706
+
707
+ Сам тип options не экспортируется, но TypeScript выводит его из signature.
708
+
709
+ Если explicit `indexedDB` не передан, используется `globalThis.indexedDB`.
710
+
711
+ Если factory отсутствует, возвращается `undefined`:
712
+
713
+ ```ts
714
+ const storage = createIndexedDbQueryStorage();
715
+
716
+ if (!storage) {
717
+ console.info("IndexedDB недоступен, persistence отключена");
718
+ }
719
+ ```
720
+
721
+ ### Default database и object store
722
+
723
+ - `dbName` по умолчанию равен system identifier;
724
+ - `storeName` по умолчанию равен `queries`;
725
+ - database открывается с версией `1`;
726
+ - object store создаётся в `onupgradeneeded`;
727
+ - открытая database promise переиспользуется внутри storage instance.
728
+
729
+ Если вы выбираете custom `storeName` для уже существующей database версии `1`, browser может не вызвать upgrade, и store не будет создан. Для custom layout используйте отдельный `dbName` либо заранее управляемую migration/version strategy.
730
+
731
+ У storage нет публичного `close()`. Обычно instance живёт вместе с persister всё время работы SPA.
732
+
733
+ ### Storage API
734
+
735
+ Возвращаемый adapter совместим с TanStack `AsyncStorage`:
736
+
737
+ ```ts
738
+ type QueryStorage<T> = {
739
+ getItem(key: string): Promise<T | undefined | null>;
740
+ setItem(key: string, value: T): Promise<unknown>;
741
+ removeItem(key: string): Promise<void>;
742
+ entries?(): Promise<Array<[key: string, value: T]>>;
743
+ };
744
+ ```
745
+
746
+ Пример прямой работы:
747
+
748
+ ```ts
749
+ const storage = createIndexedDbQueryStorage<string>({
750
+ dbName: "query-storage-demo"
751
+ });
752
+
753
+ if (storage) {
754
+ await storage.setItem("first", "один");
755
+ const value = await storage.getItem("first");
756
+ const all = await storage.entries?.();
757
+ await storage.removeItem("first");
758
+ }
759
+ ```
760
+
761
+ `setItem` завершается после окончания write transaction, а не сразу после `store.put`.
762
+
763
+ `entries` читает object store через cursor. Не завязывайте бизнес-логику на предполагаемый insertion order: порядок определяется IndexedDB keys/cursor.
764
+
765
+ ### Ошибки IndexedDB
766
+
767
+ Storage не скрывает ошибки. Promise отклоняется, если:
768
+
769
+ - database open заблокирован;
770
+ - browser отказал в storage;
771
+ - transaction завершилась error/abort;
772
+ - значение не поддерживает structured clone;
773
+ - object store отсутствует;
774
+ - превышена quota.
775
+
776
+ Не превращайте такую ошибку молча в успешную запись. На уровне приложения выберите diagnostics и degradation policy.
777
+
778
+ ### `createReactQueryPersister`
779
+
780
+ ```ts
781
+ const persister = createReactQueryPersister();
782
+ ```
783
+
784
+ Возвращает результат `experimental_createQueryPersister` либо `undefined`, если IndexedDB недоступен.
785
+
786
+ Настройки foundation wrapper:
787
+
788
+ ```ts
789
+ {
790
+ serialize: (query) => query,
791
+ deserialize: (query) => query,
792
+ prefix: systemIdentifier,
793
+ buster: adapterBuster ?? buildTimeBuster,
794
+ maxAge: 90 * 24 * 60 * 60 * 1000,
795
+ refetchOnRestore: true,
796
+ filters: { predicate: shouldPersistQuery }
797
+ }
798
+ ```
799
+
800
+ API TanStack помечено `experimental`. При обновлении major/minor версии peer dependency повторно проверяйте совместимость.
801
+
802
+ ### Constants
803
+
804
+ ```ts
805
+ REACT_QUERY_PERSISTENCE_BUSTER;
806
+ REACT_QUERY_PERSISTENCE_MAX_AGE;
807
+ ```
808
+
809
+ `REACT_QUERY_PERSISTENCE_MAX_AGE` равен 90 дням в миллисекундах:
810
+
811
+ ```ts
812
+ 90 * 24 * 60 * 60 * 1000;
813
+ ```
814
+
815
+ Это максимальный возраст persisted записи, а не `staleTime` и не `gcTime`.
816
+
817
+ ### Ключ записи
818
+
819
+ TanStack persister использует форму:
820
+
821
+ ```text
822
+ {prefix}-{queryHash}
823
+ ```
824
+
825
+ Например:
826
+
827
+ ```text
828
+ sap-prd:300-["odata","metadata",{"service":"REPORT_SRV"}]
829
+ ```
830
+
831
+ Фактический `queryHash` создаёт TanStack Query. Не собирайте persistent key вручную.
832
+
833
+ ### Что хранится
834
+
835
+ Каждая запись содержит `PersistedQuery` с query key, hash, state и buster. `state` включает data и timestamps TanStack Query.
836
+
837
+ Wrapper не делает JSON serialization:
838
+
839
+ ```ts
840
+ serialize: (query) => query;
841
+ deserialize: (query) => query;
842
+ ```
843
+
844
+ Следствие: data должна поддерживать browser structured clone.
845
+
846
+ Не сохраняйте:
847
+
848
+ - functions;
849
+ - DOM nodes;
850
+ - React elements;
851
+ - QueryClient;
852
+ - AbortController/Signal как часть data;
853
+ - class instances с важным prototype behavior;
854
+ - несериализуемые runtime handles.
855
+
856
+ ### Жизненный цикл восстановления
857
+
858
+ Для query с `meta: persistedQueryMeta`:
859
+
860
+ 1. TanStack Query создаёт query в памяти.
861
+ 2. Persister проверяет `shouldPersistQuery`.
862
+ 3. По `prefix-queryHash` читается IndexedDB.
863
+ 4. Проверяются buster и возраст записи.
864
+ 5. Подходящее state восстанавливается в Query cache.
865
+ 6. Если восстановленная query stale, `refetchOnRestore: true` запускает обновление.
866
+ 7. Если записи нет/она протухла/не совпал buster, выполняется обычный `queryFn`.
867
+ 8. Успешный результат планируется к записи в IndexedDB.
868
+
869
+ Восстановление per-query ленивое. Модуль не гидратирует весь persistent cache целиком при старте приложения.
870
+
871
+ ### Freshness: три разных времени
872
+
873
+ | Время | Отвечает за |
874
+ | --------------------------------- | ------------------------------------------------- |
875
+ | `staleTime` query | Когда data перестаёт считаться fresh. |
876
+ | `gcTime` query | Когда неактивная query удаляется из memory cache. |
877
+ | `REACT_QUERY_PERSISTENCE_MAX_AGE` | Когда IndexedDB запись слишком стара для restore. |
878
+
879
+ Пример:
880
+
881
+ ```ts
882
+ queryOptions({
883
+ queryKey: ["dictionary", "materials"],
884
+ queryFn: fetchMaterials,
885
+ meta: persistedQueryMeta,
886
+ staleTime: 2 * 60 * 60 * 1000,
887
+ gcTime: 30 * 60 * 1000
888
+ });
889
+ ```
890
+
891
+ - Через reload в течение двух часов data может восстановиться fresh без немедленного refetch.
892
+ - После двух часов data может восстановиться, но stale query будет обновлена.
893
+ - После 90 дней запись не должна использоваться.
894
+
895
+ ### Хорошие кандидаты для persistence
896
+
897
+ - публичные справочники;
898
+ - metadata/schema, одинаковые для пользователей в namespace;
899
+ - тяжёлые редко меняющиеся reference data;
900
+ - data с ясной invalidation/version strategy;
901
+ - payload без чувствительной информации.
902
+
903
+ ### Плохие кандидаты
904
+
905
+ - текущий пользователь и permissions;
906
+ - персональные списки;
907
+ - form draft;
908
+ - результаты поиска с большим числом уникальных keys;
909
+ - быстро меняющиеся balances/statuses;
910
+ - auth/session dependent response;
911
+ - data без понятного срока свежести;
912
+ - огромные таблицы, сохраняемые «на всякий случай».
913
+
914
+ ### DevTools
915
+
916
+ В browser DevTools:
917
+
918
+ 1. Откройте `Application`.
919
+ 2. Выберите `IndexedDB`.
920
+ 3. Найдите database по system identifier.
921
+ 4. Откройте object store `queries`.
922
+ 5. Проверьте keys, buster и query state.
923
+
924
+ Удаление database в DevTools — ручной способ очистить persistent cache текущего namespace. Не делайте автоматическую очистку всех databases другого приложения.
925
+
926
+ ## Session-scoped query
927
+
928
+ ### Когда нужна session scope
929
+
930
+ Query считается session-scoped, если data или право на чтение меняется вместе с server session:
931
+
932
+ - текущий пользователь;
933
+ - permissions/roles;
934
+ - персональные настройки с backend;
935
+ - доступные пользователю операции;
936
+ - auth-dependent списки;
937
+ - данные, которые нельзя показывать следующему вошедшему пользователю.
938
+
939
+ ```ts
940
+ import { sessionScopedQueryMeta } from "@ryuzaki13/react-foundation-lib/query-client";
941
+
942
+ const permissionsOptions = queryOptions({
943
+ queryKey: ["session", "permissions"] as const,
944
+ queryFn: fetchPermissions,
945
+ meta: sessionScopedQueryMeta,
946
+ staleTime: Infinity
947
+ });
948
+ ```
949
+
950
+ Meta сама ничего не сбрасывает. Host должен вызвать reset на boundary смены сессии.
951
+
952
+ ### `isSessionScopedQuery`
953
+
954
+ ```ts
955
+ function isSessionScopedQuery(query: Pick<Query, "meta">): boolean;
956
+ ```
957
+
958
+ Возвращает `true` только для строгого `meta.sessionScoped === true`.
959
+
960
+ ### `resetSessionScopedQueries`
961
+
962
+ ```ts
963
+ await resetSessionScopedQueries(queryClient);
964
+ ```
965
+
966
+ Вызывайте после:
967
+
968
+ - успешного входа другим пользователем;
969
+ - выхода;
970
+ - обновления server session, меняющего доступ;
971
+ - потери авторизации;
972
+ - смены роли/контекста, если старые данные более недопустимы.
973
+
974
+ Порядок работы:
975
+
976
+ 1. Находит только query с `sessionScoped === true`.
977
+ 2. Отменяет их незавершённые requests.
978
+ 3. Неактивные query полностью удаляет из QueryCache.
979
+ 4. Query с observer очищает: reset + `data: undefined`, `dataUpdatedAt: 0`.
980
+ 5. Повторно запрашивает только active session-scoped query.
981
+ 6. По умолчанию отправляет другим вкладкам команду такого же reset.
982
+
983
+ Обычные публичные query остаются нетронутыми.
984
+
985
+ ### Почему observed query не удаляется
986
+
987
+ Смонтированный `useQuery` владеет observer. Удаление query прямо из-под активного observer может привести к сложному lifecycle. Поэтому entry сохраняется, но старые data явно очищаются и active query перезапрашивается.
988
+
989
+ ### Disabled query
990
+
991
+ Query с observer и `enabled: false` очищается, но не refetch-ится:
992
+
993
+ ```text
994
+ до reset: cache data есть, observer disabled
995
+ после reset: query entry есть, data = undefined
996
+ queryFn повторно не вызвана
997
+ ```
998
+
999
+ Это ожидаемо: disabled query не считается active для refetch.
1000
+
1001
+ ### Hydrated data
1002
+
1003
+ Модуль явно обнуляет `data` после `query.reset()`. Это защищает от ситуации, когда hydrated initial state прежней сессии мог бы вернуться после обычного reset.
1004
+
1005
+ Foundation-пакет не включает SSR самостоятельно, но helper безопасно очищает такой cache state, если consumer использует TanStack dehydration/hydration.
1006
+
1007
+ ### Options
1008
+
1009
+ ```ts
1010
+ type ResetSessionScopedQueriesOptions = {
1011
+ readonly broadcast?: boolean;
1012
+ };
1013
+ ```
1014
+
1015
+ По умолчанию `broadcast` включён:
1016
+
1017
+ ```ts
1018
+ await resetSessionScopedQueries(queryClient);
1019
+ ```
1020
+
1021
+ Для обработки уже полученной remote-команды нужен `false`, чтобы не создать цикл:
1022
+
1023
+ ```ts
1024
+ await resetSessionScopedQueries(queryClient, { broadcast: false });
1025
+ ```
1026
+
1027
+ Обычно consumer вручную не передаёт `false`: это делает `installSessionScopedQueryReset` внутри message handler.
1028
+
1029
+ ### `installSessionScopedQueryReset`
1030
+
1031
+ ```ts
1032
+ const installed = installSessionScopedQueryReset(queryClient, {
1033
+ channelName: "sales-app:session-cache-reset"
1034
+ });
1035
+ ```
1036
+
1037
+ Типы:
1038
+
1039
+ ```ts
1040
+ type InstallSessionScopedQueryResetOptions = {
1041
+ readonly channelName: string;
1042
+ };
1043
+
1044
+ type InstalledSessionScopedQueryReset = {
1045
+ readonly cleanup: () => void;
1046
+ };
1047
+ ```
1048
+
1049
+ Канал передаёт только command:
1050
+
1051
+ ```ts
1052
+ {
1053
+ type: "session-scoped-query-cache-reset";
1054
+ }
1055
+ ```
1056
+
1057
+ Он не передаёт:
1058
+
1059
+ - query data;
1060
+ - user id;
1061
+ - auth payload;
1062
+ - token;
1063
+ - session details.
1064
+
1065
+ Это безопаснее полной передачи session cache между вкладками.
1066
+
1067
+ ### Browser fallback
1068
+
1069
+ Если отсутствует `window` или `BroadcastChannel`, installation возвращает no-op cleanup:
1070
+
1071
+ ```ts
1072
+ const { cleanup } = installSessionScopedQueryReset(queryClient, options);
1073
+ cleanup(); // безопасно
1074
+ ```
1075
+
1076
+ Локальный `resetSessionScopedQueries` всё равно работает без browser channel.
1077
+
1078
+ ### Lifecycle и global sender
1079
+
1080
+ Внутри модуля хранится одна global функция рассылки session reset. Последняя успешная installation становится активной для `resetSessionScopedQueries`.
1081
+
1082
+ Поэтому в одном application runtime обычно устанавливают один session channel.
1083
+
1084
+ `cleanup()`:
1085
+
1086
+ - удаляет `onmessage`;
1087
+ - закрывает BroadcastChannel;
1088
+ - очищает global sender, если он всё ещё принадлежит этой installation.
1089
+
1090
+ ### Пример auth boundary
1091
+
1092
+ ```ts
1093
+ async function completeLogin() {
1094
+ await establishServerSession();
1095
+ await resetSessionScopedQueries(queryClient);
1096
+ }
1097
+
1098
+ async function logout() {
1099
+ await closeServerSession();
1100
+ await resetSessionScopedQueries(queryClient);
1101
+ }
1102
+ ```
1103
+
1104
+ Сначала измените server session, затем перезапрашивайте active query. Иначе refetch может снова получить data старой сессии.
1105
+
1106
+ ### Почему session query нельзя persist
1107
+
1108
+ IndexedDB переживает reload и может остаться после logout. Даже если UI очищает memory cache, persisted персональная запись представляет риск утечки следующей сессии.
1109
+
1110
+ `shouldPersistQuery` защищает от случайного сочетания flags, но правильная metadata остаётся ответственностью owner-а query.
1111
+
1112
+ ## Синхронизация между вкладками
1113
+
1114
+ ### Две задачи, два канала
1115
+
1116
+ `installReactQueryBroadcast` создаёт два разных механизма:
1117
+
1118
+ ```text
1119
+ queriesChannel
1120
+ └─ experimental broadcastQueryClient
1121
+ синхронизирует query cache data/state
1122
+
1123
+ eventsChannel
1124
+ └─ собственный BroadcastChannel
1125
+ передаёт invalidate/setQueryData commands
1126
+ ```
1127
+
1128
+ Разделение нужно потому, что experimental TanStack broadcast не обязан передавать прикладное invalidation так, как требуется host-приложению.
1129
+
1130
+ ### Установка
1131
+
1132
+ ```ts
1133
+ const installed = installReactQueryBroadcast(queryClient, {
1134
+ queriesChannel: "sales-app:query-data",
1135
+ eventsChannel: "sales-app:query-events"
1136
+ });
1137
+ ```
1138
+
1139
+ Структурные options:
1140
+
1141
+ ```ts
1142
+ type InstallBroadcastOptions = {
1143
+ queriesChannel: string;
1144
+ eventsChannel: string;
1145
+ };
1146
+ ```
1147
+
1148
+ Возвращается:
1149
+
1150
+ ```ts
1151
+ type InstalledReactQueryBroadcast = {
1152
+ broadcast: (event: CacheSyncEvent) => void;
1153
+ cleanup: () => void;
1154
+ };
1155
+ ```
1156
+
1157
+ Эти types не экспортируются отдельными именами, но TypeScript выводит их из function signature.
1158
+
1159
+ ### Имена каналов
1160
+
1161
+ Выбирайте стабильные и уникальные имена:
1162
+
1163
+ ```text
1164
+ {app}:{environment}:query-data
1165
+ {app}:{environment}:query-events
1166
+ ```
1167
+
1168
+ Не используйте один channel name для несвязанных приложений на одном origin. Иначе они могут прочитать чужие cache events.
1169
+
1170
+ `queriesChannel` и `eventsChannel` должны различаться: у них разные wire protocols.
1171
+
1172
+ ### Cache data sync
1173
+
1174
+ Foundation wrapper вызывает:
1175
+
1176
+ ```ts
1177
+ broadcastQueryClient({
1178
+ queryClient,
1179
+ broadcastChannel: queriesChannel
1180
+ });
1181
+ ```
1182
+
1183
+ Успешные изменения Query cache, включая подходящий `setQueryData`, могут появиться в другой вкладке через experimental TanStack adapter.
1184
+
1185
+ Это не сетевой refetch. Data передаётся browser channel между вкладками одного origin.
1186
+
1187
+ ### Автоматическая invalidation
1188
+
1189
+ Wrapper подписывается на `queryClient.getQueryCache()`.
1190
+
1191
+ Когда локальная query получает action `invalidate`, wrapper автоматически отправляет event:
1192
+
1193
+ ```ts
1194
+ {
1195
+ type: "invalidate",
1196
+ keys: [query.queryKey],
1197
+ refetchType: "none"
1198
+ }
1199
+ ```
1200
+
1201
+ Remote вкладка вызывает `invalidateQueries` для каждого key.
1202
+
1203
+ Широкая локальная invalidation превращается в набор точечных events для query, которые реально обновились в cache отправителя.
1204
+
1205
+ Автоматическая отправка всегда использует `refetchType: "none"`. Если другой вкладке нужен active/inactive/all refetch, отправьте explicit event.
1206
+
1207
+ ### Event `invalidate`
1208
+
1209
+ ```ts
1210
+ installed.broadcast({
1211
+ type: "invalidate",
1212
+ keys: [["products"], ["products", "summary"]],
1213
+ refetchType: "active"
1214
+ });
1215
+ ```
1216
+
1217
+ Поддерживаемые `refetchType`:
1218
+
1219
+ - `"none"`;
1220
+ - `"active"`;
1221
+ - `"inactive"`;
1222
+ - `"all"`.
1223
+
1224
+ Если field отсутствует, receiver использует `"none"`.
1225
+
1226
+ ### Event `setQueryData`
1227
+
1228
+ ```ts
1229
+ installed.broadcast({
1230
+ type: "setQueryData",
1231
+ key: ["products", "summary"],
1232
+ data: { count: 42 }
1233
+ });
1234
+ ```
1235
+
1236
+ Receiver вызывает:
1237
+
1238
+ ```ts
1239
+ queryClient.setQueryData(event.key, event.data);
1240
+ ```
1241
+
1242
+ Event принимает `data: unknown`. TypeScript не может доказать, что data соответствует cache type receiver-а. Sender обязан использовать правильный query key/data contract.
1243
+
1244
+ ### Глобальный imperative API
1245
+
1246
+ Чтобы не передавать `installed.broadcast` через все слои:
1247
+
1248
+ ```ts
1249
+ setBroadcastFn(installed.broadcast);
1250
+
1251
+ broadcastCacheEvent({
1252
+ type: "invalidate",
1253
+ keys: [["products"]],
1254
+ refetchType: "none"
1255
+ });
1256
+ ```
1257
+
1258
+ `setBroadcastFn` заменяет одну global handler-функцию. Это singleton module state.
1259
+
1260
+ До установки handler `broadcastCacheEvent` ничего не делает:
1261
+
1262
+ ```ts
1263
+ broadcastCacheEvent(event); // no-op, если handler ещё null
1264
+ ```
1265
+
1266
+ ### Cleanup global handler
1267
+
1268
+ `installed.cleanup()` закрывает каналы, но не знает, передавали ли вы `installed.broadcast` в `setBroadcastFn`.
1269
+
1270
+ Перед/после cleanup сбросьте global handler:
1271
+
1272
+ ```ts
1273
+ setBroadcastFn(() => undefined);
1274
+ installed.cleanup();
1275
+ ```
1276
+
1277
+ Иначе поздний `broadcastCacheEvent` может попытаться использовать функцию, связанную с уже закрытым channel.
1278
+
1279
+ ### Что делает cleanup
1280
+
1281
+ ```ts
1282
+ installed.cleanup();
1283
+ ```
1284
+
1285
+ Cleanup:
1286
+
1287
+ - отписывается от QueryCache events;
1288
+ - вызывает cleanup TanStack broadcast adapter;
1289
+ - закрывает custom BroadcastChannel.
1290
+
1291
+ Вызывайте его в тестах, stories, microfrontend teardown и при повторной installation.
1292
+
1293
+ ### Защита от циклов
1294
+
1295
+ Каждая installation получает `tabId`.
1296
+
1297
+ Receiver:
1298
+
1299
+ - игнорирует message с собственным `tabId`;
1300
+ - на время применения remote event включает `isApplyingRemoteEvent`;
1301
+ - не пересылает вызванную remote message invalidation обратно.
1302
+
1303
+ Это предотвращает бесконечное эхо между вкладками.
1304
+
1305
+ ### Проверка входящих messages
1306
+
1307
+ Модуль проверяет:
1308
+
1309
+ - message является record;
1310
+ - `tabId` является string;
1311
+ - event имеет известный `type`;
1312
+ - invalidate содержит array `keys`;
1313
+ - `refetchType` входит в allowlist;
1314
+ - setQueryData содержит fields `key` и `data`.
1315
+
1316
+ Проверка намеренно не является полной schema validation QueryKey/data. Например, каждый элемент `keys` отдельно глубоко не валидируется.
1317
+
1318
+ Это защита от случайных/устаревших messages, но не security boundary между приложениями одного origin. Используйте уникальные channel names и не отправляйте secrets.
1319
+
1320
+ ### Browser support
1321
+
1322
+ Если `window` отсутствует, `installReactQueryBroadcast` возвращает no-op `broadcast` и `cleanup`.
1323
+
1324
+ Но если `window` существует, а `BroadcastChannel` отсутствует, текущая implementation попытается вызвать constructor и может выбросить `ReferenceError`.
1325
+
1326
+ Для старого/ограниченного browser сделайте feature check до installation:
1327
+
1328
+ ```ts
1329
+ const installed =
1330
+ typeof BroadcastChannel === "undefined"
1331
+ ? { broadcast: () => undefined, cleanup: () => undefined }
1332
+ : installReactQueryBroadcast(queryClient, options);
1333
+ ```
1334
+
1335
+ ### Structured clone
1336
+
1337
+ BroadcastChannel использует structured clone. В `setQueryData.data` не передавайте:
1338
+
1339
+ - functions;
1340
+ - DOM nodes;
1341
+ - React elements;
1342
+ - QueryClient;
1343
+ - non-cloneable runtime handles.
1344
+
1345
+ Ошибка clone может возникнуть при `postMessage`.
1346
+
1347
+ ### Same-origin граница
1348
+
1349
+ BroadcastChannel работает между browsing contexts одного origin и channel name. Он не синхронизирует разные domains, browser profiles или devices.
1350
+
1351
+ ### Когда нужен ручной event
1352
+
1353
+ Обычный `queryClient.setQueryData` уже может синхронизироваться TanStack adapter-ом. Не дублируйте manual `setQueryData` event автоматически везде.
1354
+
1355
+ Ручной event нужен, когда:
1356
+
1357
+ - требуется explicit прикладная команда;
1358
+ - нужен особый `refetchType`;
1359
+ - нужно одной командой перечислить несколько keys;
1360
+ - consumer сознательно не полагается только на experimental data sync.
1361
+
1362
+ ### Broadcast и session data
1363
+
1364
+ Полная cache synchronization потенциально передаёт query data между вкладками. Для команды очистки auth/session cache используйте отдельный `installSessionScopedQueryReset`: он передаёт только reset command.
1365
+
1366
+ Не отправляйте session token или чувствительный payload через `broadcastCacheEvent`.
1367
+
1368
+ ## Optimistic update
1369
+
1370
+ ### Что это такое
1371
+
1372
+ Обычная mutation:
1373
+
1374
+ ```text
1375
+ действие пользователя
1376
+ ↓
1377
+ HTTP request
1378
+ ↓
1379
+ server response
1380
+ ↓
1381
+ обновление UI
1382
+ ```
1383
+
1384
+ Пользователь ждёт сеть до визуального результата.
1385
+
1386
+ Optimistic update сначала меняет локальный Query cache:
1387
+
1388
+ ```text
1389
+ действие пользователя
1390
+ ↓
1391
+ UI/cache меняются сразу
1392
+ ↓
1393
+ HTTP request
1394
+ ├─ успех → подтвердить/слить server data
1395
+ └─ ошибка → откатить предыдущий snapshot
1396
+ ```
1397
+
1398
+ Мы предполагаем, что server примет изменение. Если предположение неверно, cache возвращается к предыдущему состоянию.
1399
+
1400
+ ### Когда использовать
1401
+
1402
+ Хорошие сценарии:
1403
+
1404
+ - toggle/checkbox;
1405
+ - изменение видимости;
1406
+ - простое inline-edit;
1407
+ - drag-and-drop reorder;
1408
+ - добавление элемента с временным id;
1409
+ - действие с высокой вероятностью успеха и понятным rollback.
1410
+
1411
+ Не используйте автоматически для:
1412
+
1413
+ - финансовых операций;
1414
+ - сложной server validation;
1415
+ - действия с большим количеством side effects;
1416
+ - mutation, где server радикально меняет результат;
1417
+ - нескольких параллельных изменений одного cache key без concurrency strategy;
1418
+ - операции, для которой невозможно корректно восстановить UI.
1419
+
1420
+ Optimistic UI не означает, что операция завершилась успешно. Показывайте error/recovery, если server отказал.
1421
+
1422
+ ### Роль lifecycle handlers
1423
+
1424
+ ```text
1425
+ mutate(variables)
1426
+ │
1427
+ ▼
1428
+ onMutate
1429
+ 1. cancelQueries
1430
+ 2. snapshot previous cache
1431
+ 3. optimistic patch
1432
+ 4. return { previous }
1433
+ │
1434
+ ▼
1435
+ mutationFn
1436
+ ├─ success → onSuccess
1437
+ │ server data merge, если нужно
1438
+ │
1439
+ ├─ error → onError
1440
+ │ rollback previous
1441
+ │
1442
+ └─ always → onSettled
1443
+ scoped invalidation/refetch policy
1444
+ ```
1445
+
1446
+ TanStack Query передаёт результат `onMutate` в следующие lifecycle callbacks.
1447
+
1448
+ ### Публичные helpers
1449
+
1450
+ | Helper | Lifecycle | Назначение |
1451
+ | --------------------- | ----------- | ------------------------------------ |
1452
+ | `onMutateOptimistic` | `onMutate` | Cancel + snapshot + patch. |
1453
+ | `onErrorOptimistic` | `onError` | Restore предыдущего snapshot. |
1454
+ | `onSuccessOptimistic` | `onSuccess` | Merge authoritative server response. |
1455
+ | `onSettledOptimistic` | `onSettled` | Invalidate один или несколько keys. |
1456
+
1457
+ Helpers низкоуровневые: они не создают `useMutation`, не выполняют request, не показывают notification и не выбирают query key.
1458
+
1459
+ ### `OptimisticContext`
1460
+
1461
+ ```ts
1462
+ type OptimisticContext<TQueryCacheData> = {
1463
+ previous: TQueryCacheData | undefined;
1464
+ };
1465
+ ```
1466
+
1467
+ `TQueryCacheData` — тип data именно в Query cache, не request variables и не обязательно server response.
1468
+
1469
+ Пример:
1470
+
1471
+ ```ts
1472
+ type Task = {
1473
+ id: string;
1474
+ title: string;
1475
+ done: boolean;
1476
+ };
1477
+
1478
+ type TaskCache = Task[];
1479
+ ```
1480
+
1481
+ ### `onMutateOptimistic`
1482
+
1483
+ ```ts
1484
+ const context = await onMutateOptimistic<TaskCache>(queryClient, tasksQueryKey, (oldCache) => {
1485
+ return oldCache?.map((task) => (task.id === taskId ? { ...task, done: true } : task));
1486
+ });
1487
+ ```
1488
+
1489
+ Signature:
1490
+
1491
+ ```ts
1492
+ async function onMutateOptimistic<TQueryCacheData>(
1493
+ queryClient: QueryClient,
1494
+ queryKey: QueryKey,
1495
+ patcher: (oldCache: TQueryCacheData | undefined) => TQueryCacheData | undefined
1496
+ ): Promise<OptimisticContext<TQueryCacheData>>;
1497
+ ```
1498
+
1499
+ Три шага выполняются последовательно:
1500
+
1501
+ 1. `cancelQueries({ queryKey })`;
1502
+ 2. `getQueryData<TQueryCacheData>(queryKey)`;
1503
+ 3. `setQueryData<TQueryCacheData>(queryKey, patcher)`.
1504
+
1505
+ ### Зачем `cancelQueries`
1506
+
1507
+ Без отмены background refetch может завершиться после optimistic patch и записать старый server snapshot:
1508
+
1509
+ ```text
1510
+ cache: done=false
1511
+ background request начат
1512
+ optimistic patch: done=true
1513
+ background response: done=false ← patch потерян
1514
+ ```
1515
+
1516
+ `cancelQueries` уменьшает эту race condition.
1517
+
1518
+ Он не отменяет другие mutation. Параллельные writes требуют отдельной стратегии.
1519
+
1520
+ ### Exact и prefix key
1521
+
1522
+ `cancelQueries({ queryKey })` по TanStack defaults может затронуть query, совпадающие по prefix.
1523
+
1524
+ `getQueryData` и `setQueryData` работают с конкретным cache key.
1525
+
1526
+ Поэтому для optimistic patch передавайте exact owner key, а не слишком широкий prefix:
1527
+
1528
+ ```ts
1529
+ const key = ["tasks", { projectId }] as const;
1530
+ ```
1531
+
1532
+ ### Patcher должен быть immutable
1533
+
1534
+ Неверно:
1535
+
1536
+ ```ts
1537
+ (old) => {
1538
+ const task = old?.find((item) => item.id === taskId);
1539
+ if (task) task.done = true;
1540
+ return old;
1541
+ };
1542
+ ```
1543
+
1544
+ Объект из Query cache мутирован по существующей ссылке.
1545
+
1546
+ Правильно:
1547
+
1548
+ ```ts
1549
+ (old) => old?.map((task) => (task.id === taskId ? { ...task, done: true } : task));
1550
+ ```
1551
+
1552
+ Создаётся новый массив и новый изменённый object.
1553
+
1554
+ ### Что означает `undefined` из patcher
1555
+
1556
+ Patcher получает `undefined`, если cache entry/data отсутствует.
1557
+
1558
+ ```ts
1559
+ (old) => old?.map(...)
1560
+ ```
1561
+
1562
+ вернёт `undefined` и не создаст data.
1563
+
1564
+ Если нужно создать initial optimistic data:
1565
+
1566
+ ```ts
1567
+ (old) => [...(old ?? []), optimisticTask];
1568
+ ```
1569
+
1570
+ Но тогда обязательно учтите ограничение rollback при отсутствующем previous snapshot.
1571
+
1572
+ ### `onErrorOptimistic`
1573
+
1574
+ ```ts
1575
+ onErrorOptimistic(queryClient, tasksQueryKey, context);
1576
+ ```
1577
+
1578
+ Signature:
1579
+
1580
+ ```ts
1581
+ function onErrorOptimistic<TQueryCacheData>(
1582
+ queryClient: QueryClient,
1583
+ queryKey: QueryKey,
1584
+ context: OptimisticContext<TQueryCacheData> | undefined
1585
+ ): void;
1586
+ ```
1587
+
1588
+ Если `context?.previous !== undefined`, helper вызывает:
1589
+
1590
+ ```ts
1591
+ queryClient.setQueryData(queryKey, context.previous);
1592
+ ```
1593
+
1594
+ Если context отсутствует, ничего не происходит. Это безопасно, когда `onMutate` сам завершился ошибкой до возврата context.
1595
+
1596
+ ### Важное ограничение rollback
1597
+
1598
+ Если до mutation data была `undefined`, context содержит:
1599
+
1600
+ ```ts
1601
+ {
1602
+ previous: undefined;
1603
+ }
1604
+ ```
1605
+
1606
+ `onErrorOptimistic` ничего не делает. Он не удаляет созданную optimistic entry.
1607
+
1608
+ Проблемный сценарий:
1609
+
1610
+ ```ts
1611
+ // Cache до mutation отсутствует.
1612
+ onMutateOptimistic<Task[]>(queryClient, key, (old) => [...(old ?? []), optimisticTask]);
1613
+ ```
1614
+
1615
+ При error optimistic task останется, если вызвать только standard helper.
1616
+
1617
+ Варианты решения:
1618
+
1619
+ 1. До optimistic create заранее обеспечить существующий cache snapshot, например `[]`.
1620
+ 2. В `onError` отдельно удалить/очистить query, если `context.previous === undefined`.
1621
+ 3. Не применять optimistic create для изначально отсутствующей query.
1622
+
1623
+ Пример explicit rollback:
1624
+
1625
+ ```ts
1626
+ onError: (_error, _variables, context) => {
1627
+ if (context?.previous === undefined) {
1628
+ queryClient.removeQueries({ queryKey: tasksQueryKey, exact: true });
1629
+ return;
1630
+ }
1631
+
1632
+ onErrorOptimistic(queryClient, tasksQueryKey, context);
1633
+ };
1634
+ ```
1635
+
1636
+ Выбирайте удаление только для exact key, которым владеет mutation.
1637
+
1638
+ ### `onSuccessOptimistic`
1639
+
1640
+ Когда server возвращает authoritative data:
1641
+
1642
+ ```ts
1643
+ onSuccessOptimistic<Task[], Task>(queryClient, tasksQueryKey, serverTask, (cache, task) => {
1644
+ return cache?.map((item) => (item.id === task.id ? task : item));
1645
+ });
1646
+ ```
1647
+
1648
+ Signature:
1649
+
1650
+ ```ts
1651
+ function onSuccessOptimistic<TQueryCacheData, TServerData>(
1652
+ queryClient: QueryClient,
1653
+ queryKey: QueryKey,
1654
+ serverData: TServerData,
1655
+ merger: (cache: TQueryCacheData | undefined, serverData: TServerData) => TQueryCacheData | undefined
1656
+ ): void;
1657
+ ```
1658
+
1659
+ `TServerData` может отличаться от cache data. Merger является явным type-safe мостом.
1660
+
1661
+ ### Полная замена ответом server
1662
+
1663
+ ```ts
1664
+ onSuccessOptimistic<Task[], Task[]>(queryClient, tasksQueryKey, serverTasks, (_cache, data) => data);
1665
+ ```
1666
+
1667
+ ### Merge одного элемента
1668
+
1669
+ ```ts
1670
+ onSuccessOptimistic<Task[], Task>(queryClient, tasksQueryKey, updatedTask, (cache, data) =>
1671
+ cache?.map((task) => (task.id === data.id ? data : task))
1672
+ );
1673
+ ```
1674
+
1675
+ Merger тоже должен быть immutable.
1676
+
1677
+ ### Server возвращает `void`
1678
+
1679
+ Если request только подтверждает изменение, а optimistic cache уже отражает точное состояние, `onSuccessOptimistic` не нужен:
1680
+
1681
+ ```ts
1682
+ onSuccess: () => {
1683
+ // Нечего сливать: server response void.
1684
+ };
1685
+ ```
1686
+
1687
+ После этого выберите onSettled invalidation strategy. Не вызывайте merger с `void` только ради формальности.
1688
+
1689
+ ### `onSettledOptimistic`
1690
+
1691
+ ```ts
1692
+ await onSettledOptimistic(queryClient, tasksQueryKey);
1693
+ ```
1694
+
1695
+ Или несколько keys:
1696
+
1697
+ ```ts
1698
+ await onSettledOptimistic(queryClient, [tasksQueryKey, taskSummaryQueryKey], { refetchType: "active" });
1699
+ ```
1700
+
1701
+ Signature:
1702
+
1703
+ ```ts
1704
+ type OnSettledOptimisticOptions = {
1705
+ refetchType?: "none" | "active" | "inactive" | "all";
1706
+ };
1707
+
1708
+ async function onSettledOptimistic(
1709
+ queryClient: QueryClient,
1710
+ queryKeys: QueryKey | QueryKey[],
1711
+ options?: OnSettledOptimisticOptions
1712
+ ): Promise<void>;
1713
+ ```
1714
+
1715
+ Helper вызывает `invalidateQueries` для всех normalized keys через `Promise.all`.
1716
+
1717
+ ### `refetchType`
1718
+
1719
+ | Value | Поведение |
1720
+ | ------------ | ---------------------------------------------------------------- |
1721
+ | `"none"` | Только invalidation, немедленного refetch нет. Default helper-а. |
1722
+ | `"active"` | Refetch active matching query. |
1723
+ | `"inactive"` | Refetch inactive matching query. |
1724
+ | `"all"` | Refetch active и inactive matching query. |
1725
+
1726
+ `"none"` подходит, когда optimistic/server merge уже дал достаточное текущее состояние.
1727
+
1728
+ `"active"` нужен, если server применяет дополнительную логику и видимый UI должен быстро получить authoritative snapshot.
1729
+
1730
+ Не ставьте `"all"` автоматически: это может создать много HTTP requests.
1731
+
1732
+ ### Как передавать один и несколько keys
1733
+
1734
+ Один key:
1735
+
1736
+ ```ts
1737
+ onSettledOptimistic(queryClient, ["tasks"]);
1738
+ ```
1739
+
1740
+ Несколько keys:
1741
+
1742
+ ```ts
1743
+ onSettledOptimistic(queryClient, [["tasks"], ["task-summary"]]);
1744
+ ```
1745
+
1746
+ Runtime различает формы по первому элементу: если первый элемент сам array, значение считается массивом keys.
1747
+
1748
+ Избегайте необычного одиночного QueryKey, у которого первый segment является array:
1749
+
1750
+ ```ts
1751
+ const ambiguousKey = [["nested"]] as const;
1752
+ ```
1753
+
1754
+ Он будет воспринят как список query keys.
1755
+
1756
+ ### Полный пример: toggle, server возвращает `void`
1757
+
1758
+ ```ts
1759
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
1760
+ import { onErrorOptimistic, onMutateOptimistic, onSettledOptimistic } from "@ryuzaki13/react-foundation-lib/query-client";
1761
+
1762
+ type Task = {
1763
+ id: string;
1764
+ title: string;
1765
+ done: boolean;
1766
+ };
1767
+
1768
+ type ToggleTaskInput = {
1769
+ id: string;
1770
+ done: boolean;
1771
+ };
1772
+
1773
+ const tasksQueryKey = ["tasks"] as const;
1774
+
1775
+ export function useToggleTaskMutation() {
1776
+ const queryClient = useQueryClient();
1777
+
1778
+ return useMutation({
1779
+ mutationFn: updateTaskOnServer,
1780
+
1781
+ onMutate: async (input: ToggleTaskInput) => {
1782
+ return onMutateOptimistic<Task[]>(queryClient, tasksQueryKey, (old) =>
1783
+ old?.map((task) => (task.id === input.id ? { ...task, done: input.done } : task))
1784
+ );
1785
+ },
1786
+
1787
+ onError: (_error, _input, context) => {
1788
+ onErrorOptimistic(queryClient, tasksQueryKey, context);
1789
+ },
1790
+
1791
+ onSettled: () => {
1792
+ return onSettledOptimistic(queryClient, tasksQueryKey);
1793
+ }
1794
+ });
1795
+ }
1796
+ ```
1797
+
1798
+ Cache timeline:
1799
+
1800
+ ```text
1801
+ до: [{ id: "1", done: false }]
1802
+ onMutate: [{ id: "1", done: true }]
1803
+
1804
+ success: optimistic data остаётся, query помечается stale
1805
+ error: previous snapshot восстанавливается
1806
+ ```
1807
+
1808
+ ### Полный пример: create с authoritative object
1809
+
1810
+ ```ts
1811
+ type Task = {
1812
+ id: string;
1813
+ title: string;
1814
+ createdAt: string;
1815
+ };
1816
+
1817
+ type NewTask = {
1818
+ title: string;
1819
+ };
1820
+
1821
+ const TEMP_ID = "optimistic:task";
1822
+
1823
+ export function useCreateTaskMutation() {
1824
+ const queryClient = useQueryClient();
1825
+
1826
+ return useMutation({
1827
+ mutationFn: createTaskOnServer,
1828
+
1829
+ onMutate: async (input: NewTask) => {
1830
+ return onMutateOptimistic<Task[]>(queryClient, tasksQueryKey, (old) => [
1831
+ ...(old ?? []),
1832
+ { id: TEMP_ID, title: input.title, createdAt: "" }
1833
+ ]);
1834
+ },
1835
+
1836
+ onError: (_error, _input, context) => {
1837
+ if (context?.previous === undefined) {
1838
+ queryClient.removeQueries({
1839
+ queryKey: tasksQueryKey,
1840
+ exact: true
1841
+ });
1842
+ return;
1843
+ }
1844
+
1845
+ onErrorOptimistic(queryClient, tasksQueryKey, context);
1846
+ },
1847
+
1848
+ onSuccess: (createdTask: Task) => {
1849
+ onSuccessOptimistic<Task[], Task>(
1850
+ queryClient,
1851
+ tasksQueryKey,
1852
+ createdTask,
1853
+ (cache, serverTask) => cache?.map((task) => (task.id === TEMP_ID ? serverTask : task)) ?? [serverTask]
1854
+ );
1855
+ },
1856
+
1857
+ onSettled: () => {
1858
+ return onSettledOptimistic(queryClient, tasksQueryKey, { refetchType: "active" });
1859
+ }
1860
+ });
1861
+ }
1862
+ ```
1863
+
1864
+ Timeline:
1865
+
1866
+ ```text
1867
+ до:
1868
+ [{ id: "server-1", title: "Первая", createdAt: "..." }]
1869
+
1870
+ optimistic:
1871
+ [
1872
+ { id: "server-1", ... },
1873
+ { id: "optimistic:task", title: "Вторая", createdAt: "" }
1874
+ ]
1875
+
1876
+ server success:
1877
+ [
1878
+ { id: "server-1", ... },
1879
+ { id: "server-2", title: "Вторая", createdAt: "2026-08-18..." }
1880
+ ]
1881
+ ```
1882
+
1883
+ ### Несколько связанных keys
1884
+
1885
+ Mutation может влиять на detail, list и counters:
1886
+
1887
+ ```ts
1888
+ onSettled: () =>
1889
+ onSettledOptimistic(
1890
+ queryClient,
1891
+ [
1892
+ ["orders", orderId],
1893
+ ["orders", "list"],
1894
+ ["orders", "counters"]
1895
+ ],
1896
+ { refetchType: "active" }
1897
+ );
1898
+ ```
1899
+
1900
+ Указывайте только keys реальных owners. Не инвалидируйте весь cache приложения.
1901
+
1902
+ ### Optimistic update и broadcast
1903
+
1904
+ Optimistic helpers сами не вызывают `broadcastCacheEvent`.
1905
+
1906
+ При установленном `installReactQueryBroadcast`:
1907
+
1908
+ - обычный Query cache update может уйти через TanStack data channel;
1909
+ - `onSettledOptimistic` вызывает local invalidation;
1910
+ - wrapper автоматически передаст конкретную invalidation другой вкладке с `refetchType: "none"`.
1911
+
1912
+ Manual event нужен только для осознанного другого поведения:
1913
+
1914
+ ```ts
1915
+ onSettled: async () => {
1916
+ await onSettledOptimistic(queryClient, tasksQueryKey, { refetchType: "active" });
1917
+
1918
+ broadcastCacheEvent({
1919
+ type: "invalidate",
1920
+ keys: [tasksQueryKey],
1921
+ refetchType: "active"
1922
+ });
1923
+ };
1924
+ ```
1925
+
1926
+ Такой explicit event может дублировать automatic `"none"` invalidation message. Делайте это только если remote active refetch действительно требуется.
1927
+
1928
+ ### Параллельные mutation одного key
1929
+
1930
+ Helpers хранят один snapshot на каждую mutation, но не координируют порядок нескольких writes.
1931
+
1932
+ Проблема:
1933
+
1934
+ ```text
1935
+ cache = A
1936
+
1937
+ mutation 1 snapshot A → patch B
1938
+ mutation 2 snapshot B → patch C
1939
+ mutation 2 success
1940
+ mutation 1 error → rollback A
1941
+
1942
+ успешный результат mutation 2 потерян
1943
+ ```
1944
+
1945
+ `cancelQueries` отменяет reads, а не соседние mutations.
1946
+
1947
+ Для concurrent writes выберите стратегию:
1948
+
1949
+ - сериализовать mutation по entity/key;
1950
+ - блокировать повторное действие до settlement;
1951
+ - хранить более богатый optimistic context/patch stack;
1952
+ - использовать mutation id и условный rollback;
1953
+ - отказаться от optimistic update и refetch server snapshot.
1954
+
1955
+ Текущие helpers лучше подходят одной последовательной mutation для одного exact cache key.
1956
+
1957
+ ### Server validation и merge
1958
+
1959
+ Если server:
1960
+
1961
+ - округляет число;
1962
+ - добавляет id/timestamp;
1963
+ - меняет status;
1964
+ - пересчитывает totals;
1965
+ - сортирует list;
1966
+ - применяет permissions/business rules,
1967
+
1968
+ то optimistic value не является authoritative. Используйте `onSuccessOptimistic` либо active invalidation.
1969
+
1970
+ ### Error handling
1971
+
1972
+ Rollback восстанавливает cache, но не объясняет ошибку пользователю. В `onError` обычно нужны:
1973
+
1974
+ - rollback;
1975
+ - безопасное notification;
1976
+ - diagnostics без sensitive data;
1977
+ - понятная возможность повторить действие.
1978
+
1979
+ ```ts
1980
+ onError: (error, _variables, context) => {
1981
+ onErrorOptimistic(queryClient, key, context);
1982
+ notifyError("Изменение не сохранено");
1983
+ reportError(error);
1984
+ };
1985
+ ```
1986
+
1987
+ Не оставляйте пустой `catch` и не показывайте success до фактического server confirmation, если это может ввести пользователя в заблуждение.
1988
+
1989
+ ### Типовые ошибки
1990
+
1991
+ #### Мутировать old cache
1992
+
1993
+ Всегда возвращайте новые изменённые containers/objects.
1994
+
1995
+ #### Забыть rollback
1996
+
1997
+ Optimistic patch без `onError` оставит ложное состояние при отказе server.
1998
+
1999
+ #### Считать `context` обязательным
2000
+
2001
+ Если `onMutate` выбросил ошибку, context может быть `undefined`.
2002
+
2003
+ #### Не учесть `previous: undefined`
2004
+
2005
+ Standard rollback helper не удаляет новую cache entry.
2006
+
2007
+ #### Refetch `all` после каждой mutation
2008
+
2009
+ Создаёт лишний network traffic и может ухудшить UX сильнее обычной non-optimistic mutation.
2010
+
2011
+ #### Использовать широкий query prefix как exact data key
2012
+
2013
+ Cancel и patch начнут относиться к разным наборам cache entries.
2014
+
2015
+ #### Игнорировать параллельные writes
2016
+
2017
+ Поздний rollback старой mutation может затереть более новое успешное изменение.
2018
+
2019
+ ## Безопасность и сериализация
2020
+
2021
+ ### Query key не должен содержать secrets
2022
+
2023
+ Query key может попасть:
2024
+
2025
+ - в memory cache;
2026
+ - в IndexedDB persistent key/state;
2027
+ - в broadcast message;
2028
+ - в DevTools;
2029
+ - в diagnostics/error report.
2030
+
2031
+ Нельзя помещать:
2032
+
2033
+ - token;
2034
+ - password;
2035
+ - session cookie;
2036
+ - raw authorization header;
2037
+ - секретный payload.
2038
+
2039
+ Используйте безопасные business identifiers и нормализованные параметры.
2040
+
2041
+ ### Persistence — явное разрешение
2042
+
2043
+ `persistedQueryMeta` означает, что data может пережить reload и оставаться в browser storage долгое время.
2044
+
2045
+ Перед включением проверьте:
2046
+
2047
+ 1. Data не персональная и не session-scoped.
2048
+ 2. Namespace разделяет несовместимые system/client/tenant.
2049
+ 3. Query key включает все влияющие параметры.
2050
+ 4. Есть freshness/invalidation policy.
2051
+ 5. Data поддерживает structured clone.
2052
+ 6. Объём не создаёт неконтролируемый storage growth.
2053
+
2054
+ ### Namespace не является authorization
2055
+
2056
+ Разные `dbName`/prefix уменьшают случайное смешение environments, но не защищают от JavaScript того же origin.
2057
+
2058
+ При XSS вредоносный code может читать browser storage и channels. Не сохраняйте sensitive data, полагаясь только на необычное имя database.
2059
+
2060
+ ### Broadcast не является доверенным transport
2061
+
2062
+ Message приходит от другого context того же origin. Runtime guards фильтруют явный мусор, но не проверяют business authorization или точный data schema.
2063
+
2064
+ Не используйте broadcast вместо backend confirmation.
2065
+
2066
+ ### Structured clone ограничения
2067
+
2068
+ И IndexedDB, и BroadcastChannel используют structured clone. Plain serializable data — самый предсказуемый вариант:
2069
+
2070
+ ```ts
2071
+ type SafeCacheData = {
2072
+ id: string;
2073
+ count: number;
2074
+ labels: string[];
2075
+ updatedAt: string;
2076
+ };
2077
+ ```
2078
+
2079
+ Сложные instances могут потерять ожидаемое поведение или не клонироваться.
2080
+
2081
+ ### Error callbacks и персональные данные
2082
+
2083
+ Global error handler получает query/mutation objects и variables. Не логируйте их целиком без sanitization.
2084
+
2085
+ Хороший diagnostics context:
2086
+
2087
+ ```ts
2088
+ {
2089
+ operation: "update-task",
2090
+ queryKeyOwner: "tasks",
2091
+ errorName: error.name,
2092
+ message: error.message
2093
+ }
2094
+ ```
2095
+
2096
+ Плохой context:
2097
+
2098
+ ```ts
2099
+ {
2100
+ mutationVariables,
2101
+ allQueryCache: queryClient.getQueryCache().getAll(),
2102
+ token
2103
+ }
2104
+ ```
2105
+
2106
+ ## Browser, SSR и lifecycle
2107
+
2108
+ ### Основная среда — browser SPA
2109
+
2110
+ Persistence и broadcast предназначены для browser runtime:
2111
+
2112
+ - IndexedDB;
2113
+ - BroadcastChannel;
2114
+ - `window.location.hostname`;
2115
+ - несколько вкладок одного origin.
2116
+
2117
+ Pure QueryClient/optimistic helpers сами используют TanStack Query API, но единый entrypoint всё равно требует корректных build-time constants.
2118
+
2119
+ ### Поведение без IndexedDB
2120
+
2121
+ ```ts
2122
+ createIndexedDbQueryStorage(); // undefined
2123
+ createReactQueryPersister(); // undefined
2124
+ createQueryClient({}); // работает без default persister
2125
+ ```
2126
+
2127
+ Приложение продолжает работать с обычным memory cache.
2128
+
2129
+ ### Поведение без `window`
2130
+
2131
+ `installReactQueryBroadcast` возвращает no-op.
2132
+
2133
+ `installSessionScopedQueryReset` возвращает no-op cleanup.
2134
+
2135
+ `resetSessionScopedQueries` продолжает локально работать с QueryClient.
2136
+
2137
+ Production system identifier fallback без window — `{APP_ID}:server`.
2138
+
2139
+ ### `window` есть, BroadcastChannel нет
2140
+
2141
+ Session reset installation проверяет оба globals и деградирует в no-op.
2142
+
2143
+ Общая cache broadcast installation проверяет только `window`. Сделайте дополнительный feature check в host, если поддерживаются browsers без BroadcastChannel.
2144
+
2145
+ ### Global module state
2146
+
2147
+ Модуль хранит:
2148
+
2149
+ - текущий persistence project adapter;
2150
+ - global cache broadcast function;
2151
+ - global session-reset sender.
2152
+
2153
+ Это process/module singletons. В обычной SPA они удобны, но в tests, HMR и microfrontends требуют lifecycle discipline.
2154
+
2155
+ ### Повторная installation
2156
+
2157
+ Перед повторной установкой:
2158
+
2159
+ 1. сбросьте imperative broadcast handler;
2160
+ 2. вызовите cleanup предыдущего broadcast;
2161
+ 3. вызовите cleanup session channel;
2162
+ 4. установите новые channels;
2163
+ 5. сохраните новый handler.
2164
+
2165
+ Иначе останутся дублированные subscriptions/channels.
2166
+
2167
+ ### React Provider
2168
+
2169
+ `createQueryClient` не рендерит Provider:
2170
+
2171
+ ```tsx
2172
+ <QueryClientProvider client={queryClient}>
2173
+ <App />
2174
+ </QueryClientProvider>
2175
+ ```
2176
+
2177
+ `useQueryClient`, `useQuery` и `useMutation` требуют этот provider в component tree.
2178
+
2179
+ ### React Strict Mode
2180
+
2181
+ Не устанавливайте channels внутри component render/effect без уверенного cleanup. В development Strict Mode effect setup может выполняться повторно.
2182
+
2183
+ Предпочтительно устанавливать application-wide QueryClient/channels в bootstrap module или в отдельной lifecycle boundary с симметричным cleanup.
2184
+
2185
+ ## Тестирование consumer-кода
2186
+
2187
+ ### Test QueryClient без retry
2188
+
2189
+ Для unit test создавайте отдельный client:
2190
+
2191
+ ```ts
2192
+ import { QueryClient } from "@tanstack/react-query";
2193
+
2194
+ export function createTestQueryClient() {
2195
+ return new QueryClient({
2196
+ defaultOptions: {
2197
+ queries: { retry: false },
2198
+ mutations: { retry: false }
2199
+ }
2200
+ });
2201
+ }
2202
+ ```
2203
+
2204
+ Это изолирует cache каждого test.
2205
+
2206
+ После test:
2207
+
2208
+ ```ts
2209
+ queryClient.clear();
2210
+ ```
2211
+
2212
+ ### Test `createQueryClient`
2213
+
2214
+ Проверьте defaults и error callbacks:
2215
+
2216
+ ```ts
2217
+ const client = createQueryClient({});
2218
+
2219
+ expect(client.getDefaultOptions().queries).toMatchObject({
2220
+ staleTime: 10 * 60 * 1000,
2221
+ gcTime: 30 * 60 * 1000,
2222
+ retry: false,
2223
+ throwOnError: false
2224
+ });
2225
+ ```
2226
+
2227
+ В environment без IndexedDB `persister` должен отсутствовать.
2228
+
2229
+ ### IndexedDB test
2230
+
2231
+ Передайте factory явно, не полагаясь на browser global:
2232
+
2233
+ ```ts
2234
+ import { indexedDB } from "fake-indexeddb";
2235
+
2236
+ const storage = createIndexedDbQueryStorage<string>({
2237
+ indexedDB,
2238
+ dbName: "query-client-test"
2239
+ });
2240
+
2241
+ expect(storage).toBeDefined();
2242
+ await storage?.setItem("key", "value");
2243
+ await expect(storage?.getItem("key")).resolves.toBe("value");
2244
+ ```
2245
+
2246
+ Используйте уникальный `dbName` между несвязанными tests, чтобы data не протекала.
2247
+
2248
+ ### Test opt-in persistence
2249
+
2250
+ ```ts
2251
+ expect(shouldPersistQuery({ meta: persistedQueryMeta })).toBe(true);
2252
+ expect(shouldPersistQuery({ meta: undefined })).toBe(false);
2253
+ expect(shouldPersistQuery({ meta: sessionScopedQueryMeta })).toBe(false);
2254
+ expect(
2255
+ shouldPersistQuery({
2256
+ meta: { persist: true, sessionScoped: true }
2257
+ })
2258
+ ).toBe(false);
2259
+ ```
2260
+
2261
+ ### Test optimistic rollback
2262
+
2263
+ ```ts
2264
+ const client = createTestQueryClient();
2265
+ const key = ["tasks"] as const;
2266
+ const initial = [{ id: "1", done: false }];
2267
+
2268
+ client.setQueryData(key, initial);
2269
+
2270
+ const context = await onMutateOptimistic<typeof initial>(client, key, (old) => old?.map((task) => ({ ...task, done: true })));
2271
+
2272
+ expect(client.getQueryData(key)).toEqual([{ id: "1", done: true }]);
2273
+
2274
+ onErrorOptimistic(client, key, context);
2275
+ expect(client.getQueryData(key)).toEqual(initial);
2276
+ ```
2277
+
2278
+ Отдельно тестируйте `previous: undefined`, если mutation способна создать cache entry.
2279
+
2280
+ ### Test session reset
2281
+
2282
+ Создайте public и session query, выполните reset и проверьте их отдельно:
2283
+
2284
+ ```ts
2285
+ await client.fetchQuery({
2286
+ queryKey: ["public"],
2287
+ queryFn: () => "public"
2288
+ });
2289
+
2290
+ await client.fetchQuery({
2291
+ queryKey: ["session"],
2292
+ queryFn: () => "private",
2293
+ meta: sessionScopedQueryMeta
2294
+ });
2295
+
2296
+ await resetSessionScopedQueries(client, { broadcast: false });
2297
+
2298
+ expect(client.getQueryData(["public"])).toBe("public");
2299
+ expect(client.getQueryData(["session"])).toBeUndefined();
2300
+ ```
2301
+
2302
+ ### Test BroadcastChannel
2303
+
2304
+ Для unit test предоставьте fake implementation, которая:
2305
+
2306
+ - регистрирует channels по имени;
2307
+ - отправляет message другим instances, но не себе;
2308
+ - вызывает `onmessage`;
2309
+ - умеет `close`;
2310
+ - очищает registry после test.
2311
+
2312
+ Всегда вызывайте installation cleanup.
2313
+
2314
+ ### Сброс global state между tests
2315
+
2316
+ ```ts
2317
+ setBroadcastFn(() => undefined);
2318
+ configureQueryPersistenceProjectAdapter({});
2319
+ installedBroadcast?.cleanup();
2320
+ installedSessionReset?.cleanup();
2321
+ queryClient.clear();
2322
+ ```
2323
+
2324
+ Без cleanup test order может влиять на результат.
2325
+
2326
+ ### Проверки самого пакета
2327
+
2328
+ ```bash
2329
+ npm run typecheck
2330
+ npm run lint
2331
+ node_modules/.bin/vitest run src/query-client
2332
+ npm run build
2333
+ ```
2334
+
2335
+ Profile tests подтверждают:
2336
+
2337
+ - IndexedDB CRUD и opt-in filtering;
2338
+ - условное подключение persister;
2339
+ - broadcast no-op вне browser;
2340
+ - cache data/invalidation sync;
2341
+ - игнорирование невалидных messages;
2342
+ - отсутствие remote event loop;
2343
+ - optimistic snapshot/patch/rollback/merge/invalidation;
2344
+ - session reset active/inactive/disabled/hydrated query;
2345
+ - межвкладочную reset command без cache data.
2346
+
2347
+ ## Полная карта API
2348
+
2349
+ Раздел перечисляет все публичные symbols entrypoint. Не импортируйте другие names через private path.
2350
+
2351
+ ### QueryClient
2352
+
2353
+ | Symbol | Вид | Назначение |
2354
+ | ----------------------- | -------- | -------------------------------------- |
2355
+ | `QueryBroadcastOptions` | type | Global query/mutation error callbacks. |
2356
+ | `createQueryClient` | function | Создаёт новый настроенный QueryClient. |
2357
+
2358
+ ### Query metadata
2359
+
2360
+ | Symbol | Вид | Назначение |
2361
+ | ------------------------ | ----- | ----------------------------------------------- |
2362
+ | `AppQueryMeta` | type | `persist`, `sessionScoped` и consumer metadata. |
2363
+ | `persistedQueryMeta` | const | Opt-in public persistence marker. |
2364
+ | `sessionScopedQueryMeta` | const | Session reset marker. |
2365
+
2366
+ ### Persistence project adapter
2367
+
2368
+ | Symbol | Вид | Назначение |
2369
+ | ----------------------------------------- | -------- | ------------------------------------ |
2370
+ | `QueryPersistenceProjectAdapter` | type | Buster и system identifier resolver. |
2371
+ | `configureQueryPersistenceProjectAdapter` | function | Заменяет global adapter. |
2372
+ | `getQueryPersistenceProjectAdapter` | function | Возвращает текущий adapter. |
2373
+
2374
+ ### IndexedDB persistence
2375
+
2376
+ | Symbol | Вид | Назначение |
2377
+ | --------------------------------- | -------- | ----------------------------------------- |
2378
+ | `REACT_QUERY_PERSISTENCE_BUSTER` | const | Build-time default buster. |
2379
+ | `REACT_QUERY_PERSISTENCE_MAX_AGE` | const | Persistent max age: 90 дней. |
2380
+ | `shouldPersistQuery` | function | Opt-in predicate с session safety. |
2381
+ | `createIndexedDbQueryStorage` | function | AsyncStorage поверх IndexedDB. |
2382
+ | `createReactQueryPersister` | function | Настроенный per-query TanStack persister. |
2383
+
2384
+ Options type `createIndexedDbQueryStorage` не экспортируется отдельным symbol; используйте структурный object или `Parameters<typeof createIndexedDbQueryStorage>[0]`.
2385
+
2386
+ ### Broadcast
2387
+
2388
+ | Symbol | Вид | Назначение |
2389
+ | ---------------------------- | -------- | --------------------------------------- |
2390
+ | `installReactQueryBroadcast` | function | Устанавливает data + event channels. |
2391
+ | `setBroadcastFn` | function | Устанавливает global imperative sender. |
2392
+ | `broadcastCacheEvent` | function | Отправляет event через текущий sender. |
2393
+
2394
+ `CacheSyncEvent`, installation options и result types не экспортируются отдельными именами. Они доступны через inference:
2395
+
2396
+ ```ts
2397
+ type BroadcastEvent = Parameters<typeof broadcastCacheEvent>[0];
2398
+ type BroadcastOptions = Parameters<typeof installReactQueryBroadcast>[1];
2399
+ type InstalledBroadcast = ReturnType<typeof installReactQueryBroadcast>;
2400
+ ```
2401
+
2402
+ ### Session-scoped cache
2403
+
2404
+ | Symbol | Вид | Назначение |
2405
+ | --------------------------------------- | -------- | ----------------------------------------- |
2406
+ | `ResetSessionScopedQueriesOptions` | type | Управляет broadcast reset. |
2407
+ | `InstallSessionScopedQueryResetOptions` | type | Имя command channel. |
2408
+ | `InstalledSessionScopedQueryReset` | type | Cleanup contract. |
2409
+ | `isSessionScopedQuery` | function | Predicate по query meta. |
2410
+ | `resetSessionScopedQueries` | function | Cancel/clear/refetch session query. |
2411
+ | `installSessionScopedQueryReset` | function | Межвкладочная reset-command installation. |
2412
+
2413
+ ### Optimistic update
2414
+
2415
+ | Symbol | Вид | Назначение |
2416
+ | ---------------------------- | -------------- | ------------------------------------ |
2417
+ | `OptimisticContext` | type | Previous cache snapshot. |
2418
+ | `OnSettledOptimisticOptions` | type | Invalidation refetchType. |
2419
+ | `onMutateOptimistic` | async function | Cancel, snapshot и optimistic patch. |
2420
+ | `onErrorOptimistic` | function | Rollback defined previous snapshot. |
2421
+ | `onSuccessOptimistic` | function | Merge server data в текущий cache. |
2422
+ | `onSettledOptimistic` | async function | Invalidate один/несколько keys. |
2423
+
2424
+ ## Частые вопросы
2425
+
2426
+ ### `createQueryClient` действительно singleton?
2427
+
2428
+ Нет. Функция создаёт новый client при каждом вызове. Host должен вызвать её один раз и экспортировать instance.
2429
+
2430
+ ### Нужно ли помечать каждую query `persistedQueryMeta`?
2431
+
2432
+ Нет. Persistence строго opt-in и подходит только проверенным публичным долгоживущим data.
2433
+
2434
+ ### Почему query с `persistedQueryMeta` не появилась в IndexedDB?
2435
+
2436
+ Проверьте:
2437
+
2438
+ - доступен ли IndexedDB;
2439
+ - query завершилась ли успешно;
2440
+ - meta действительно `persist: true`;
2441
+ - нет ли `sessionScoped: true`;
2442
+ - не возникла ли quota/transaction error;
2443
+ - вызывается ли query через client с подключённым default persister;
2444
+ - под каким dbName/system identifier открыт storage.
2445
+
2446
+ ### Почему после restore всё равно идёт request?
2447
+
2448
+ `refetchOnRestore: true` обновляет восстановленную stale query. Проверьте `staleTime`, возраст data, buster и maxAge.
2449
+
2450
+ ### Можно ли persist permissions?
2451
+
2452
+ Нет. Permissions зависят от session/role. Используйте `sessionScopedQueryMeta` и reset при смене сессии.
2453
+
2454
+ ### Чем `sessionScopedQueryMeta` отличается от `gcTime: 0`?
2455
+
2456
+ `gcTime` управляет garbage collection неактивной query. Session reset является явной security/lifecycle boundary: cancel, очистка old data, refetch active query и команда другим вкладкам.
2457
+
2458
+ ### Нужно ли вручную broadcast обычный `setQueryData`?
2459
+
2460
+ Не всегда. Установленный TanStack broadcast синхронизирует cache data. Manual event используйте только для explicit policy.
2461
+
2462
+ ### Почему другая вкладка только стала stale, но не сделала request?
2463
+
2464
+ Automatic invalidation использует `refetchType: "none"`. Для немедленного remote refetch отправьте explicit invalidate event с `"active"` или другой подходящей policy.
2465
+
2466
+ ### Почему broadcast не работает в другом domain?
2467
+
2468
+ BroadcastChannel ограничен одним origin и channel name.
2469
+
2470
+ ### Можно ли передавать Date в IndexedDB?
2471
+
2472
+ Browser structured clone поддерживает `Date`, но убедитесь, что consumer mapping и cache contract ожидают Date после restore. Для transport/domain snapshots часто предсказуемее serializable ISO string.
2473
+
2474
+ ### Почему rollback не удалил созданный optimistic item?
2475
+
2476
+ Если до mutation cache была `undefined`, standard context не отличает «entry отсутствовала» от undefined data и `onErrorOptimistic` ничего не делает. Добавьте explicit exact-key removal или заранее создайте empty cache snapshot.
2477
+
2478
+ ### Достаточно ли `cancelQueries` для параллельных mutation?
2479
+
2480
+ Нет. Оно отменяет matching queries/refetches, а не mutation. Параллельные writes требуют serialization, mutation id, patch stack или отказа от optimistic update.
2481
+
2482
+ ### Нужно ли всегда вызывать `onSettledOptimistic`?
2483
+
2484
+ Не механически. Выберите cache strategy. Если server response полностью записан через `setQueryData` и invalidation не нужна, дополнительная invalidation может быть лишней. Если server мог изменить другие fields/data, invalidation полезна.
2485
+
2486
+ ### Что произойдёт без build-time buster?
2487
+
2488
+ Import entrypoint может завершиться `ReferenceError`, потому что constant читается на верхнем уровне. Настройте host bundler `define`.
2489
+
2490
+ ### Как очистить channels при HMR/test?
2491
+
2492
+ Сбросьте global broadcast handler, вызовите оба cleanup и очистите test QueryClient.
2493
+
2494
+ ## Связанные модули
2495
+
2496
+ - `@ryuzaki13/react-foundation-api/odata` — OData reads/mutations и TanStack integration.
2497
+ - `@ryuzaki13/react-foundation-api/http` — обычные HTTP query/mutation functions.
2498
+ - `@ryuzaki13/react-foundation-lib/odata-service` — чистые metadata/value/path/filter helpers.
2499
+ - `@ryuzaki13/react-foundation-lib/pwa` — Service Worker update и HTTP cache policy.
2500
+ - `@ryuzaki13/react-foundation-lib/error-report` — подготовка и диагностика error reports.