@ryuzaki13/react-foundation-api 1.1.15 → 1.1.17

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 +8 -3
  20. package/src/adt/README.mdx +164 -0
  21. package/src/async/README.mdx +253 -0
  22. package/src/error-report/README.mdx +148 -0
  23. package/src/foundationApi.mdx +123 -0
  24. package/src/http/README.mdx +221 -0
  25. package/src/odata/README.mdx +790 -0
  26. package/src/persisted/README.mdx +454 -0
  27. package/src/resource/README.mdx +358 -0
  28. package/src/server-fn/README.mdx +194 -0
  29. package/src/transport/README.mdx +183 -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
package/src/README.md DELETED
@@ -1,937 +0,0 @@
1
- # shared/api: руководство пользователя публичного API
2
-
3
- Этот документ описывает, как пользоваться публичным API слоя `src/shared/api`.
4
-
5
- Если нужно сопровождать внутреннюю реализацию, менять границы слоёв, добавлять transport или править cache orchestration, сначала читать [ARCHITECTURE.md](./ARCHITECTURE.md).
6
-
7
- ## Быстрый выбор слоя
8
-
9
- `shared/api` теперь разделён на несколько независимых назначений:
10
-
11
- | Слой | Когда использовать | Что не делать |
12
- | ---------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
13
- | `shared/api/http` | Обычный HTTP/REST без SAP, OData, SAML2 и CSRF | Не использовать для OData Gateway |
14
- | `shared/api/odata` | SAP Gateway / OData v2 / metadata-aware запросы | Не собирать OData URL вручную в feature/entity |
15
- | `shared/api/odata/transport` | Низкоуровневый OData transport: `fetchJson`, `fetchBase`, SSO, CSRF | Не использовать как общий fetch для SSR/REST |
16
- | `shared/api/resource` | Универсальная модель ресурса с произвольными read/write operation names | Не вшивать сюда конкретные transport details |
17
- | `shared/api/server-fn` | Адаптация TanStack Start server functions к `resource`/`persisted` operation contract | Не импортировать `@tanstack/react-start` в shared |
18
- | `shared/api/persisted` | Узкий фасад для сохранённых записей с capability `list/latest/history/save/create/delete` | Не расширять под произвольные операции, для этого есть `resource` |
19
-
20
- ## Правило выбора
21
-
22
- 1. Если endpoint OData/SAP Gateway, использовать `shared/api/odata`.
23
- 2. Если endpoint обычный HTTP/REST, использовать `shared/api/http`.
24
- 3. Если нужно описать ресурс с query/mutation, query keys и cache policy, использовать `shared/api/resource`.
25
- 4. Если ресурс хранит сохранённые записи и укладывается в `list/latest/history/save/create/delete`, можно использовать `shared/api/persisted`.
26
- 5. Если проект на TanStack Start вызывает serverFn, транспортную operation создавать через `shared/api/server-fn`.
27
-
28
- ## Импорты
29
-
30
- Для прикладного кода предпочтителен публичный barrel:
31
-
32
- ```ts
33
- import { httpJsonQueryFn, odataQueryFn } from "@/shared/api";
34
- ```
35
-
36
- Для специализированных подслоёв допустимы точные публичные импорты:
37
-
38
- ```ts
39
- import { createResourceDescriptor } from "@/shared/api/resource";
40
- import { createServerFnQueryOperation } from "@/shared/api/server-fn";
41
- import { createPersistedResourceDescriptor } from "@/shared/api/persisted";
42
- ```
43
-
44
- Не использовать удалённый путь:
45
-
46
- ```ts
47
- // Нельзя: такого слоя больше нет.
48
- import { fetchJson } from "@/shared/api/fetch";
49
- ```
50
-
51
- Если нужен `fetchJson`, это OData-specific transport:
52
-
53
- ```ts
54
- import { fetchJson } from "@/shared/api";
55
- ```
56
-
57
- или внутри `shared/api`:
58
-
59
- ```ts
60
- import { fetchJson } from "../odata/transport";
61
- ```
62
-
63
- ## `shared/api/http`
64
-
65
- `http` — это чистый HTTP-слой без SAP/OData side effects.
66
-
67
- Он не добавляет:
68
-
69
- - SAP base URL;
70
- - SAP client;
71
- - X-CSRF token;
72
- - SAML2/SSO recovery;
73
- - OData envelope parsing;
74
- - OData metadata;
75
- - OData-specific error reporting.
76
-
77
- ### Публичные функции
78
-
79
- ```ts
80
- httpFetch(input, options): Promise<Response>
81
- httpFetchPayload(input, options): Promise<unknown>
82
- httpJsonQueryFn(url, options): queryFn
83
- httpJsonMutationFn(url, options): mutationFn
84
- ```
85
-
86
- ### `httpFetch`
87
-
88
- Использовать, когда нужен сырой `Response`.
89
-
90
- ```ts
91
- import { httpFetch } from "@/shared/api";
92
-
93
- const response = await httpFetch("/api/files/report", {
94
- baseUrl: "",
95
- init: {
96
- headers: {
97
- Accept: "application/pdf"
98
- }
99
- }
100
- });
101
- ```
102
-
103
- `httpFetch` бросает `Error`, если `response.ok === false`.
104
-
105
- ### `httpFetchPayload`
106
-
107
- Использовать, когда нужен JSON/text payload без TanStack Query factory.
108
-
109
- ```ts
110
- import { httpFetchPayload } from "@/shared/api";
111
-
112
- const payload = await httpFetchPayload("/api/profile", {
113
- baseUrl: ""
114
- });
115
- ```
116
-
117
- Результат всегда `unknown`. Его нужно сузить самостоятельно.
118
-
119
- ```ts
120
- type Profile = {
121
- readonly id: string;
122
- readonly name: string;
123
- };
124
-
125
- function parseProfile(payload: unknown): Profile {
126
- if (typeof payload !== "object" || payload === null || !("id" in payload) || !("name" in payload)) {
127
- throw new Error("Некорректный профиль.");
128
- }
129
-
130
- if (typeof payload.id !== "string" || typeof payload.name !== "string") {
131
- throw new Error("Некорректный профиль.");
132
- }
133
-
134
- return {
135
- id: payload.id,
136
- name: payload.name
137
- };
138
- }
139
-
140
- const profile = parseProfile(payload);
141
- ```
142
-
143
- ### `httpJsonQueryFn`
144
-
145
- Использовать как `queryFn` для обычного REST endpoint.
146
-
147
- ```ts
148
- import { queryOptions, useQuery } from "@tanstack/react-query";
149
-
150
- import { httpJsonQueryFn } from "@/shared/api";
151
-
152
- type Profile = {
153
- readonly id: string;
154
- readonly name: string;
155
- };
156
-
157
- function parseProfile(payload: unknown): Profile {
158
- if (typeof payload !== "object" || payload === null || !("id" in payload) || !("name" in payload)) {
159
- throw new Error("Некорректный профиль.");
160
- }
161
-
162
- if (typeof payload.id !== "string" || typeof payload.name !== "string") {
163
- throw new Error("Некорректный профиль.");
164
- }
165
-
166
- return {
167
- id: payload.id,
168
- name: payload.name
169
- };
170
- }
171
-
172
- const profileQueryOptions = (userId: string) =>
173
- queryOptions({
174
- queryKey: ["profile", userId],
175
- queryFn: httpJsonQueryFn(`/api/users/${userId}/profile`, {
176
- parse: parseProfile
177
- })
178
- });
179
-
180
- export function useProfileQuery(userId: string) {
181
- return useQuery(profileQueryOptions(userId));
182
- }
183
- ```
184
-
185
- Особенности:
186
-
187
- - `parse` обязателен;
188
- - `signal` из TanStack Query добавляется автоматически, если в `init.signal` не передан свой;
189
- - `swCache` добавляет заголовок `x-sw-cache`;
190
- - JSON определяется по `Content-Type: application/json`, иначе payload будет строкой.
191
-
192
- ### `httpJsonMutationFn`
193
-
194
- Использовать как `mutationFn` для REST endpoint.
195
-
196
- ```ts
197
- import { useMutation } from "@tanstack/react-query";
198
-
199
- import { httpJsonMutationFn } from "@/shared/api";
200
-
201
- type SaveProfileInput = {
202
- readonly name: string;
203
- };
204
-
205
- type SaveProfileResult = {
206
- readonly id: string;
207
- };
208
-
209
- function parseSaveProfileResult(payload: unknown): SaveProfileResult {
210
- if (typeof payload !== "object" || payload === null || !("id" in payload) || typeof payload.id !== "string") {
211
- throw new Error("Некорректный результат сохранения профиля.");
212
- }
213
-
214
- return { id: payload.id };
215
- }
216
-
217
- export function useSaveProfileMutation(userId: string) {
218
- return useMutation({
219
- mutationKey: ["profile", userId, "save"],
220
- mutationFn: httpJsonMutationFn<SaveProfileInput, SaveProfileResult>(`/api/users/${userId}/profile`, {
221
- method: "PUT",
222
- parse: parseSaveProfileResult
223
- })
224
- });
225
- }
226
- ```
227
-
228
- Если нужно изменить body перед отправкой, использовать `mapBody`:
229
-
230
- ```ts
231
- httpJsonMutationFn<SaveProfileInput, SaveProfileResult>("/api/profile", {
232
- method: "POST",
233
- mapBody: (input) => ({
234
- displayName: input.name.trim()
235
- }),
236
- parse: parseSaveProfileResult
237
- });
238
- ```
239
-
240
- ## `shared/api/odata`
241
-
242
- `odata` — слой для SAP Gateway / OData v2.
243
-
244
- Использовать его, если запрос зависит от:
245
-
246
- - OData metadata;
247
- - TextEntitySet / EntityType / FunctionImport;
248
- - `$select`, `$expand`, `$filter`, `$orderby`, `$top`, `$skip`;
249
- - OData key serialization;
250
- - SAP base URL;
251
- - X-CSRF;
252
- - SAML2/SSO handling.
253
-
254
- ### Высокоуровневые helper-ы
255
-
256
- Для большинства задач использовать:
257
-
258
- ```ts
259
- odataQueryFn;
260
- odataReadFn;
261
- odataCreateFn;
262
- odataUpdateFn;
263
- odataDeleteFn;
264
- odataFunctionImportFn;
265
- ```
266
-
267
- Пример query:
268
-
269
- ```ts
270
- import { queryOptions, useQuery } from "@tanstack/react-query";
271
-
272
- import { odataQueryFn } from "@/shared/api";
273
- import { createFilterEqual } from "@ryuzaki13/react-foundation-lib/odata-service";
274
-
275
- type RawUser = {
276
- ID: string;
277
- NAME: string;
278
- };
279
-
280
- type User = {
281
- readonly id: string;
282
- readonly name: string;
283
- };
284
-
285
- const usersQueryOptions = (departmentId: string) =>
286
- queryOptions({
287
- queryKey: ["users", departmentId],
288
- queryFn: odataQueryFn<RawUser, User>({
289
- odata: {
290
- service: "TEXT_USER_SRV",
291
- target: "TextUserSet"
292
- },
293
- options: {
294
- expression: {
295
- filters: [createFilterEqual("DEPARTMENT_ID", departmentId)]
296
- }
297
- },
298
- transform: (rows) =>
299
- rows.map((row) => ({
300
- id: row.ID,
301
- name: row.NAME
302
- }))
303
- })
304
- });
305
-
306
- export function useUsersQuery(departmentId: string) {
307
- return useQuery(usersQueryOptions(departmentId));
308
- }
309
- ```
310
-
311
- Подробнее по metadata-aware helper-ам: [odata/README.md](./odata/README.md).
312
-
313
- ### Низкоуровневый OData transport
314
-
315
- Из `shared/api/odata/transport` доступны:
316
-
317
- ```ts
318
- fetchBase;
319
- fetchODataJson;
320
- fetchJson;
321
- fetchJsonQueryFn;
322
- fetchJsonMutationFn;
323
- fetchDeleteFn;
324
- fetchQueryFn;
325
- fetchMetadata;
326
- resolveODataBaseUrl;
327
- normalizeODataServiceName;
328
- SsoRequiredError;
329
- recoverSsoSession;
330
- ```
331
-
332
- Этот слой исторически назывался `shared/api/fetch`, но теперь находится внутри `odata`, потому что его поведение SAP/OData-specific.
333
-
334
- Использовать transport напрямую стоит только если:
335
-
336
- - нужен запрос к SAP endpoint без metadata-aware `odataFetchFn`;
337
- - нужен bootstrap-запрос;
338
- - нужен SSO recovery;
339
- - нужен raw OData JSON helper;
340
- - существующий helper не покрывает сценарий.
341
-
342
- Пример:
343
-
344
- ```ts
345
- import { fetchJson } from "@/shared/api";
346
-
347
- type TransportRequestRaw = {
348
- readonly TRKORR: string;
349
- readonly AS4TEXT: string;
350
- };
351
-
352
- const rows = await fetchJson<TransportRequestRaw[]>("/TextTransportRequestSet", undefined, "odataDp0");
353
- ```
354
-
355
- Не использовать `fetchJson` для обычного REST/SSR endpoint. Для этого есть `http`.
356
-
357
- ## `shared/api/resource`
358
-
359
- `resource` — generic orchestration-слой поверх TanStack Query.
360
-
361
- Он решает задачи:
362
-
363
- - стабильные query keys;
364
- - нормализация `scope`;
365
- - произвольные read operation names;
366
- - произвольные write operation names;
367
- - `queryOptions`;
368
- - `useQuery`;
369
- - `fetchQuery`;
370
- - `useMutation`;
371
- - cache strategies после успешных мутаций.
372
-
373
- Он не знает:
374
-
375
- - как выполнять HTTP;
376
- - как выполнять OData;
377
- - как вызывать serverFn;
378
- - что такое конкретная бизнес-сущность;
379
- - какие operation names должны существовать.
380
-
381
- ### Базовая модель
382
-
383
- Ресурс описывается descriptor-ом:
384
-
385
- ```ts
386
- const descriptor = createResourceDescriptor({
387
- namespace: "profile",
388
- resource: "settings",
389
- normalizeScope: (scope: ProfileScope | null | undefined) => ({
390
- userId: scope?.userId.trim() ?? ""
391
- }),
392
- isEnabled: (scope) => Boolean(scope?.userId),
393
- getScopeError: () => "Не указан userId.",
394
- operations: {
395
- queries: {
396
- detail: createResourceQueryOperation(...)
397
- },
398
- mutations: {
399
- save: createResourceMutationOperation(...)
400
- }
401
- }
402
- });
403
- ```
404
-
405
- `namespace` и `resource` участвуют в query key. Они должны быть стабильными строками.
406
-
407
- `scope` — внешний контекст ресурса: пользователь, приложение, ракурс, tenant, документ или другой идентификатор.
408
-
409
- `args` — аргументы конкретной read-операции.
410
-
411
- `input` — payload write-операции.
412
-
413
- ### Query key
414
-
415
- По умолчанию ключ строится так:
416
-
417
- ```ts
418
- [namespace, resource, normalizedScope, operationName, normalizedArgs?]
419
- ```
420
-
421
- Пример:
422
-
423
- ```ts
424
- descriptor.keys.operation("detail", { userId: " USER " }, { version: 2 });
425
- // ["profile", "settings", { userId: "USER" }, "detail", { version: 2 }]
426
- ```
427
-
428
- Нормализация:
429
-
430
- - `null` и `undefined` превращаются в `null`;
431
- - строки trim-ятся;
432
- - массивы нормализуются поэлементно;
433
- - ключи объектов сортируются;
434
- - остальные значения приводятся к строке.
435
-
436
- Если этого недостаточно, передать `normalizeScope`.
437
-
438
- ### Read operation
439
-
440
- ```ts
441
- import { createResourceQueryOperation } from "@/shared/api/resource";
442
-
443
- type ProfileScope = {
444
- readonly userId: string;
445
- };
446
-
447
- type ProfileArgs = {
448
- readonly includePermissions: boolean;
449
- };
450
-
451
- type Profile = {
452
- readonly id: string;
453
- readonly name: string;
454
- };
455
-
456
- const detail = createResourceQueryOperation<ProfileScope, ProfileArgs, Profile>({
457
- staleTime: 1000 * 60,
458
- execute: async ({ scope, args, signal }) => {
459
- const response = await fetch(`/api/users/${scope.userId}?permissions=${args.includePermissions}`, { signal });
460
- const payload: unknown = await response.json();
461
- return parseProfile(payload);
462
- }
463
- });
464
- ```
465
-
466
- ### Descriptor + hook
467
-
468
- ```ts
469
- import { createResourceDescriptor, useResourceQuery } from "@/shared/api/resource";
470
-
471
- const profileResource = createResourceDescriptor({
472
- namespace: "profile",
473
- resource: "user",
474
- isEnabled: (scope: ProfileScope | null | undefined) => Boolean(scope?.userId),
475
- operations: {
476
- queries: {
477
- detail
478
- }
479
- }
480
- });
481
-
482
- export function useProfileQuery(scope: ProfileScope, args: ProfileArgs) {
483
- return useResourceQuery(profileResource, "detail", scope, args);
484
- }
485
- ```
486
-
487
- ### Imperative preload
488
-
489
- ```ts
490
- import { getResourceQueryData } from "@/shared/api/resource";
491
-
492
- const profile = await getResourceQueryData(profileResource, "detail", scope, args, queryClient);
493
- ```
494
-
495
- ### Mutation operation
496
-
497
- ```ts
498
- import { createResourceMutationOperation, createInvalidateResourceScopeCacheStrategy } from "@/shared/api/resource";
499
-
500
- type SaveProfileInput = {
501
- readonly name: string;
502
- };
503
-
504
- const save = createResourceMutationOperation<ProfileScope, SaveProfileInput, Profile, typeof profileResource>({
505
- execute: async ({ scope, input }) => {
506
- const response = await fetch(`/api/users/${scope.userId}`, {
507
- method: "PUT",
508
- headers: { "Content-Type": "application/json" },
509
- body: JSON.stringify(input)
510
- });
511
- const payload: unknown = await response.json();
512
- return parseProfile(payload);
513
- },
514
- cacheStrategy: createInvalidateResourceScopeCacheStrategy()
515
- });
516
- ```
517
-
518
- На практике `typeof profileResource` в mutation operation часто неудобен из-за порядка объявления. В таком случае можно:
519
-
520
- - объявить mutation после descriptor через отдельный factory;
521
- - использовать `ResourceDescriptor<Scope, Queries, Mutations>` как именованный тип;
522
- - передать cache strategy на уровне `useResourceMutation`.
523
-
524
- ### Mutation hook
525
-
526
- ```ts
527
- import { useResourceMutation } from "@/shared/api/resource";
528
-
529
- export function useSaveProfileMutation(scope: ProfileScope) {
530
- return useResourceMutation(profileResource, "save", scope);
531
- }
532
- ```
533
-
534
- ### Cache strategies
535
-
536
- Доступны:
537
-
538
- ```ts
539
- createInvalidateResourceScopeCacheStrategy;
540
- createSetResourceQueryDataCacheStrategy;
541
- composeResourceCacheStrategies;
542
- applyResourceCacheStrategy;
543
- ```
544
-
545
- Инвалидация всего scope:
546
-
547
- ```ts
548
- cacheStrategy: createInvalidateResourceScopeCacheStrategy();
549
- ```
550
-
551
- Точечное обновление query:
552
-
553
- ```ts
554
- createSetResourceQueryDataCacheStrategy({
555
- getQueryKey: ({ descriptor, scope }) => descriptor.keys.operation("detail", scope, undefined),
556
- update: (_current, { result }) => result
557
- });
558
- ```
559
-
560
- Композиция:
561
-
562
- ```ts
563
- composeResourceCacheStrategies(
564
- createSetResourceQueryDataCacheStrategy(...),
565
- createInvalidateResourceScopeCacheStrategy()
566
- );
567
- ```
568
-
569
- ## `shared/api/server-fn`
570
-
571
- `server-fn` адаптирует TanStack Start server functions к operation contract из `resource`.
572
-
573
- Слой специально не импортирует `@tanstack/react-start`.
574
-
575
- Он знает только переносимую форму:
576
-
577
- ```ts
578
- type ServerFnTransport<TData, TResponse> = (request: { readonly data: TData }) => Promise<TResponse>;
579
- ```
580
-
581
- ### Query operation
582
-
583
- ```ts
584
- import { createResourceDescriptor, useResourceQuery } from "@/shared/api/resource";
585
- import { createServerFnQueryOperation } from "@/shared/api/server-fn";
586
-
587
- type Scope = {
588
- readonly userId: string;
589
- };
590
-
591
- type Profile = {
592
- readonly id: string;
593
- readonly name: string;
594
- };
595
-
596
- const profileResource = createResourceDescriptor({
597
- namespace: "profile",
598
- resource: "user",
599
- operations: {
600
- queries: {
601
- detail: createServerFnQueryOperation<Scope, void, Scope, Profile>({
602
- serverFn: getProfileServerFn,
603
- buildData: (scope) => scope,
604
- staleTime: 1000 * 60
605
- })
606
- }
607
- }
608
- });
609
-
610
- export function useProfileQuery(scope: Scope) {
611
- return useResourceQuery(profileResource, "detail", scope, undefined);
612
- }
613
- ```
614
-
615
- ### Mutation operation
616
-
617
- ```ts
618
- import { createServerFnMutationOperation } from "@/shared/api/server-fn";
619
-
620
- type SaveProfileInput = {
621
- readonly name: string;
622
- };
623
-
624
- type SaveProfileData = Scope & SaveProfileInput;
625
-
626
- const saveProfileOperation = createServerFnMutationOperation<Scope, SaveProfileInput, SaveProfileData, Profile, typeof profileResource>({
627
- serverFn: saveProfileServerFn,
628
- buildData: (scope, input) => ({
629
- ...scope,
630
- name: input.name
631
- })
632
- });
633
- ```
634
-
635
- ### `transform`
636
-
637
- Если serverFn возвращает DTO, а resource должен отдавать доменную модель, использовать `transform`:
638
-
639
- ```ts
640
- createServerFnQueryOperation<Scope, void, Scope, RawProfile, Profile>({
641
- serverFn: getProfileServerFn,
642
- buildData: (scope) => scope,
643
- transform: (raw) => ({
644
- id: raw.ID,
645
- name: raw.NAME
646
- })
647
- });
648
- ```
649
-
650
- ### `executor`
651
-
652
- `executor` нужен редко:
653
-
654
- - тесты;
655
- - tracing;
656
- - retry wrapper;
657
- - дополнительная интеграционная обвязка конкретного проекта.
658
-
659
- ```ts
660
- createServerFnQueryOperation({
661
- serverFn,
662
- buildData,
663
- executor: async (currentServerFn, request, context) => {
664
- console.debug("serverFn", context.client);
665
- return await currentServerFn(request);
666
- }
667
- });
668
- ```
669
-
670
- ## `shared/api/persisted`
671
-
672
- `persisted` — специализированный фасад для ресурсов сохранённых записей.
673
-
674
- Использовать, если ресурс действительно описывается capability:
675
-
676
- ```ts
677
- list
678
- latest
679
- history
680
- save
681
- create
682
- delete
683
- ```
684
-
685
- Если нужны операции вроде `publish`, `archive`, `clone`, `recalculate`, `preview`, `restore`, лучше использовать `shared/api/resource`.
686
-
687
- ### Что делает `persisted`
688
-
689
- - создаёт стандартные query keys;
690
- - нормализует scope через `resource` key normalization;
691
- - предоставляет hooks под фиксированные capability;
692
- - содержит JSON payload helpers;
693
- - содержит OData и REST operation adapters;
694
- - применяет cache strategies после mutation.
695
-
696
- ### Descriptor
697
-
698
- ```ts
699
- import { createPersistedResourceDescriptor, usePersistedLatestQuery, usePersistedSaveMutation } from "@/shared/api/persisted";
700
-
701
- type ViewConfigScope = {
702
- readonly appId: string;
703
- readonly viewId: string;
704
- };
705
-
706
- const viewConfigResource = createPersistedResourceDescriptor({
707
- namespace: "viewConfig",
708
- resource: "view",
709
- normalizeScope: (scope: ViewConfigScope | null | undefined) => ({
710
- appId: scope?.appId.trim() ?? "",
711
- viewId: scope?.viewId.trim() ?? ""
712
- }),
713
- isEnabled: (scope) => Boolean(scope?.appId && scope?.viewId),
714
- getScopeError: () => "Не задан scope конфигурации.",
715
- transport: {
716
- latest: latestOperation,
717
- save: saveOperation
718
- }
719
- });
720
-
721
- export function useViewConfigLatestQuery(scope: ViewConfigScope) {
722
- return usePersistedLatestQuery(viewConfigResource, scope);
723
- }
724
-
725
- export function useSaveViewConfigMutation(scope: ViewConfigScope) {
726
- return usePersistedSaveMutation(viewConfigResource, scope);
727
- }
728
- ```
729
-
730
- ### OData persisted operation
731
-
732
- ```ts
733
- import {
734
- createInvalidatePersistedScopeCacheStrategy,
735
- createPersistedODataMutationOperation,
736
- createPersistedODataQueryOperation,
737
- parsePersistedJson,
738
- stringifyPersistedJson
739
- } from "@/shared/api/persisted";
740
- import { createFilterEqual } from "@ryuzaki13/react-foundation-lib/odata-service";
741
-
742
- type ViewConfigRaw = {
743
- readonly APP_ID: string;
744
- readonly VIEW_ID: string;
745
- readonly PAYLOAD: string | null;
746
- };
747
-
748
- type ViewConfigPayload = {
749
- readonly columns: readonly string[];
750
- };
751
-
752
- const latestOperation = createPersistedODataQueryOperation<ViewConfigScope, void, readonly ViewConfigRaw[], ViewConfigPayload | null>({
753
- odata: {
754
- service: "TEXT_CONFIG_SRV",
755
- target: "TextConfigLatestSet"
756
- },
757
- buildOptions: (scope) => ({
758
- expression: {
759
- filters: [createFilterEqual("APP_ID", scope.appId), createFilterEqual("VIEW_ID", scope.viewId)]
760
- }
761
- }),
762
- transform: (rows) => parsePersistedJson<ViewConfigPayload>(rows[0]?.PAYLOAD)
763
- });
764
-
765
- const saveOperation = createPersistedODataMutationOperation<ViewConfigScope, { readonly payload: ViewConfigPayload }, unknown, unknown>({
766
- odata: {
767
- service: "TEXT_CONFIG_SRV",
768
- target: "TextConfigSet"
769
- },
770
- method: "create",
771
- bodyMapper: (scope, input) => ({
772
- APP_ID: scope.appId,
773
- VIEW_ID: scope.viewId,
774
- PAYLOAD: stringifyPersistedJson(input.payload)
775
- }),
776
- cacheStrategy: createInvalidatePersistedScopeCacheStrategy()
777
- });
778
- ```
779
-
780
- ### REST persisted operation
781
-
782
- REST operation использует `shared/api/http`, поэтому без `executor` нужно обязательно передать `parseResponse`.
783
-
784
- ```ts
785
- import { createPersistedRestQueryOperation, createPersistedRestMutationOperation } from "@/shared/api/persisted";
786
-
787
- type Preset = {
788
- readonly id: string;
789
- readonly title: string;
790
- };
791
-
792
- function parsePresetList(payload: unknown): readonly Preset[] {
793
- if (!Array.isArray(payload)) {
794
- throw new Error("Некорректный список preset.");
795
- }
796
-
797
- return payload.map((item) => {
798
- if (typeof item !== "object" || item === null || !("id" in item) || !("title" in item)) {
799
- throw new Error("Некорректный preset.");
800
- }
801
-
802
- if (typeof item.id !== "string" || typeof item.title !== "string") {
803
- throw new Error("Некорректный preset.");
804
- }
805
-
806
- return {
807
- id: item.id,
808
- title: item.title
809
- };
810
- });
811
- }
812
-
813
- const listOperation = createPersistedRestQueryOperation<ViewConfigScope, void, readonly Preset[]>({
814
- baseUrl: "",
815
- buildUrl: (scope) => `/api/views/${scope.appId}/${scope.viewId}/presets`,
816
- parseResponse: parsePresetList
817
- });
818
- ```
819
-
820
- Если используется `executor`, он сам отвечает за тип результата:
821
-
822
- ```ts
823
- const listOperation = createPersistedRestQueryOperation<ViewConfigScope, void, readonly Preset[]>({
824
- buildUrl: (scope) => `/api/views/${scope.appId}/${scope.viewId}/presets`,
825
- executor: async (request) => {
826
- const payload = await customHttpClient(request.url, request.init);
827
- return parsePresetList(payload);
828
- }
829
- });
830
- ```
831
-
832
- ### serverFn внутри persisted descriptor
833
-
834
- serverFn operation берётся из `shared/api/server-fn`, а descriptor остаётся `persisted`.
835
-
836
- ```ts
837
- import { createPersistedResourceDescriptor, usePersistedLatestQuery } from "@/shared/api/persisted";
838
- import { createServerFnQueryOperation } from "@/shared/api/server-fn";
839
-
840
- const viewConfigResource = createPersistedResourceDescriptor({
841
- namespace: "viewConfig",
842
- resource: "view",
843
- transport: {
844
- latest: createServerFnQueryOperation<ViewConfigScope, void, ViewConfigScope, ViewConfigPayload | null>({
845
- serverFn: getViewConfigServerFn,
846
- buildData: (scope) => scope
847
- })
848
- }
849
- });
850
-
851
- export function useLatest(scope: ViewConfigScope) {
852
- return usePersistedLatestQuery(viewConfigResource, scope);
853
- }
854
- ```
855
-
856
- ## Что не делать
857
-
858
- ### Не использовать OData transport как generic fetch
859
-
860
- ```ts
861
- // Плохо: обычный REST endpoint идёт через OData/SAP transport.
862
- fetchJson<Profile>("/api/profile", undefined, "");
863
- ```
864
-
865
- Лучше:
866
-
867
- ```ts
868
- httpJsonQueryFn("/api/profile", {
869
- parse: parseProfile
870
- });
871
- ```
872
-
873
- ### Не расширять `persisted` новыми произвольными capability
874
-
875
- ```ts
876
- // Плохо: persisted начинает превращаться в универсальный resource.
877
- transport: {
878
- publish: ...
879
- }
880
- ```
881
-
882
- Лучше использовать `resource`:
883
-
884
- ```ts
885
- operations: {
886
- mutations: {
887
- publish: createResourceMutationOperation(...)
888
- }
889
- }
890
- ```
891
-
892
- ### Не импортировать TanStack Start в shared
893
-
894
- ```ts
895
- // Плохо: shared начнёт зависеть от SSR runtime.
896
- import { createServerFn } from "@tanstack/react-start";
897
- ```
898
-
899
- В shared передаётся уже созданная serverFn через `ServerFnTransport`.
900
-
901
- ### Не приводить внешний payload без parser-а
902
-
903
- ```ts
904
- // Плохо: внешний контракт не проверяется.
905
- const payload = await httpFetchPayload("/api/profile");
906
- return payload as Profile;
907
- ```
908
-
909
- Лучше:
910
-
911
- ```ts
912
- const payload = await httpFetchPayload("/api/profile");
913
- return parseProfile(payload);
914
- ```
915
-
916
- ## Ответ на частый вопрос: почему `odata/transport` не использует `http`
917
-
918
- Это сделано намеренно в текущей версии слоя.
919
-
920
- `shared/api/http` — минимальный HTTP transport для обычных endpoint-ов. Он умеет получить `Response` или payload, но не знает ничего про SAP/OData.
921
-
922
- `shared/api/odata/transport` — stateful transport для SAP Gateway. Он дополнительно отвечает за:
923
-
924
- - выбор OData base URL;
925
- - SAP client;
926
- - X-CSRF token cache;
927
- - повторное получение CSRF token;
928
- - обнаружение HTML/SAML2 формы вместо JSON;
929
- - восстановление SSO-сессии;
930
- - `SsoRequiredError`;
931
- - OData envelope `{ d, results, __count }`;
932
- - report unexpected HTML response;
933
- - поддержку `x-sw-cache` в OData-запросах.
934
-
935
- Если заставить OData transport использовать текущий `httpFetchPayload`, он потеряет доступ к части lifecycle на уровне `Response` и ошибочно смешает generic HTTP error policy с SAP/OData policy.
936
-
937
- Теоретически можно выделить ещё более низкий primitive, например `httpFetchRaw`, который только вызывает `fetch` и не парсит payload. Но это отдельный рефакторинг. Пока разделение намеренное: `http` для neutral HTTP, `odata/transport` для SAP/OData.