@ryuzaki13/react-foundation-api 1.1.17 → 1.1.19

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.
@@ -6,6 +6,18 @@ import { Meta } from "@storybook/addon-docs/blocks";
6
6
 
7
7
  `persisted` — специализированный слой поверх TanStack Query для данных, которые пользователь сохраняет и позднее восстанавливает: presets, variants, view configs и другие versioned records.
8
8
 
9
+ Слово `persisted` здесь означает backend-сущности «сохранённая запись». Это не то же самое, что persistence TanStack Query cache в IndexedDB.
10
+
11
+ ```text
12
+ Persisted record
13
+ пользователь нажал «Сохранить» → backend хранит вариант/конфигурацию
14
+
15
+ Persisted Query cache
16
+ infrastructure сохраняет временный server snapshot между reload
17
+ ```
18
+
19
+ Backend record остаётся source of truth. Query persistence, если настроена проектом, лишь ускоряет восстановление snapshot.
20
+
9
21
  ## Ментальная модель
10
22
 
11
23
  ```text
@@ -19,6 +31,21 @@ PersistedResourceDescriptor
19
31
 
20
32
  Capability optional: resource не обязан поддерживать все шесть операций. TypeScript разрешит соответствующий hook только когда operation присутствует в descriptor; runtime также выдаёт понятную ошибку при неправильном объекте.
21
33
 
34
+ ### Термины для начинающих
35
+
36
+ - **record** — сохранённая запись на backend;
37
+ - **payload** — полезная конфигурация внутри record, часто JSON string;
38
+ - **scope** — кому или чему принадлежат records: user, app, table, entity;
39
+ - **latest** — последний актуальный snapshot;
40
+ - **history** — версии/события с аргументами paging;
41
+ - **capability** — реально поддерживаемая operation;
42
+ - **transport adapter** — как capability выполняет REST/OData/serverFn request;
43
+ - **descriptor** — единая карта resource, keys, scope policy и capabilities.
44
+
45
+ ### Почему это не CRUD
46
+
47
+ `save` и `create` могут иметь разную бизнес-семантику, `latest` не равен generic `read(id)`, а `history` требует paging/filter args. Фиксированные имена отражают распространённую модель versioned user records, но не пытаются описать любую entity.
48
+
22
49
  ## `persisted` или `resource`
23
50
 
24
51
  | Нужна модель | Использовать |
@@ -28,6 +55,8 @@ Capability optional: resource не обязан поддерживать все
28
55
 
29
56
  `persisted` построен на тех же принципах keys/cache, но намеренно не является универсальным CRUD.
30
57
 
58
+ Не используйте `/persisted` для обычного server list, который пользователь никогда не сохраняет и не восстанавливает. Для него достаточно entity query или `/resource`.
59
+
31
60
  ## Установка и provider
32
61
 
33
62
  ```bash
@@ -190,6 +219,86 @@ saveMutation.mutate({
190
219
 
191
220
  Нельзя вызвать `usePersistedHistoryQuery` для этого descriptor, пока capability `history` не добавлена.
192
221
 
222
+ ## Descriptor подробно
223
+
224
+ ```ts
225
+ interface PersistedResourceDescriptor<TScope, TTransport> {
226
+ namespace: string;
227
+ resource: string;
228
+ keys: PersistedRecordKeys<TScope>;
229
+ transport: TTransport;
230
+ isEnabled?: (scope: TScope | null | undefined) => boolean;
231
+ getScopeError?: (scope: TScope | null | undefined) => string | null;
232
+ }
233
+ ```
234
+
235
+ ### `namespace` и `resource`
236
+
237
+ Это техническая стабильная identity:
238
+
239
+ ```ts
240
+ namespace: "view-config",
241
+ resource: "table-view"
242
+ ```
243
+
244
+ Не используйте локализованные labels, route title, случайный UUID при каждом start или URL endpoint. User id принадлежит scope, а не namespace.
245
+
246
+ ### `transport`
247
+
248
+ Это object capabilities:
249
+
250
+ ```ts
251
+ transport: {
252
+ list,
253
+ latest,
254
+ history,
255
+ save,
256
+ create,
257
+ delete: deleteOperation
258
+ }
259
+ ```
260
+
261
+ Подключайте только реально существующие operations. Empty placeholder создаёт ложный public contract.
262
+
263
+ ### `normalizeScope`
264
+
265
+ Normalizer используется при создании `keys` и меняет только cache identity:
266
+
267
+ ```ts
268
+ const scope = { userId: " ivanov ", viewId: " main " };
269
+
270
+ // Key может содержать trimmed values,
271
+ // но execute/buildUrl получает literal scope с пробелами.
272
+ ```
273
+
274
+ Если transport требует canonical values, нормализуйте их на request boundary или создавайте canonical scope до вызова descriptor-а.
275
+
276
+ ### Nullable scope требует `isEnabled`
277
+
278
+ Критическое правило:
279
+
280
+ ```ts
281
+ isEnabled: (scope) => Boolean(scope?.userId && scope?.viewId)
282
+ ```
283
+
284
+ Если callback отсутствует, default query enabled равен `true`, даже при `scope === undefined`. TanStack Query запустит queryFn, а scope assertion выбросит error.
285
+
286
+ `getScopeError` не отключает query. Он только формирует сообщение при actual invalid execution.
287
+
288
+ Standard error:
289
+
290
+ ```text
291
+ Недостаточно данных scope для persisted-record ресурса.
292
+ ```
293
+
294
+ ### Descriptor создаётся на module boundary
295
+
296
+ ```ts
297
+ export const viewResource = createPersistedResourceDescriptor(...);
298
+ ```
299
+
300
+ Не создавайте descriptor внутри React render. Stable module object упрощает inference, keys, testing и reuse.
301
+
193
302
  ## Read capabilities
194
303
 
195
304
  | Capability | Hook | Imperative helper | Args |
@@ -209,6 +318,66 @@ const page = await getPersistedHistoryData(
209
318
 
210
319
  Query operation получает `{ scope, args, client, signal }`. `isEnabled` operation и descriptor определяют автоматический запуск React observer; `staleTime`, `gcTime`, `meta` переходят в TanStack Query options.
211
320
 
321
+ ### Read lifecycle
322
+
323
+ ```text
324
+ hook/get helper
325
+
326
+
327
+ найти capability в descriptor.transport
328
+ │ missing
329
+ ├────────────────► runtime error
330
+ │ exists
331
+
332
+ построить key + enabled + lifetime + meta
333
+
334
+
335
+ TanStack Query запускает queryFn
336
+
337
+
338
+ assert scope
339
+
340
+
341
+ operation.execute({ scope, args, client, signal })
342
+ ```
343
+
344
+ Missing capability error содержит resource и operation:
345
+
346
+ ```text
347
+ Persisted-record ресурс 'view' не поддерживает операцию 'history'.
348
+ ```
349
+
350
+ ### `list` и `latest` получают `args: undefined`
351
+
352
+ Их operation type — `PersistedQueryOperation<TScope, void, TResult>`. В query key optional args не добавляется:
353
+
354
+ ```ts
355
+ descriptor.keys.list(scope);
356
+ // [...scopeKey, "list"]
357
+ ```
358
+
359
+ ### `history` identity
360
+
361
+ Все args, меняющие page/result, должны попасть в call:
362
+
363
+ ```ts
364
+ usePersistedHistoryQuery(resource, scope, {
365
+ cursor: "next-42",
366
+ limit: 20,
367
+ includeDeleted: false
368
+ });
369
+ ```
370
+
371
+ Они нормализуются и добавляются в key. Не прячьте paging/filter во внешнем closure operation-а.
372
+
373
+ ### Disabled query и imperative fetch
374
+
375
+ `enabled: false` управляет automatic observer start. При `queryClient.fetchQuery` query function всё равно защищает scope. Передавайте валидный scope imperative helpers.
376
+
377
+ ### Query `meta`
378
+
379
+ Operation может передать `QueryMeta`, например marker persistence/diagnostics. Generic layer не интерпретирует meta, только переносит в TanStack options.
380
+
212
381
  Imperative helpers используют `queryClient.fetchQuery`. Передавайте валидный scope; disabled policy в первую очередь управляет observer, а фактическая query function всё равно проверяет scope.
213
382
 
214
383
  ## Write capabilities
@@ -221,6 +390,36 @@ Imperative helpers используют `queryClient.fetchQuery`. Передав
221
390
 
222
391
  Mutation operation получает `{ scope, input, client }`. Generic persisted contract не передаёт `AbortSignal` для mutation.
223
392
 
393
+ ### Write lifecycle
394
+
395
+ ```text
396
+ usePersistedSave/Create/DeleteMutation
397
+
398
+
399
+ assert capability существует
400
+
401
+
402
+ mutate(input)
403
+
404
+
405
+ assert scope
406
+
407
+
408
+ operation.execute({ scope, input, client })
409
+ │ success
410
+
411
+ cache strategy
412
+ │ await
413
+
414
+ hook onSuccess(result, input)
415
+ ```
416
+
417
+ Mutation key включает operation/scope, но не input. Две save mutations одного scope могут иметь разные inputs, оставаясь одним operation family.
418
+
419
+ При сохранённом inferred descriptor TypeScript не даст передать resource без `delete` в delete hook. Runtime всё равно проверяет capability для JS, `any`, cast и dynamic object.
420
+
421
+ Scope повторно проверяется перед cache strategy. Не мутируйте captured scope object на месте во время request.
422
+
224
423
  После успешного transport:
225
424
 
226
425
  1. выбирается cache strategy;
@@ -238,6 +437,8 @@ usePersistedSaveMutation(resource, scope, {
238
437
 
239
438
  `cacheStrategy: undefined` использует strategy операции; `null` отключает; объект заменяет исходную strategy. Для дополнения применяйте композицию.
240
439
 
440
+ Если cache strategy бросила error, server write уже мог завершиться. Не повторяйте write автоматически только ради исправления cache: это может создать duplicate.
441
+
241
442
  ## Query keys
242
443
 
243
444
  ```ts
@@ -268,6 +469,59 @@ keys.delete(scope);
268
469
 
269
470
  Используется нормализация `/resource`: strings trim, object keys сортируются, `undefined` становится `null`. Date/Map/cyclic object требуют явного нормализатора.
270
471
 
472
+ ### Точные key forms
473
+
474
+ ```ts
475
+ keys.all;
476
+ // [namespace, resource]
477
+
478
+ keys.scope(scope);
479
+ // [namespace, resource, normalizedScope]
480
+
481
+ keys.list(scope);
482
+ // [namespace, resource, normalizedScope, "list"]
483
+
484
+ keys.latest(scope);
485
+ // [namespace, resource, normalizedScope, "latest"]
486
+
487
+ keys.history(scope, args);
488
+ // [namespace, resource, normalizedScope, "history", normalizedArgs]
489
+
490
+ keys.save(scope);
491
+ // [namespace, resource, normalizedScope, "save"]
492
+ ```
493
+
494
+ `history(scope, undefined)` не добавляет последний item, а `history(scope, null)` добавляет `null`.
495
+
496
+ ### Prefix invalidation
497
+
498
+ ```ts
499
+ await queryClient.invalidateQueries({
500
+ queryKey: descriptor.keys.scope(scope)
501
+ });
502
+ ```
503
+
504
+ Затрагивает list/latest/history этого scope. Точечный вариант:
505
+
506
+ ```ts
507
+ await queryClient.invalidateQueries({
508
+ queryKey: descriptor.keys.latest(scope)
509
+ });
510
+ ```
511
+
512
+ ### Специальные значения scope
513
+
514
+ Default normalizer не умеет Date/Map/Set. Используйте ISO string:
515
+
516
+ ```ts
517
+ normalizeScope: (scope) => ({
518
+ userId: scope?.userId ?? "",
519
+ periodStart: scope?.periodStart.toISOString() ?? null
520
+ })
521
+ ```
522
+
523
+ Query keys нельзя использовать для secrets, SAML/CSRF tokens или полного payload config.
524
+
271
525
  ## JSON payload codec
272
526
 
273
527
  ### Parse
@@ -297,6 +551,38 @@ const payload = stringifyPersistedJson({ compact: false, columns: [] });
297
551
 
298
552
  `createPersistedJsonCodec<T>()` возвращает `{ parse, stringify }` для передачи единым контрактом.
299
553
 
554
+ ### Restore boundary
555
+
556
+ ```text
557
+ backend payload string
558
+ │ parsePersistedJson
559
+
560
+ JS value с compile-time T
561
+ │ runtime validation
562
+
563
+ valid saved model
564
+ │ explicit normalize/migrate restore boundary
565
+
566
+ application state
567
+ ```
568
+
569
+ Не нормализуйте пользовательский draft при каждом keystroke. Serialization/normalization должны происходить на явно названной save/restore boundary.
570
+
571
+ ### Повреждённый JSON и отсутствие payload неразличимы
572
+
573
+ ```ts
574
+ parsePersistedJson(null); // null
575
+ parsePersistedJson(""); // null
576
+ parsePersistedJson("broken"); // null
577
+ parsePersistedJson("null"); // null
578
+ ```
579
+
580
+ Если product должен различать «не сохранено» и «данные повреждены», используйте strict custom codec/parser с discriminated result или typed error.
581
+
582
+ ### Codec не делает migration
583
+
584
+ Generic type не добавляет version/schema check. Для versioned payload храните version в data и обрабатывайте старые versions в domain restore mapper.
585
+
300
586
  ## REST adapters
301
587
 
302
588
  ### Query
@@ -314,6 +600,37 @@ const payload = stringifyPersistedJson({ compact: false, columns: [] });
314
600
 
315
601
  Read signal всегда записывается поверх `buildInit.signal`, чтобы текущая TanStack Query отменяла именно свой запрос.
316
602
 
603
+ ### REST read: точная сборка request
604
+
605
+ ```ts
606
+ {
607
+ url: buildUrl(scope, args),
608
+ baseUrl,
609
+ init: {
610
+ ...buildInit?.(scope, args),
611
+ signal
612
+ }
613
+ }
614
+ ```
615
+
616
+ Query signal перезаписывает signal, который мог вернуть `buildInit`.
617
+
618
+ Если custom `executor` задан, он получает весь request `{ url, baseUrl, init }`, а `parseResponse` не вызывается. Executor обязан вернуть `TResponse`.
619
+
620
+ Без executor используется `/http` → `httpFetchPayload` → обязательный `parseResponse(unknown)`.
621
+
622
+ ### REST read transform
623
+
624
+ ```ts
625
+ transform: (record, { scope, args }) => ({
626
+ id: record.id,
627
+ config: parseAndValidateConfig(record.payload),
628
+ owner: scope.userId
629
+ })
630
+ ```
631
+
632
+ Ошибка parser/transform становится query error. Не заменяйте invalid payload пустым config без product decision.
633
+
317
634
  ### Mutation
318
635
 
319
636
  `createPersistedRestMutationOperation` поддерживает method `POST`, `PUT`, `DELETE`.
@@ -328,6 +645,60 @@ Read signal всегда записывается поверх `buildInit.signal
328
645
 
329
646
  REST adapter использует нейтральный `/http`: SAP SSO/X-CSRF policy здесь нет.
330
647
 
648
+ ### REST mutation: точная сборка init
649
+
650
+ Default fields строятся до `buildInit`, а body — после:
651
+
652
+ ```ts
653
+ {
654
+ method,
655
+ headers: payload === undefined
656
+ ? undefined
657
+ : { "Content-Type": "application/json" },
658
+ ...buildInit(scope, input),
659
+ body: payload === undefined
660
+ ? undefined
661
+ : JSON.stringify(payload)
662
+ }
663
+ ```
664
+
665
+ Следствия:
666
+
667
+ - custom `buildInit.headers` полностью заменяет default Headers object;
668
+ - если нужны custom headers вместе с JSON, добавьте Content-Type сами;
669
+ - `buildInit` по type не может менять `method`/`body`;
670
+ - `bodyMapper` может вернуть `null`: body станет string `"null"`;
671
+ - только `undefined` означает отсутствие body;
672
+ - mutation AbortSignal generic contract не передаёт.
673
+
674
+ ```ts
675
+ buildInit: () => ({
676
+ headers: {
677
+ "Content-Type": "application/json",
678
+ "If-Match": "*"
679
+ }
680
+ })
681
+ ```
682
+
683
+ Для DELETE body появится только при `bodyMapper`. Проверьте contract endpoint-а; многие DELETE используют только URL.
684
+
685
+ ### Custom REST executor
686
+
687
+ ```ts
688
+ const executor = vi.fn(async (request) => {
689
+ expect(request.url).toBe("/api/views/latest");
690
+ return savedRecordFixture;
691
+ });
692
+
693
+ const operation = createPersistedRestQueryOperation({
694
+ buildUrl: () => "/api/views/latest",
695
+ executor,
696
+ transform: (record) => record.payload
697
+ });
698
+ ```
699
+
700
+ Custom executor полезен для tests/tracing, но не должен содержать business mapping конкретной config.
701
+
331
702
  ## OData adapters
332
703
 
333
704
  OData operations используют metadata-aware transport из [`/odata`](../odata/README.mdx).
@@ -366,6 +737,41 @@ const latest = createPersistedODataReadOperation({
366
737
 
367
738
  `buildParams` должен возвращать wrapped OData parameters из `foundation-lib/odata-service`.
368
739
 
740
+ ### `query` или `read`
741
+
742
+ | Factory | Semantic operation | Target |
743
+ | --- | --- | --- |
744
+ | `createPersistedODataQueryOperation` | `query` | collection/plain или parameterized Entity |
745
+ | `createPersistedODataReadOperation` | `read` | одна Entity по keys |
746
+
747
+ Выбор определяется metadata target. Если query возвращает array и transform берёт первый элемент, semantic operation всё равно `query`.
748
+
749
+ Для keyed read `buildParams` фактически обязателен, даже если structural options допускают отсутствие. Metadata-aware OData layer проверит keys/path.
750
+
751
+ ### OData query request assembly
752
+
753
+ ```ts
754
+ {
755
+ odata,
756
+ params: buildParams?.(scope, args),
757
+ options: {
758
+ baseUrl,
759
+ ...buildOptions?.(scope, args)
760
+ },
761
+ init: buildInit?.(scope, args)
762
+ }
763
+ ```
764
+
765
+ `buildOptions.baseUrl`, если возвращён, перезапишет adapter `baseUrl`, потому что spread идёт позже. Для ясности задавайте base URL одним способом.
766
+
767
+ `buildInit` не может задавать signal/method/body по type. Query signal приходит из generic operation context.
768
+
769
+ ### `autoParse` не включён adapter-ом
770
+
771
+ Persisted OData factories не имеют option `autoParse` и не добавляют его во request. Response values остаются transport values.
772
+
773
+ Если нужны Date/number/boolean domain values, выполните явный transform/validator. Type с `Date` сам Date не создаёт.
774
+
369
775
  ### Mutation
370
776
 
371
777
  ```ts
@@ -395,10 +801,57 @@ Semantic methods:
395
801
 
396
802
  OData adapter наследует metadata validation, SAP cookies, SSO recovery и X-CSRF lifecycle. Query/read получают AbortSignal; mutations — нет в generic contract.
397
803
 
804
+ ### OData mutation assembly
805
+
806
+ `create`:
807
+
808
+ ```text
809
+ body = bodyMapper(scope, input)
810
+ params отсутствуют
811
+ ```
812
+
813
+ `update`:
814
+
815
+ ```text
816
+ params = buildParams(scope, input)
817
+ body = bodyMapper(scope, input)
818
+ ```
819
+
820
+ `delete`:
821
+
822
+ ```text
823
+ params = buildParams(scope, input)
824
+ body отсутствует
825
+ ```
826
+
827
+ `buildOptions` и `buildInit` доступны всем трём. Cache strategy получает уже transformed result.
828
+
829
+ ### FunctionImport
830
+
831
+ Persisted OData mutation factory поддерживает только `create`, `update`, `delete`. FunctionImport capability собирайте custom persisted operation/совместимым adapter-ом. Не выдавайте FI за create из-за business verb.
832
+
833
+ ### Custom OData executor
834
+
835
+ Executor получает semantic method, metadata-aware request и `{ client, signal? }`. Query/read context содержит signal, mutation context — только client.
836
+
398
837
  ## Custom и server-function operations
399
838
 
400
839
  Descriptor принимает любой object, соответствующий `PersistedQueryOperation`/`PersistedMutationOperation`. Server-function factories из [`/server-fn`](../server-fn/README.mdx) возвращают совместимые resource operations и могут быть использованы как capabilities, если их generic contract совпадает.
401
840
 
841
+ Пример custom operation:
842
+
843
+ ```ts
844
+ const latest: PersistedQueryOperation<ViewScope, void, ViewConfig | null> = {
845
+ staleTime: 60_000,
846
+ async execute({ scope, client, signal }) {
847
+ const record = await customTransport(scope, { client, signal });
848
+ return restoreViewConfig(record.payload);
849
+ }
850
+ };
851
+ ```
852
+
853
+ Business mapper остаётся у владельца ViewConfig, generic package содержит infrastructure contract.
854
+
402
855
  ## Cache strategies
403
856
 
404
857
  ### Инвалидация всего scope
@@ -428,6 +881,149 @@ const combined = composePersistedCacheStrategies(setLatest, invalidate);
428
881
 
429
882
  `applyPersistedCacheStrategy` — низкоуровневый helper для ручного вызова optional strategy.
430
883
 
884
+ ### Cache strategy context
885
+
886
+ ```ts
887
+ {
888
+ client,
889
+ descriptor,
890
+ scope,
891
+ input,
892
+ result
893
+ }
894
+ ```
895
+
896
+ Точечный update latest:
897
+
898
+ ```ts
899
+ const updateLatest = createSetPersistedQueryDataCacheStrategy<
900
+ ViewScope,
901
+ SaveViewInput,
902
+ ViewConfig,
903
+ ViewConfig | null
904
+ >({
905
+ getQueryKey: ({ descriptor, scope }) => descriptor.keys.latest(scope),
906
+ update: (_current, { result }) => result
907
+ });
908
+ ```
909
+
910
+ ### Почему invalidation — безопасный default
911
+
912
+ Save может изменить latest, list labels/timestamps, history и server-computed revision. Если result не содержит authoritative формы всех projections, инвалидируйте scope.
913
+
914
+ ### Strategy order
915
+
916
+ ```ts
917
+ composePersistedCacheStrategies(updateLatest, invalidateHistory)
918
+ ```
919
+
920
+ выполняется слева направо и await-ит каждую strategy. `null`/`undefined` entries пропускаются.
921
+
922
+ Updater не должен мутировать current snapshot. Возвращайте новое значение.
923
+
924
+ ### Cache error после write
925
+
926
+ Server уже мог сохранить record. Cache failure — не доказательство write failure. Восстановите cache/refetch отдельно вместо слепого повторения create/save.
927
+
928
+ ## Полный end-to-end сценарий restore/edit/save
929
+
930
+ ```text
931
+ 1. usePersistedLatestQuery(scope)
932
+ 2. transport получает backend record
933
+ 3. transform разбирает и валидирует payload
934
+ 4. component создаёт независимый draft clone
935
+ 5. пользователь меняет literal draft
936
+ 6. save mutation сериализует snapshot на save boundary
937
+ 7. backend сохраняет record
938
+ 8. cache strategy invalidates/updates snapshots
939
+ 9. draft отдельно принимает product-specific saved state
940
+ ```
941
+
942
+ Не записывайте normalized saved snapshot обратно в draft автоматически, если продукт должен сохранять literal ввод до explicit save.
943
+
944
+ ## Тестирование
945
+
946
+ ### Keys и scope
947
+
948
+ ```ts
949
+ it("строит одинаковый key для эквивалентного scope", () => {
950
+ const first = viewResource.keys.latest({
951
+ userId: " ivanov ",
952
+ viewId: "main"
953
+ });
954
+ const second = viewResource.keys.latest({
955
+ viewId: "main",
956
+ userId: "ivanov"
957
+ });
958
+
959
+ expect(first).toEqual(second);
960
+ });
961
+ ```
962
+
963
+ ### Codec
964
+
965
+ Проверяйте valid object, `null`, empty string, malformed JSON, version migration и non-serializable value.
966
+
967
+ ### REST request
968
+
969
+ С custom executor проверяйте URL/base URL, read signal, method, body serialization, headers replacement, parser bypass и transform context.
970
+
971
+ ### OData request
972
+
973
+ С custom executor проверяйте semantic method, wrapped params, body presence/absence, options/baseUrl и context client/signal.
974
+
975
+ ### Descriptor lifecycle
976
+
977
+ Покройте:
978
+
979
+ - отсутствующую capability;
980
+ - nullable scope с `isEnabled`;
981
+ - imperative invalid scope error;
982
+ - list/latest keys без args;
983
+ - history keys с args;
984
+ - mutation cache override `undefined`/`null`/object;
985
+ - порядок strategy → hook onSuccess;
986
+ - strategy error не запускает следующие callbacks;
987
+ - transport error не применяет strategy.
988
+
989
+ ## Частые ошибки
990
+
991
+ ### Путать persisted record и persisted Query cache
992
+
993
+ Это разные lifecycle и sources of truth.
994
+
995
+ ### Descriptor без `isEnabled` при nullable scope
996
+
997
+ Query будет автоматически запущен и упадёт на scope assertion.
998
+
999
+ ### Использовать JSON codec как validator
1000
+
1001
+ `JSON.parse` проверяет syntax, но не domain schema.
1002
+
1003
+ ### Нормализовать key и ожидать нормализованный request scope
1004
+
1005
+ Key normalizer не переписывает execute input.
1006
+
1007
+ ### Скрыть history args во внешнем closure
1008
+
1009
+ Args должны входить в hook/helper call и query key.
1010
+
1011
+ ### Потерять Content-Type в REST `buildInit.headers`
1012
+
1013
+ Custom headers заменяют default object — добавьте JSON header явно.
1014
+
1015
+ ### Передать HTTP method в OData adapter
1016
+
1017
+ Нужны semantic `create`/`update`/`delete`.
1018
+
1019
+ ### Ожидать `autoParse` OData values
1020
+
1021
+ Persisted adapter его не включает. Парсите/валидируйте в transform.
1022
+
1023
+ ### Retry create после cache error
1024
+
1025
+ Backend мог уже создать record. Используйте idempotency и отдельное cache recovery.
1026
+
431
1027
  ## Ошибки и безопасность
432
1028
 
433
1029
  - Scope `null`/`undefined` или rejected `isEnabled` блокирует query и вызывает ошибку при фактическом execution.
@@ -437,6 +1033,36 @@ const combined = composePersistedCacheStrategies(setLatest, invalidate);
437
1033
  - Cache update — локальная проекция, backend остаётся source of truth.
438
1034
  - `parsePersistedJson` подавляет syntax error и возвращает `null`; различайте «нет payload» и «повреждён payload» на уровне продукта, если это важно.
439
1035
 
1036
+ ## FAQ
1037
+
1038
+ ### Обязан ли resource поддерживать все capabilities?
1039
+
1040
+ Нет. Подключайте только существующие backend operations.
1041
+
1042
+ ### Чем `save` отличается от `create`?
1043
+
1044
+ Generic layer не навязывает точную semantics. Обычно save обновляет текущий snapshot, create создаёт новую named/versioned запись; contract определяет owner resource.
1045
+
1046
+ ### Можно ли использовать другой payload format?
1047
+
1048
+ Да. `PersistedPayloadCodec` — interface; JSON helpers лишь convenience.
1049
+
1050
+ ### Как получить data вне React component?
1051
+
1052
+ Через `getPersistedListData`, `getPersistedLatestData`, `getPersistedHistoryData` с общим QueryClient.
1053
+
1054
+ ### Есть ли imperative mutation helper?
1055
+
1056
+ Public convenience API mutation сейчас hook-oriented. Custom orchestration должна работать через корректную operation/resource boundary и не обходить cache strategy случайно.
1057
+
1058
+ ### Кто мигрирует старую version payload?
1059
+
1060
+ Domain restore mapper владельца конфигурации.
1061
+
1062
+ ### Можно ли хранить draft прямо в Query cache?
1063
+
1064
+ Нет. Query cache хранит backend snapshot; editable draft должен быть независимым.
1065
+
440
1066
  ## Полный API
441
1067
 
442
1068
  | Группа | Exports |