@ryuzaki13/react-foundation-api 1.1.17 → 1.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adt/README.mdx +305 -8
- package/src/async/README.mdx +375 -0
- package/src/error-report/README.mdx +323 -0
- package/src/http/README.mdx +349 -0
- package/src/odata/README.mdx +4907 -555
- package/src/persisted/README.mdx +626 -0
- package/src/resource/README.mdx +462 -0
- package/src/server-fn/README.mdx +402 -0
- package/src/transport/README.mdx +345 -0
package/src/persisted/README.mdx
CHANGED
|
@@ -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 |
|