@ryuzaki13/react-foundation-api 1.1.17 → 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.
@@ -25,6 +25,54 @@ import { Meta } from "@storybook/addon-docs/blocks";
25
25
 
26
26
  Для стандартной модели сохранённых записей `list/latest/history/save/create/delete` удобнее [`/persisted`](../persisted/README.mdx).
27
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
+
28
76
  ## Установка и provider
29
77
 
30
78
  ```bash
@@ -137,6 +185,52 @@ const saveOperation = createResourceMutationOperation<
137
185
 
138
186
  Factories `createResourceQueryOperation` и `createResourceMutationOperation` не изменяют объект. Они помогают TypeScript сохранить generic types.
139
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
+
140
234
  ### 3. Создать descriptor
141
235
 
142
236
  ```ts
@@ -159,6 +253,42 @@ const ordersResource = createResourceDescriptor({
159
253
 
160
254
  `normalizeScope` влияет только на query key. В `execute` приходит исходный literal scope. Например, key получит trimmed `companyId`, но operation увидит строку ровно в переданном object. Это позволяет нормализовать identity, не переписывая draft/input.
161
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
+
162
292
  ### 4. Использовать query hook
163
293
 
164
294
  ```tsx
@@ -198,6 +328,14 @@ mutation.mutate({ id: "42", title: "Новый заголовок" });
198
328
 
199
329
  Mutation key содержит resource/scope/operation, но не input. Input передаётся только в `mutationFn`.
200
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
+
201
339
  ## Query lifecycle
202
340
 
203
341
  `buildResourceQueryOptions(descriptor, name, scope, args)` создаёт TanStack `queryOptions`:
@@ -220,6 +358,54 @@ const options = buildResourceQueryOptions(
220
358
 
221
359
  Перед фактическим `execute` scope обязан быть не `null`/`undefined` и пройти descriptor policy. Иначе выбрасывается `getScopeError` или стандартная ошибка.
222
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
+
223
409
  ## Императивная загрузка
224
410
 
225
411
  ```ts
@@ -234,6 +420,28 @@ const orders = await getResourceQueryData(
234
420
 
235
421
  Функция использует `queryClient.fetchQuery`, поэтому переиспользует query cache. Передавайте только валидный scope: imperative `fetchQuery` нельзя воспринимать как disabled React observer; query function всё равно защищает scope и может бросить ошибку.
236
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
+
237
445
  ## Query keys
238
446
 
239
447
  ```ts
@@ -280,6 +488,57 @@ normalizeResourceKeyValue({ b: " x ", a: undefined });
280
488
 
281
489
  Нормализуйте специальные значения сами, например Date → ISO string. Query key не должен содержать secrets, DOM nodes или огромные payload.
282
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
+
283
542
  ## Cache strategies после mutation
284
543
 
285
544
  ### Инвалидация scope
@@ -336,6 +595,183 @@ useResourceMutation(descriptor, "save", scope, {
336
595
 
337
596
  `applyResourceCacheStrategy(strategy, context)` — низкоуровневый helper для ручного применения optional strategy.
338
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
+
339
775
  ## Ошибки и границы
340
776
 
341
777
  - Отсутствующая operation даёт раннюю ошибку с именем resource/operation.
@@ -346,6 +782,32 @@ useResourceMutation(descriptor, "save", scope, {
346
782
  - Не используйте query cache как authoritative persistent storage.
347
783
  - Cache strategy выполняется только после успешной mutation.
348
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
+
349
811
  ## Полный API
350
812
 
351
813
  | Группа | Exports |