@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,1080 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation API/Query/Persisted Records" />
4
+
5
+ # Сохранённые записи через `@ryuzaki13/react-foundation-api/persisted`
6
+
7
+ `persisted` — специализированный слой поверх TanStack Query для данных, которые пользователь сохраняет и позднее восстанавливает: presets, variants, view configs и другие versioned records.
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
+
21
+ ## Ментальная модель
22
+
23
+ ```text
24
+ PersistedResourceDescriptor
25
+ ├─ identity: namespace + resource + normalized scope
26
+ ├─ read capability: list | latest | history
27
+ ├─ write capability: save | create | delete
28
+ ├─ transport operation: REST | OData | server-fn | custom
29
+ └─ cache strategy после успешной mutation
30
+ ```
31
+
32
+ Capability optional: resource не обязан поддерживать все шесть операций. TypeScript разрешит соответствующий hook только когда operation присутствует в descriptor; runtime также выдаёт понятную ошибку при неправильном объекте.
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
+
49
+ ## `persisted` или `resource`
50
+
51
+ | Нужна модель | Использовать |
52
+ | --- | --- |
53
+ | Фиксированные сохранённые records: list/latest/history/save/create/delete | `/persisted` |
54
+ | Произвольные названия и semantics операций | [`/resource`](../resource/README.mdx) |
55
+
56
+ `persisted` построен на тех же принципах keys/cache, но намеренно не является универсальным CRUD.
57
+
58
+ Не используйте `/persisted` для обычного server list, который пользователь никогда не сохраняет и не восстанавливает. Для него достаточно entity query или `/resource`.
59
+
60
+ ## Установка и provider
61
+
62
+ ```bash
63
+ npm install @ryuzaki13/react-foundation-api @ryuzaki13/react-foundation-lib @tanstack/react-query react
64
+ ```
65
+
66
+ Hooks требуют `QueryClientProvider`. Descriptor и operations создавайте на module level, чтобы ссылки были стабильными.
67
+
68
+ ## Импорт
69
+
70
+ ```ts
71
+ import {
72
+ applyPersistedCacheStrategy,
73
+ composePersistedCacheStrategies,
74
+ createInvalidatePersistedScopeCacheStrategy,
75
+ createPersistedJsonCodec,
76
+ createPersistedODataMutationOperation,
77
+ createPersistedODataQueryOperation,
78
+ createPersistedODataReadOperation,
79
+ createPersistedRecordKeys,
80
+ createPersistedResourceDescriptor,
81
+ createPersistedRestMutationOperation,
82
+ createPersistedRestQueryOperation,
83
+ createSetPersistedQueryDataCacheStrategy,
84
+ getPersistedHistoryData,
85
+ getPersistedLatestData,
86
+ getPersistedListData,
87
+ parsePersistedJson,
88
+ stringifyPersistedJson,
89
+ usePersistedCreateMutation,
90
+ usePersistedDeleteMutation,
91
+ usePersistedHistoryQuery,
92
+ usePersistedLatestQuery,
93
+ usePersistedListQuery,
94
+ usePersistedSaveMutation
95
+ } from "@ryuzaki13/react-foundation-api/persisted";
96
+ ```
97
+
98
+ ## Пошаговый REST пример
99
+
100
+ ### 1. Контракты
101
+
102
+ ```ts
103
+ type ViewScope = {
104
+ userId: string;
105
+ viewId: string;
106
+ };
107
+
108
+ type ViewConfig = {
109
+ columns: string[];
110
+ compact: boolean;
111
+ };
112
+
113
+ type SavedViewRecord = {
114
+ id: string;
115
+ createdUtc: string;
116
+ payload: string;
117
+ };
118
+
119
+ type SaveViewInput = {
120
+ payload: ViewConfig;
121
+ };
122
+ ```
123
+
124
+ Внешний record и распарсенный config — разные типы. TypeScript не проверяет network payload автоматически.
125
+
126
+ ### 2. Runtime parsers
127
+
128
+ ```ts
129
+ function parseRecord(value: unknown): SavedViewRecord {
130
+ if (!isSavedViewRecord(value)) {
131
+ throw new Error("Некорректный SavedViewRecord payload");
132
+ }
133
+
134
+ return value;
135
+ }
136
+
137
+ function parseRecordList(value: unknown): SavedViewRecord[] {
138
+ if (!Array.isArray(value)) throw new Error("Ожидался массив records");
139
+ return value.map(parseRecord);
140
+ }
141
+ ```
142
+
143
+ ### 3. Operations
144
+
145
+ ```ts
146
+ const listOperation = createPersistedRestQueryOperation<
147
+ ViewScope,
148
+ void,
149
+ SavedViewRecord[]
150
+ >({
151
+ buildUrl: (scope) =>
152
+ `/api/users/${encodeURIComponent(scope.userId)}/views/${encodeURIComponent(scope.viewId)}`,
153
+ parseResponse: parseRecordList,
154
+ staleTime: 60_000
155
+ });
156
+
157
+ const latestOperation = createPersistedRestQueryOperation<
158
+ ViewScope,
159
+ void,
160
+ SavedViewRecord,
161
+ ViewConfig | null
162
+ >({
163
+ buildUrl: (scope) =>
164
+ `/api/users/${encodeURIComponent(scope.userId)}/views/${encodeURIComponent(scope.viewId)}/latest`,
165
+ parseResponse: parseRecord,
166
+ transform: (record) => parsePersistedJson<ViewConfig>(record.payload)
167
+ });
168
+
169
+ const saveOperation = createPersistedRestMutationOperation<
170
+ ViewScope,
171
+ SaveViewInput,
172
+ SavedViewRecord
173
+ >({
174
+ buildUrl: (scope) =>
175
+ `/api/users/${encodeURIComponent(scope.userId)}/views/${encodeURIComponent(scope.viewId)}`,
176
+ method: "PUT",
177
+ bodyMapper: (_scope, input) => ({
178
+ payload: stringifyPersistedJson(input.payload)
179
+ }),
180
+ parseResponse: parseRecord,
181
+ cacheStrategy: createInvalidatePersistedScopeCacheStrategy()
182
+ });
183
+ ```
184
+
185
+ ### 4. Descriptor
186
+
187
+ ```ts
188
+ const viewResource = createPersistedResourceDescriptor({
189
+ namespace: "view-config",
190
+ resource: "view",
191
+ normalizeScope: (scope: ViewScope | null | undefined) => ({
192
+ userId: scope?.userId.trim() ?? "",
193
+ viewId: scope?.viewId.trim() ?? ""
194
+ }),
195
+ isEnabled: (scope) => Boolean(scope?.userId && scope?.viewId),
196
+ getScopeError: () => "Не определён пользователь или представление",
197
+ transport: {
198
+ list: listOperation,
199
+ latest: latestOperation,
200
+ save: saveOperation
201
+ }
202
+ });
203
+ ```
204
+
205
+ `normalizeScope` изменяет только cache identity. В `buildUrl`/`execute` передаётся исходный scope.
206
+
207
+ ### 5. Hooks
208
+
209
+ ```tsx
210
+ const scope = { userId, viewId };
211
+ const latestQuery = usePersistedLatestQuery(viewResource, scope);
212
+ const listQuery = usePersistedListQuery(viewResource, scope);
213
+ const saveMutation = usePersistedSaveMutation(viewResource, scope);
214
+
215
+ saveMutation.mutate({
216
+ payload: { columns: ["name", "status"], compact: true }
217
+ });
218
+ ```
219
+
220
+ Нельзя вызвать `usePersistedHistoryQuery` для этого descriptor, пока capability `history` не добавлена.
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
+
302
+ ## Read capabilities
303
+
304
+ | Capability | Hook | Imperative helper | Args |
305
+ | --- | --- | --- | --- |
306
+ | `list` | `usePersistedListQuery` | `getPersistedListData` | нет (`void`) |
307
+ | `latest` | `usePersistedLatestQuery` | `getPersistedLatestData` | нет (`void`) |
308
+ | `history` | `usePersistedHistoryQuery` | `getPersistedHistoryData` | обязательный generic args |
309
+
310
+ ```ts
311
+ const page = await getPersistedHistoryData(
312
+ resourceWithHistory,
313
+ scope,
314
+ { cursor: "next", limit: 20 },
315
+ queryClient
316
+ );
317
+ ```
318
+
319
+ Query operation получает `{ scope, args, client, signal }`. `isEnabled` operation и descriptor определяют автоматический запуск React observer; `staleTime`, `gcTime`, `meta` переходят в TanStack Query options.
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
+
381
+ Imperative helpers используют `queryClient.fetchQuery`. Передавайте валидный scope; disabled policy в первую очередь управляет observer, а фактическая query function всё равно проверяет scope.
382
+
383
+ ## Write capabilities
384
+
385
+ | Capability | Hook | Типичный смысл |
386
+ | --- | --- | --- |
387
+ | `save` | `usePersistedSaveMutation` | Сохранить/обновить snapshot |
388
+ | `create` | `usePersistedCreateMutation` | Создать новую именованную запись |
389
+ | `delete` | `usePersistedDeleteMutation` | Удалить запись |
390
+
391
+ Mutation operation получает `{ scope, input, client }`. Generic persisted contract не передаёт `AbortSignal` для mutation.
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
+
423
+ После успешного transport:
424
+
425
+ 1. выбирается cache strategy;
426
+ 2. strategy выполняется и awaited;
427
+ 3. вызывается optional hook `onSuccess(result, input)`.
428
+
429
+ ```ts
430
+ usePersistedSaveMutation(resource, scope, {
431
+ cacheStrategy: null, // Отключить operation strategy.
432
+ onSuccess(result) {
433
+ showSuccess(`Сохранено ${result.id}`);
434
+ }
435
+ });
436
+ ```
437
+
438
+ `cacheStrategy: undefined` использует strategy операции; `null` отключает; объект заменяет исходную strategy. Для дополнения применяйте композицию.
439
+
440
+ Если cache strategy бросила error, server write уже мог завершиться. Не повторяйте write автоматически только ради исправления cache: это может создать duplicate.
441
+
442
+ ## Query keys
443
+
444
+ ```ts
445
+ const keys = createPersistedRecordKeys<ViewScope>({
446
+ namespace: "view-config",
447
+ resource: "view",
448
+ normalizeScope: (scope) => ({
449
+ userId: scope?.userId ?? "",
450
+ viewId: scope?.viewId ?? ""
451
+ })
452
+ });
453
+
454
+ keys.all;
455
+ keys.scope(scope);
456
+ keys.list(scope);
457
+ keys.latest(scope);
458
+ keys.history(scope, { limit: 20 });
459
+ keys.save(scope);
460
+ keys.create(scope);
461
+ keys.delete(scope);
462
+ ```
463
+
464
+ Форма:
465
+
466
+ ```text
467
+ [namespace, resource, normalizedScope, operation, optionalArgs]
468
+ ```
469
+
470
+ Используется нормализация `/resource`: strings trim, object keys сортируются, `undefined` становится `null`. Date/Map/cyclic object требуют явного нормализатора.
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
+
525
+ ## JSON payload codec
526
+
527
+ ### Parse
528
+
529
+ ```ts
530
+ parsePersistedJson<ViewConfig>(null); // null.
531
+ parsePersistedJson<ViewConfig>(""); // null.
532
+ parsePersistedJson<ViewConfig>("broken"); // null.
533
+ parsePersistedJson<ViewConfig>('{"compact":true,"columns":[]}');
534
+ // Object, но без runtime validation.
535
+ ```
536
+
537
+ `parsePersistedJson<T>` только вызывает `JSON.parse` и делает TypeScript cast. Он не проверяет schema. После parse примените domain validator/normalizer на явной restore boundary.
538
+
539
+ ### Stringify
540
+
541
+ ```ts
542
+ const payload = stringifyPersistedJson({ compact: false, columns: [] });
543
+ ```
544
+
545
+ Ошибки `JSON.stringify` пробрасываются. Передавайте только JSON-compatible данные. В частности:
546
+
547
+ - cyclic object и `BigInt` приводят к ошибке;
548
+ - `Date` превращается в ISO string;
549
+ - `undefined` в object-поле удаляется;
550
+ - вызов с корневым `undefined` фактически может вернуть `undefined`, несмотря на заявленный string contract — не передавайте его.
551
+
552
+ `createPersistedJsonCodec<T>()` возвращает `{ parse, stringify }` для передачи единым контрактом.
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
+
586
+ ## REST adapters
587
+
588
+ ### Query
589
+
590
+ `createPersistedRestQueryOperation` принимает:
591
+
592
+ - `buildUrl(scope, args)` — относительный или абсолютный URL;
593
+ - optional `baseUrl`;
594
+ - optional `buildInit(scope, args)`;
595
+ - `parseResponse(unknown)` или custom `executor`;
596
+ - optional `transform`;
597
+ - `staleTime`, `gcTime`, `isEnabled`.
598
+
599
+ Если нет ни `executor`, ни `parseResponse`, operation бросит `REST persisted operation requires executor or parseResponse.`
600
+
601
+ Read signal всегда записывается поверх `buildInit.signal`, чтобы текущая TanStack Query отменяла именно свой запрос.
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
+
634
+ ### Mutation
635
+
636
+ `createPersistedRestMutationOperation` поддерживает method `POST`, `PUT`, `DELETE`.
637
+
638
+ - `bodyMapper(scope, input)` создаёт JSON body;
639
+ - если mapper отсутствует/вернул `undefined`, body и default Content-Type не задаются;
640
+ - иначе body сериализуется и default header равен `application/json`;
641
+ - `buildInit` применяется после default headers и может полностью заменить headers;
642
+ - response обязательно проходит `parseResponse` или custom executor;
643
+ - optional `transform` строит domain result;
644
+ - optional `cacheStrategy` прикрепляется к operation.
645
+
646
+ REST adapter использует нейтральный `/http`: SAP SSO/X-CSRF policy здесь нет.
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
+
702
+ ## OData adapters
703
+
704
+ OData operations используют metadata-aware transport из [`/odata`](../odata/README.mdx).
705
+
706
+ ### Query collection
707
+
708
+ ```ts
709
+ const list = createPersistedODataQueryOperation<
710
+ ViewScope,
711
+ void,
712
+ SavedViewRecord[]
713
+ >({
714
+ odata: { service: "Z_VIEW_CONFIG_SRV", target: "ViewSet" },
715
+ buildOptions: (scope) => ({
716
+ expression: createFilterEqual("UserId", scope.userId)
717
+ }),
718
+ transform: (rows) => rows
719
+ });
720
+ ```
721
+
722
+ Доступные options factory: `odata`, optional `baseUrl`, `buildParams`, `buildOptions`, `buildInit`, `transform`, `executor`, `staleTime`, `gcTime`, `meta`, `isEnabled`.
723
+
724
+ `createPersistedODataQueryOperation` использует semantic method `query` и возвращает collection response data. `createPersistedODataReadOperation` использует `read` для одной entity по key parameters:
725
+
726
+ ```ts
727
+ const latest = createPersistedODataReadOperation({
728
+ odata: { service: "Z_VIEW_CONFIG_SRV", target: "ViewSet" },
729
+ buildParams: (scope: ViewScope) => wrapODataParams({
730
+ UserId: scope.userId,
731
+ ViewId: scope.viewId
732
+ }),
733
+ transform: (record: SavedViewRecord) =>
734
+ parsePersistedJson<ViewConfig>(record.payload)
735
+ });
736
+ ```
737
+
738
+ `buildParams` должен возвращать wrapped OData parameters из `foundation-lib/odata-service`.
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
+
775
+ ### Mutation
776
+
777
+ ```ts
778
+ const save = createPersistedODataMutationOperation({
779
+ odata: { service: "Z_VIEW_CONFIG_SRV", target: "ViewSet" },
780
+ method: "update",
781
+ buildParams: (scope: ViewScope) => wrapODataParams({
782
+ UserId: scope.userId,
783
+ ViewId: scope.viewId
784
+ }),
785
+ bodyMapper: (_scope, input: SaveViewInput) => ({
786
+ Payload: stringifyPersistedJson(input.payload)
787
+ }),
788
+ cacheStrategy: createInvalidatePersistedScopeCacheStrategy()
789
+ });
790
+ ```
791
+
792
+ Semantic methods:
793
+
794
+ | Method | Params | Body |
795
+ | --- | --- | --- |
796
+ | `create` | запрещены | обязательный `bodyMapper` |
797
+ | `update` | обязательный `buildParams` | обязательный `bodyMapper` |
798
+ | `delete` | обязательный `buildParams` | запрещён |
799
+
800
+ Это OData operation names, а не raw HTTP strings. Не передавайте `"PUT"`/`"POST"`.
801
+
802
+ OData adapter наследует metadata validation, SAP cookies, SSO recovery и X-CSRF lifecycle. Query/read получают AbortSignal; mutations — нет в generic contract.
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
+
837
+ ## Custom и server-function operations
838
+
839
+ Descriptor принимает любой object, соответствующий `PersistedQueryOperation`/`PersistedMutationOperation`. Server-function factories из [`/server-fn`](../server-fn/README.mdx) возвращают совместимые resource operations и могут быть использованы как capabilities, если их generic contract совпадает.
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
+
855
+ ## Cache strategies
856
+
857
+ ### Инвалидация всего scope
858
+
859
+ ```ts
860
+ const invalidate = createInvalidatePersistedScopeCacheStrategy();
861
+ ```
862
+
863
+ Инвалидирует prefix `descriptor.keys.scope(scope)`, поэтому затрагивает list/latest/history данного scope.
864
+
865
+ ### Точечное обновление
866
+
867
+ ```ts
868
+ const setLatest = createSetPersistedQueryDataCacheStrategy({
869
+ getQueryKey: ({ descriptor, scope }) => descriptor.keys.latest(scope),
870
+ update: (_current, { result }) => result
871
+ });
872
+ ```
873
+
874
+ ### Композиция
875
+
876
+ ```ts
877
+ const combined = composePersistedCacheStrategies(setLatest, invalidate);
878
+ ```
879
+
880
+ Порядок последовательный. Если strategy бросила ошибку, следующие steps и hook `onSuccess` не выполняются, хотя backend mutation уже могла завершиться.
881
+
882
+ `applyPersistedCacheStrategy` — низкоуровневый helper для ручного вызова optional strategy.
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
+
1027
+ ## Ошибки и безопасность
1028
+
1029
+ - Scope `null`/`undefined` или rejected `isEnabled` блокирует query и вызывает ошибку при фактическом execution.
1030
+ - Network payload валидируйте в `parseResponse`/`transform`; TypeScript generic не является validator-ом.
1031
+ - Не храните secrets в payload/query keys/client persistence.
1032
+ - Save/create должны быть идемпотентны настолько, насколько это возможно для retry policy.
1033
+ - Cache update — локальная проекция, backend остаётся source of truth.
1034
+ - `parsePersistedJson` подавляет syntax error и возвращает `null`; различайте «нет payload» и «повреждён payload» на уровне продукта, если это важно.
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
+
1066
+ ## Полный API
1067
+
1068
+ | Группа | Exports |
1069
+ | --- | --- |
1070
+ | Descriptor/keys | `createPersistedResourceDescriptor`, `createPersistedRecordKeys` |
1071
+ | React reads | `usePersistedListQuery`, `usePersistedLatestQuery`, `usePersistedHistoryQuery` |
1072
+ | Imperative reads | `getPersistedListData`, `getPersistedLatestData`, `getPersistedHistoryData` |
1073
+ | Mutations | `usePersistedSaveMutation`, `usePersistedCreateMutation`, `usePersistedDeleteMutation` |
1074
+ | Payload | `parsePersistedJson`, `stringifyPersistedJson`, `createPersistedJsonCodec` |
1075
+ | REST adapters | `createPersistedRestQueryOperation`, `createPersistedRestMutationOperation` |
1076
+ | OData adapters | `createPersistedODataQueryOperation`, `createPersistedODataReadOperation`, `createPersistedODataMutationOperation` |
1077
+ | Cache | `applyPersistedCacheStrategy`, `createInvalidatePersistedScopeCacheStrategy`, `createSetPersistedQueryDataCacheStrategy`, `composePersistedCacheStrategies` |
1078
+ | Names/types | `PersistedReadOperationName`, `PersistedWriteOperationName`, `PersistedOperationName`, `PersistedPayloadCodec`, `PersistedRecordKeys` |
1079
+ | Operation contracts | `PersistedQueryOperationContext`, `PersistedMutationOperationContext`, `PersistedQueryOperation`, `PersistedMutationOperation`, `PersistedOperationCapabilities`, `PersistedTransportAdapter` |
1080
+ | Descriptor/cache contracts | `PersistedResourceDescriptor`, `CreatePersistedResourceDescriptorOptions`, `PersistedCacheStrategyContext`, `PersistedCacheStrategy`, `PersistedSetQueryDataStrategyOptions` |