@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.
- package/README.md +32 -43
- package/dist/chunks/{odataFetchFn-vnAXC-c0.js → odataFetchFn-B9wSQpUS.js} +11 -11
- package/dist/chunks/{odataFetchFn-vnAXC-c0.js.map → odataFetchFn-B9wSQpUS.js.map} +1 -1
- package/dist/odata/fetchCollectionData.d.ts +1 -1
- package/dist/odata/fetchCollectionData.d.ts.map +1 -1
- package/dist/odata/index.js +111 -112
- package/dist/odata/index.js.map +1 -1
- package/dist/odata/projectODataCollectionSort.d.ts +1 -1
- package/dist/odata/projectODataCollectionSort.d.ts.map +1 -1
- package/dist/odata/types.d.ts +1 -2
- package/dist/odata/types.d.ts.map +1 -1
- package/dist/odata/useODataCollection.d.ts +1 -1
- package/dist/odata/useODataCollection.d.ts.map +1 -1
- package/dist/odata/useODataCollectionQuery.d.ts +1 -1
- package/dist/odata/useODataCollectionQuery.d.ts.map +1 -1
- package/dist/odata/useODataEntity.d.ts +1 -1
- package/dist/odata/useODataEntity.d.ts.map +1 -1
- package/dist/persisted/index.js +1 -1
- package/package.json +2 -2
- package/src/adt/README.mdx +461 -0
- package/src/async/README.mdx +628 -0
- package/src/error-report/README.mdx +471 -0
- package/src/foundationApi.mdx +123 -0
- package/src/http/README.mdx +570 -0
- package/src/odata/README.mdx +5142 -0
- package/src/persisted/README.mdx +1080 -0
- package/src/resource/README.mdx +820 -0
- package/src/server-fn/README.mdx +596 -0
- package/src/transport/README.mdx +528 -0
- package/src/README.md +0 -937
- package/src/async/README.md +0 -623
- package/src/async/async.mdx +0 -6
- package/src/odata/README.md +0 -761
- package/src/odata/odataFetchFn.mdx +0 -6
- package/src/persisted/README.md +0 -598
- 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` |
|