@ryuzaki13/react-foundation-api 1.1.16 → 1.1.18

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 (36) hide show
  1. package/README.md +32 -43
  2. package/dist/chunks/{odataFetchFn-vnAXC-c0.js → odataFetchFn-B9wSQpUS.js} +11 -11
  3. package/dist/chunks/{odataFetchFn-vnAXC-c0.js.map → odataFetchFn-B9wSQpUS.js.map} +1 -1
  4. package/dist/odata/fetchCollectionData.d.ts +1 -1
  5. package/dist/odata/fetchCollectionData.d.ts.map +1 -1
  6. package/dist/odata/index.js +111 -112
  7. package/dist/odata/index.js.map +1 -1
  8. package/dist/odata/projectODataCollectionSort.d.ts +1 -1
  9. package/dist/odata/projectODataCollectionSort.d.ts.map +1 -1
  10. package/dist/odata/types.d.ts +1 -2
  11. package/dist/odata/types.d.ts.map +1 -1
  12. package/dist/odata/useODataCollection.d.ts +1 -1
  13. package/dist/odata/useODataCollection.d.ts.map +1 -1
  14. package/dist/odata/useODataCollectionQuery.d.ts +1 -1
  15. package/dist/odata/useODataCollectionQuery.d.ts.map +1 -1
  16. package/dist/odata/useODataEntity.d.ts +1 -1
  17. package/dist/odata/useODataEntity.d.ts.map +1 -1
  18. package/dist/persisted/index.js +1 -1
  19. package/package.json +2 -2
  20. package/src/adt/README.mdx +461 -0
  21. package/src/async/README.mdx +628 -0
  22. package/src/error-report/README.mdx +471 -0
  23. package/src/foundationApi.mdx +123 -0
  24. package/src/http/README.mdx +570 -0
  25. package/src/odata/README.mdx +5142 -0
  26. package/src/persisted/README.mdx +1080 -0
  27. package/src/resource/README.mdx +820 -0
  28. package/src/server-fn/README.mdx +596 -0
  29. package/src/transport/README.mdx +528 -0
  30. package/src/README.md +0 -937
  31. package/src/async/README.md +0 -623
  32. package/src/async/async.mdx +0 -6
  33. package/src/odata/README.md +0 -761
  34. package/src/odata/odataFetchFn.mdx +0 -6
  35. package/src/persisted/README.md +0 -598
  36. package/src/persisted/persisted.mdx +0 -6
@@ -0,0 +1,820 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation API/Query/Generic Resource" />
4
+
5
+ # Generic resources через `@ryuzaki13/react-foundation-api/resource`
6
+
7
+ Модуль описывает transport-agnostic ресурс поверх TanStack Query: стабильные query keys, проверку scope, произвольные read/write operations и cache strategies после mutation.
8
+
9
+ ## Для кого этот документ
10
+
11
+ Здесь термины используются так:
12
+
13
+ - **resource** — технически связанная группа данных, например `orders`;
14
+ - **scope** — контекст, внутри которого данные уникальны, например `{ companyId }`;
15
+ - **query/read operation** — чтение без изменения server state;
16
+ - **mutation/write operation** — создание/изменение/удаление;
17
+ - **descriptor** — объект, объединяющий identity, operations и правила доступности;
18
+ - **query key** — сериализуемый identity записи в TanStack Query cache.
19
+
20
+ Модуль не выполняет HTTP/OData сам. Transport находится внутри `operation.execute` или создаётся adapter-ом `/server-fn`.
21
+
22
+ ## Когда использовать
23
+
24
+ Используйте `/resource`, когда названия операций произвольны: `search`, `detail`, `archive`, `recalculate`.
25
+
26
+ Для стандартной модели сохранённых записей `list/latest/history/save/create/delete` удобнее [`/persisted`](../persisted/README.mdx).
27
+
28
+ Не используйте `/resource`, если нужен один приватный request в одной feature и descriptor не даёт повторного использования. Абстракция полезна, когда resource имеет устойчивое имя, несколько consumers/operations и общую cache policy.
29
+
30
+ ## Ментальная модель
31
+
32
+ ```text
33
+ Descriptor
34
+ ├── namespace + resource ───────────────► base query key
35
+ ├── scope policy ───────────────────────► можно ли выполнять operation
36
+ ├── query operations
37
+ │ ├── search(args) ───────────────────► Promise<SearchResult>
38
+ │ └── detail(args) ───────────────────► Promise<Order>
39
+ └── mutation operations
40
+ ├── save(input) ────────────────────► Promise<Order>
41
+ └── archive(input) ─────────────────► Promise<void>
42
+
43
+
44
+ cache strategy
45
+ ```
46
+
47
+ Descriptor не хранит данные. Данные хранятся в QueryClient по keys descriptor-а.
48
+
49
+ ### Что является identity
50
+
51
+ Для query:
52
+
53
+ ```text
54
+ namespace + resource + normalized scope + operation name + normalized args
55
+ ```
56
+
57
+ Для mutation key:
58
+
59
+ ```text
60
+ namespace + resource + normalized scope + operation name
61
+ ```
62
+
63
+ Mutation input в key не входит. Он передаётся `mutationFn` в момент `mutate(input)`.
64
+
65
+ ### Query cache — server snapshot
66
+
67
+ Query data — копия последнего известного server state. Не редактируйте её как form draft:
68
+
69
+ ```ts
70
+ // Нельзя: in-place mutation cache object.
71
+ query.data.title = draftTitle;
72
+ ```
73
+
74
+ Draft формы должен быть отдельным state. После save cache synchronizes через strategy.
75
+
76
+ ## Установка и provider
77
+
78
+ ```bash
79
+ npm install @ryuzaki13/react-foundation-api @ryuzaki13/react-foundation-lib @tanstack/react-query react
80
+ ```
81
+
82
+ React hooks работают только внутри `QueryClientProvider`:
83
+
84
+ ```tsx
85
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
86
+
87
+ const queryClient = new QueryClient();
88
+
89
+ export function App() {
90
+ return <QueryClientProvider client={queryClient}>{/* routes */}</QueryClientProvider>;
91
+ }
92
+ ```
93
+
94
+ Создайте client один раз, а не внутри render.
95
+
96
+ ## Импорт
97
+
98
+ ```ts
99
+ import {
100
+ applyResourceCacheStrategy,
101
+ buildResourceQueryOptions,
102
+ composeResourceCacheStrategies,
103
+ createInvalidateResourceScopeCacheStrategy,
104
+ createResourceDescriptor,
105
+ createResourceKeys,
106
+ createResourceMutationOperation,
107
+ createResourceQueryOperation,
108
+ createSetResourceQueryDataCacheStrategy,
109
+ getResourceQueryData,
110
+ normalizeResourceKeyValue,
111
+ useResourceMutation,
112
+ useResourceQuery
113
+ } from "@ryuzaki13/react-foundation-api/resource";
114
+ ```
115
+
116
+ Типы экспортируются из того же subpath.
117
+
118
+ ## Пошаговый пример
119
+
120
+ ### 1. Описать scope и данные
121
+
122
+ ```ts
123
+ type OrdersScope = {
124
+ companyId: string;
125
+ };
126
+
127
+ type SearchOrdersArgs = {
128
+ search: string;
129
+ limit: number;
130
+ };
131
+
132
+ type Order = {
133
+ id: string;
134
+ title: string;
135
+ };
136
+
137
+ type SaveOrderInput = {
138
+ id: string;
139
+ title: string;
140
+ };
141
+ ```
142
+
143
+ ### 2. Создать operations
144
+
145
+ ```ts
146
+ const searchOperation = createResourceQueryOperation<
147
+ OrdersScope,
148
+ SearchOrdersArgs,
149
+ Order[]
150
+ >({
151
+ async execute({ scope, args, signal }) {
152
+ const query = new URLSearchParams({
153
+ companyId: scope.companyId,
154
+ search: args.search,
155
+ limit: String(args.limit)
156
+ });
157
+
158
+ const response = await fetch(`/api/orders?${query}`, { signal });
159
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
160
+ return response.json() as Promise<Order[]>;
161
+ },
162
+ isEnabled: (scope, args) => Boolean(scope?.companyId && args.limit > 0),
163
+ staleTime: 60_000,
164
+ gcTime: 5 * 60_000
165
+ });
166
+
167
+ const saveOperation = createResourceMutationOperation<
168
+ OrdersScope,
169
+ SaveOrderInput,
170
+ Order,
171
+ unknown
172
+ >({
173
+ async execute({ scope, input }) {
174
+ const response = await fetch(`/api/companies/${scope.companyId}/orders/${input.id}`, {
175
+ method: "PUT",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify(input)
178
+ });
179
+
180
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
181
+ return response.json() as Promise<Order>;
182
+ }
183
+ });
184
+ ```
185
+
186
+ Factories `createResourceQueryOperation` и `createResourceMutationOperation` не изменяют объект. Они помогают TypeScript сохранить generic types.
187
+
188
+ Они также не добавляют retry, validation, transport headers или cache behavior. Всё перечисленное должно быть явно в operation/adapter/QueryClient policy.
189
+
190
+ ### Query operation contract
191
+
192
+ ```ts
193
+ interface ResourceQueryOperation<TScope, TArgs, TResult> {
194
+ execute(context: {
195
+ scope: TScope;
196
+ args: TArgs;
197
+ client: QueryClient;
198
+ signal?: AbortSignal;
199
+ }): Promise<TResult>;
200
+ isEnabled?(scope: TScope | null | undefined, args: TArgs): boolean;
201
+ readonly staleTime?: number;
202
+ readonly gcTime?: number;
203
+ }
204
+ ```
205
+
206
+ `client` позволяет operation переиспользовать другие query snapshots или metadata. Не создавайте новый QueryClient внутри execute.
207
+
208
+ `signal` нужно передавать transport-у:
209
+
210
+ ```ts
211
+ const response = await fetch(url, { signal });
212
+ ```
213
+
214
+ ### Mutation operation contract
215
+
216
+ ```ts
217
+ interface ResourceMutationOperation<TScope, TInput, TResult, TDescriptor> {
218
+ execute(context: {
219
+ scope: TScope;
220
+ input: TInput;
221
+ client: QueryClient;
222
+ }): Promise<TResult>;
223
+ readonly cacheStrategy?: ResourceCacheStrategy<
224
+ TScope,
225
+ TInput,
226
+ TResult,
227
+ TDescriptor
228
+ >;
229
+ }
230
+ ```
231
+
232
+ Generic mutation contract не содержит AbortSignal. Не обещайте automatic cancellation mutation.
233
+
234
+ ### 3. Создать descriptor
235
+
236
+ ```ts
237
+ const ordersResource = createResourceDescriptor({
238
+ namespace: "sales",
239
+ resource: "orders",
240
+ normalizeScope: (scope: OrdersScope | null | undefined) => ({
241
+ companyId: scope?.companyId.trim() ?? ""
242
+ }),
243
+ operations: {
244
+ queries: { search: searchOperation },
245
+ mutations: { save: saveOperation }
246
+ },
247
+ isEnabled: (scope) => Boolean(scope?.companyId),
248
+ getScopeError: () => "Не выбрана компания"
249
+ });
250
+ ```
251
+
252
+ `namespace + resource` должны быть устойчивыми и уникальными. Не включайте локализованные labels или случайные значения.
253
+
254
+ `normalizeScope` влияет только на query key. В `execute` приходит исходный literal scope. Например, key получит trimmed `companyId`, но operation увидит строку ровно в переданном object. Это позволяет нормализовать identity, не переписывая draft/input.
255
+
256
+ ### Поля descriptor-а
257
+
258
+ | Поле | Назначение |
259
+ | --- | --- |
260
+ | `namespace` | Верхний технический namespace keys, например `sales` |
261
+ | `resource` | Устойчивое имя resource, например `orders` |
262
+ | `operations.queries` | Record read operations с произвольными names |
263
+ | `operations.mutations` | Record write operations |
264
+ | `keys` | Custom key factory или generated factory |
265
+ | `normalizeScope` | Настройка generated key factory |
266
+ | `isEnabled` | Общая доступность resource scope |
267
+ | `getScopeError` | Error message для фактического execute invalid scope |
268
+
269
+ Operations можно опустить с одной стороны:
270
+
271
+ ```ts
272
+ createResourceDescriptor({
273
+ namespace: "catalog",
274
+ resource: "countries",
275
+ operations: {
276
+ queries: { list: listCountriesOperation }
277
+ }
278
+ });
279
+ ```
280
+
281
+ В runtime отсутствующая группа превращается в пустой object.
282
+
283
+ ### Descriptor создаётся один раз
284
+
285
+ ```ts
286
+ // На уровне module — правильно.
287
+ export const ordersResource = createResourceDescriptor(...);
288
+ ```
289
+
290
+ Не создавайте его внутри component render: descriptor и callbacks должны иметь устойчивую identity, а module-level definition проще тестировать и переиспользовать.
291
+
292
+ ### 4. Использовать query hook
293
+
294
+ ```tsx
295
+ function OrdersList({ companyId, search }: Props) {
296
+ const query = useResourceQuery(
297
+ ordersResource,
298
+ "search",
299
+ { companyId },
300
+ { search, limit: 50 }
301
+ );
302
+
303
+ if (query.isPending) return <p>Загрузка…</p>;
304
+ if (query.isError) return <p>Ошибка загрузки</p>;
305
+
306
+ return query.data.map((order) => <div key={order.id}>{order.title}</div>);
307
+ }
308
+ ```
309
+
310
+ Rules of Hooks применяются полностью: hook нельзя вызывать условно или в callback.
311
+
312
+ ### 5. Использовать mutation hook
313
+
314
+ ```tsx
315
+ const mutation = useResourceMutation(
316
+ ordersResource,
317
+ "save",
318
+ { companyId },
319
+ {
320
+ onSuccess(savedOrder) {
321
+ showSuccess(`Сохранён ${savedOrder.title}`);
322
+ }
323
+ }
324
+ );
325
+
326
+ mutation.mutate({ id: "42", title: "Новый заголовок" });
327
+ ```
328
+
329
+ Mutation key содержит resource/scope/operation, но не input. Input передаётся только в `mutationFn`.
330
+
331
+ ### Несколько одновременных mutations
332
+
333
+ Две `save` mutations одного scope имеют одинаковый mutation key, но разные inputs и отдельные mutation instances. Mutation key группирует operations для наблюдения/diagnostics; это не Query cache slot с data.
334
+
335
+ ### Scope проверяется при `mutate`
336
+
337
+ Hook можно создать с nullable scope, но actual mutation вызовет `assertResourceScope`. Если scope invalid, server execute не начнётся и mutation получит error.
338
+
339
+ ## Query lifecycle
340
+
341
+ `buildResourceQueryOptions(descriptor, name, scope, args)` создаёт TanStack `queryOptions`:
342
+
343
+ - key из `descriptor.keys.operation`;
344
+ - query function с `{ scope, args, client, signal }`;
345
+ - `enabled` из descriptor и operation;
346
+ - `staleTime`/`gcTime` из operation.
347
+
348
+ ```ts
349
+ const options = buildResourceQueryOptions(
350
+ ordersResource,
351
+ "search",
352
+ { companyId: "1000" },
353
+ { search: "", limit: 50 }
354
+ );
355
+ ```
356
+
357
+ `enabled` сначала проверяет `descriptor.isEnabled(scope)`, затем `operation.isEnabled(scope, args)`. Если одна проверка вернула `false`, React query не стартует автоматически.
358
+
359
+ Перед фактическим `execute` scope обязан быть не `null`/`undefined` и пройти descriptor policy. Иначе выбрасывается `getScopeError` или стандартная ошибка.
360
+
361
+ ### Критическое правило nullable scope
362
+
363
+ Если scope может быть `null`/`undefined`, descriptor обязан иметь `isEnabled`:
364
+
365
+ ```ts
366
+ isEnabled: (scope) => Boolean(scope?.companyId)
367
+ ```
368
+
369
+ Без него default enabled равен `true`, даже для `undefined`. TanStack Query попытается запустить query, а query function выбросит scope error.
370
+
371
+ Operation-level `isEnabled` не заменяет общую scope policy, хотя тоже может проверить scope. Хорошая структура:
372
+
373
+ ```ts
374
+ isEnabled: (scope) => Boolean(scope?.companyId),
375
+
376
+ // В operation:
377
+ isEnabled: (_scope, args) => args.limit > 0 && args.search.length >= 2
378
+ ```
379
+
380
+ ### Стандартная scope error
381
+
382
+ Если custom `getScopeError` отсутствует:
383
+
384
+ ```text
385
+ Недостаточно данных scope для ресурса 'orders'.
386
+ ```
387
+
388
+ `getScopeError` вызывается только при попытке actual execution invalid scope. Возвращаемое `null` приводит к standard fallback из-за `??`.
389
+
390
+ ### Отсутствующая operation
391
+
392
+ При неверном runtime descriptor-е error возникает до transport:
393
+
394
+ ```text
395
+ Ресурс 'orders' не поддерживает read-операцию 'detail'.
396
+ ```
397
+
398
+ Runtime guard проверяет object и callable `execute`. TypeScript обычно ловит неверное name раньше, если descriptor types сохранены.
399
+
400
+ ### `staleTime` и `gcTime`
401
+
402
+ Значения просто передаются TanStack Query:
403
+
404
+ - `staleTime` не является HTTP TTL;
405
+ - `gcTime` отсчитывается для неактивного query;
406
+ - отсутствие значения означает default QueryClient policy;
407
+ - они не влияют на mutation.
408
+
409
+ ## Императивная загрузка
410
+
411
+ ```ts
412
+ const orders = await getResourceQueryData(
413
+ ordersResource,
414
+ "search",
415
+ { companyId: "1000" },
416
+ { search: "", limit: 50 },
417
+ queryClient
418
+ );
419
+ ```
420
+
421
+ Функция использует `queryClient.fetchQuery`, поэтому переиспользует query cache. Передавайте только валидный scope: imperative `fetchQuery` нельзя воспринимать как disabled React observer; query function всё равно защищает scope и может бросить ошибку.
422
+
423
+ ### Loader/preload
424
+
425
+ ```ts
426
+ export async function loadOrdersRoute({
427
+ companyId,
428
+ queryClient
429
+ }: {
430
+ companyId: string;
431
+ queryClient: QueryClient;
432
+ }) {
433
+ return getResourceQueryData(
434
+ ordersResource,
435
+ "search",
436
+ { companyId },
437
+ { search: "", limit: 50 },
438
+ queryClient
439
+ );
440
+ }
441
+ ```
442
+
443
+ Component с теми же exact inputs получит тот же query key и переиспользует snapshot.
444
+
445
+ ## Query keys
446
+
447
+ ```ts
448
+ const keys = createResourceKeys<OrdersScope, "search" | "save">({
449
+ namespace: "sales",
450
+ resource: "orders",
451
+ normalizeScope: (scope) => ({ companyId: scope?.companyId ?? "" })
452
+ });
453
+
454
+ keys.all;
455
+ // ["sales", "orders"]
456
+
457
+ keys.scope({ companyId: "1000" });
458
+ // ["sales", "orders", { companyId: "1000" }]
459
+
460
+ keys.operation("search", { companyId: "1000" }, { limit: 50 });
461
+ // ["sales", "orders", { companyId: "1000" }, "search", { limit: 50 }]
462
+ ```
463
+
464
+ `args === undefined` не добавляется в key. `args === null` добавляется как `null`.
465
+
466
+ ### `normalizeResourceKeyValue`
467
+
468
+ Нормализация рекурсивна:
469
+
470
+ - `null` и `undefined` → `null`;
471
+ - string → `trim()`;
472
+ - number/boolean остаются без изменения;
473
+ - array нормализуется по элементам, порядок сохраняется;
474
+ - object keys сортируются, значения нормализуются;
475
+ - другие значения превращаются через `String(value)`.
476
+
477
+ ```ts
478
+ normalizeResourceKeyValue({ b: " x ", a: undefined });
479
+ // { a: null, b: "x" }
480
+ ```
481
+
482
+ Ограничения:
483
+
484
+ - `Date`, `Map`, `Set` и class instances не имеют специальной сериализации; обычный `Date` превратится в `{}`;
485
+ - cyclic object приведёт к рекурсивной ошибке;
486
+ - `NaN`/`Infinity` остаются number, но их cache semantics могут быть неочевидны;
487
+ - function/symbol/bigint превращаются в строки и могут collision-иться.
488
+
489
+ Нормализуйте специальные значения сами, например Date → ISO string. Query key не должен содержать secrets, DOM nodes или огромные payload.
490
+
491
+ ### Дополнительные примеры normalization
492
+
493
+ ```ts
494
+ normalizeResourceKeyValue(undefined);
495
+ // null
496
+
497
+ normalizeResourceKeyValue([" a ", undefined, false]);
498
+ // ["a", null, false]
499
+
500
+ normalizeResourceKeyValue({ z: 1, a: { b: " x " } });
501
+ // { a: { b: "x" }, z: 1 }
502
+
503
+ normalizeResourceKeyValue(new Date("2026-08-21T00:00:00Z"));
504
+ // {} — Date не поддерживается специально
505
+ ```
506
+
507
+ ### Object key order
508
+
509
+ Эти scopes создадут одинаковую normalized identity:
510
+
511
+ ```ts
512
+ { companyId: "1000", plant: "1100" }
513
+ { plant: "1100", companyId: "1000" }
514
+ ```
515
+
516
+ Object keys сортируются рекурсивно. Array order при этом значим:
517
+
518
+ ```ts
519
+ ["1000", "2000"] !== ["2000", "1000"]
520
+ ```
521
+
522
+ Если array представляет set, отсортируйте его в `normalizeScope`/до args.
523
+
524
+ ### Custom keys
525
+
526
+ Можно передать полностью свою `ResourceKeys`, например для совместимости с существующей cache taxonomy. Factory обязана сохранять prefix semantics:
527
+
528
+ ```ts
529
+ const keys = createResourceKeys<OrdersScope, "search" | "save">({
530
+ namespace: "sales-v2",
531
+ resource: "orders"
532
+ });
533
+
534
+ createResourceDescriptor({
535
+ // ...
536
+ keys
537
+ });
538
+ ```
539
+
540
+ Не собирайте key ad hoc в cache strategy: используйте `descriptor.keys`.
541
+
542
+ ## Cache strategies после mutation
543
+
544
+ ### Инвалидация scope
545
+
546
+ ```ts
547
+ const invalidateScope = createInvalidateResourceScopeCacheStrategy();
548
+ ```
549
+
550
+ Стратегия вызывает `client.invalidateQueries` по `descriptor.keys.scope(scope)`. Все query этого resource/scope становятся stale и активные observers могут refetch.
551
+
552
+ ### Точечный `setQueryData`
553
+
554
+ ```ts
555
+ const updateDetail = createSetResourceQueryDataCacheStrategy<
556
+ OrdersScope,
557
+ SaveOrderInput,
558
+ Order,
559
+ Order[],
560
+ typeof ordersResource
561
+ >({
562
+ getQueryKey: ({ descriptor, scope }) =>
563
+ descriptor.keys.operation("search", scope, { search: "", limit: 50 }),
564
+ update: (current = [], { result }) =>
565
+ current.map((order) => (order.id === result.id ? result : order))
566
+ });
567
+ ```
568
+
569
+ `update` получает текущее cache data или `undefined`, а также descriptor/scope/input/result/client. Оно должно возвращать новое значение и не мутировать старое.
570
+
571
+ ### Композиция
572
+
573
+ ```ts
574
+ const strategy = composeResourceCacheStrategies(
575
+ updateDetail,
576
+ invalidateScope
577
+ );
578
+ ```
579
+
580
+ Strategies выполняются последовательно в переданном порядке. Если одна бросила ошибку, следующие и custom `onSuccess` mutation hook не выполнятся; сама server mutation при этом уже могла успешно завершиться.
581
+
582
+ ### Override в hook
583
+
584
+ ```ts
585
+ useResourceMutation(descriptor, "save", scope, {
586
+ cacheStrategy: undefined // Использовать strategy операции.
587
+ });
588
+
589
+ useResourceMutation(descriptor, "save", scope, {
590
+ cacheStrategy: null // Полностью отключить strategy операции.
591
+ });
592
+ ```
593
+
594
+ Переданная strategy заменяет operation strategy, а не дополняет её. Для объединения используйте `composeResourceCacheStrategies`.
595
+
596
+ `applyResourceCacheStrategy(strategy, context)` — низкоуровневый helper для ручного применения optional strategy.
597
+
598
+ ### Полный cache context
599
+
600
+ ```ts
601
+ interface ResourceCacheStrategyContext<TScope, TInput, TResult, TDescriptor> {
602
+ readonly client: QueryClient;
603
+ readonly descriptor: TDescriptor;
604
+ readonly scope: TScope;
605
+ readonly input: TInput;
606
+ readonly result: TResult;
607
+ }
608
+ ```
609
+
610
+ Strategy имеет все данные для точного key/update, но не должна содержать domain network side effects. Server write уже завершён.
611
+
612
+ ### Порядок mutation success
613
+
614
+ ```text
615
+ operation.execute success
616
+
617
+
618
+ resolved scope повторно проверяется
619
+
620
+
621
+ selected cache strategy
622
+ │ await
623
+
624
+ hook options.onSuccess(result, input)
625
+ ```
626
+
627
+ `options.cacheStrategy` выбирается так:
628
+
629
+ | Значение | Поведение |
630
+ | --- | --- |
631
+ | `undefined` | Использовать strategy operation |
632
+ | `null` | Не выполнять strategy |
633
+ | object | Полностью заменить operation strategy |
634
+
635
+ ### Invalidate или set data
636
+
637
+ Используйте invalidation, если server:
638
+
639
+ - вычисляет дополнительные fields;
640
+ - меняет сортировку/агрегации;
641
+ - write затрагивает несколько неизвестных queries.
642
+
643
+ Используйте `setQueryData`, если response содержит authoritative готовую запись и точный key известен.
644
+
645
+ Композиция полезна, когда detail можно обновить сразу, а lists безопаснее refetch-нуть.
646
+
647
+ ### Current data может быть `undefined`
648
+
649
+ Updater обязан это учесть:
650
+
651
+ ```ts
652
+ update: (current, { result }) => {
653
+ if (!current) return [result];
654
+ return current.map((item) => item.id === result.id ? result : item);
655
+ }
656
+ ```
657
+
658
+ Возвращайте новое значение. Не делайте `current.push(...)`.
659
+
660
+ ### Ошибка cache strategy после server success
661
+
662
+ Server mutation уже могла примениться. Если strategy бросила error:
663
+
664
+ - следующие composed strategies не выполняются;
665
+ - hook custom `onSuccess` не вызывается;
666
+ - caller может получить rejected async lifecycle.
667
+
668
+ Не повторяйте write автоматически только из-за cache error: это может создать duplicate. Разделяйте retry server operation и cache recovery.
669
+
670
+ ## Transport adapters
671
+
672
+ Generic operation может использовать:
673
+
674
+ - `/http` для обычного REST;
675
+ - `/odata` для metadata-aware SAP operation;
676
+ - `/server-fn` для функций `{ data }`;
677
+ - тестовый in-memory executor.
678
+
679
+ Resource не должен знать бизнес transport автоматически. Это позволяет одному descriptor pattern работать с разными infrastructures без смешивания contracts.
680
+
681
+ ## Тестирование
682
+
683
+ ### Key factory
684
+
685
+ ```ts
686
+ it("нормализует scope и args", () => {
687
+ const key = ordersResource.keys.operation(
688
+ "search",
689
+ { companyId: " 1000 " },
690
+ { limit: 50, search: " test " }
691
+ );
692
+
693
+ expect(key).toEqual([
694
+ "sales",
695
+ "orders",
696
+ { companyId: "1000" },
697
+ "search",
698
+ { limit: 50, search: "test" }
699
+ ]);
700
+ });
701
+ ```
702
+
703
+ ### Query operation context
704
+
705
+ ```ts
706
+ it("передаёт scope, args, client и signal", async () => {
707
+ const execute = vi.fn().mockResolvedValue([]);
708
+ const operation = createResourceQueryOperation({ execute });
709
+ const descriptor = createResourceDescriptor({
710
+ namespace: "test",
711
+ resource: "orders",
712
+ operations: { queries: { list: operation } },
713
+ isEnabled: (scope) => Boolean(scope)
714
+ });
715
+
716
+ await queryClient.fetchQuery(
717
+ buildResourceQueryOptions(descriptor, "list", "scope-1", {})
718
+ );
719
+
720
+ expect(execute).toHaveBeenCalledWith(
721
+ expect.objectContaining({
722
+ scope: "scope-1",
723
+ args: {},
724
+ client: queryClient,
725
+ signal: expect.any(AbortSignal)
726
+ })
727
+ );
728
+ });
729
+ ```
730
+
731
+ Покройте:
732
+
733
+ - nullable/invalid scope disabled;
734
+ - custom scope error при imperative execution;
735
+ - descriptor + operation enabled policies;
736
+ - missing operation runtime error;
737
+ - same normalized inputs → same key;
738
+ - meaningful inputs → different keys;
739
+ - signal передаётся query transport;
740
+ - mutation success strategy order;
741
+ - `undefined`/`null`/override strategy semantics;
742
+ - setQueryData не мутирует old snapshot;
743
+ - composed strategy прекращается на error.
744
+
745
+ ## Частые ошибки
746
+
747
+ ### Descriptor без `isEnabled` при nullable scope
748
+
749
+ Query будет enabled и затем упадёт на scope assertion.
750
+
751
+ ### Date прямо в args
752
+
753
+ Default normalizer превратит Date в `{}`. Передавайте ISO string.
754
+
755
+ ### Нормализовать scope и ожидать изменённый execute input
756
+
757
+ `normalizeScope` меняет только key identity.
758
+
759
+ ### Не включить args, влияющие на response
760
+
761
+ Factory включает переданный args целиком. Не прячьте server filter во внешнем closure, отсутствующем в args/key.
762
+
763
+ ### Создавать descriptor в render
764
+
765
+ Держите его на module boundary.
766
+
767
+ ### Повторить mutation после cache error
768
+
769
+ Server write уже мог завершиться. Восстановите cache отдельно.
770
+
771
+ ### Deep import operation type
772
+
773
+ Используйте public subpath `/resource`.
774
+
775
+ ## Ошибки и границы
776
+
777
+ - Отсутствующая operation даёт раннюю ошибку с именем resource/operation.
778
+ - Невалидный scope блокирует hook через `enabled`, но ручное выполнение бросает ошибку.
779
+ - Error transport-а сохраняется TanStack Query без автоматического преобразования.
780
+ - Descriptor лучше создавать на уровне module, чтобы ссылка не менялась на каждом render.
781
+ - Args/scope должны быть сериализуемыми и описывать все данные, влияющие на query result.
782
+ - Не используйте query cache как authoritative persistent storage.
783
+ - Cache strategy выполняется только после успешной mutation.
784
+
785
+ ## FAQ
786
+
787
+ ### Resource сам выполняет HTTP?
788
+
789
+ Нет. Transport находится в `operation.execute` или adapter factory.
790
+
791
+ ### Может ли scope быть primitive?
792
+
793
+ Да: string/number и другие типы разрешены generic-ом. Он должен устойчиво описывать tenant/user/entity boundary.
794
+
795
+ ### Почему args входят в query key автоматически?
796
+
797
+ Потому что разные args обычно означают разные server snapshots.
798
+
799
+ ### Почему mutation input не входит в mutation key?
800
+
801
+ Mutation key описывает семейство operation, а конкретный input хранится в mutation instance execution state.
802
+
803
+ ### Как отключить operation cache strategy для одного hook?
804
+
805
+ Передать `cacheStrategy: null`.
806
+
807
+ ### Можно ли использовать без React hook?
808
+
809
+ Queries — через `getResourceQueryData`/`buildResourceQueryOptions`. Для mutations public convenience API сейчас hook-oriented; operation можно orchestrate на корректной owner boundary.
810
+
811
+ ## Полный API
812
+
813
+ | Группа | Exports |
814
+ | --- | --- |
815
+ | Descriptor/operations | `createResourceDescriptor`, `createResourceQueryOperation`, `createResourceMutationOperation` |
816
+ | Query/mutation | `buildResourceQueryOptions`, `useResourceQuery`, `getResourceQueryData`, `useResourceMutation` |
817
+ | Keys | `normalizeResourceKeyValue`, `createResourceKeys`, `ResourceKeyValue`, `ResourceKeys` |
818
+ | Cache | `applyResourceCacheStrategy`, `createInvalidateResourceScopeCacheStrategy`, `createSetResourceQueryDataCacheStrategy`, `composeResourceCacheStrategies` |
819
+ | Contracts | `ResourceQueryOperationContext`, `ResourceMutationOperationContext`, `ResourceQueryOperation`, `ResourceMutationOperation`, `ResourceDescriptor`, `CreateResourceDescriptorOptions`, `ResourceCacheStrategyContext`, `ResourceCacheStrategy`, `UseResourceMutationOptions`, `ResourceSetQueryDataStrategyOptions` |
820
+ | Type extraction | `ResourceQueryArgs`, `ResourceQueryResult`, `ResourceMutationInput`, `ResourceMutationResult` |