@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
|
@@ -85,6 +85,64 @@ type ErrorReportDeliveryBody = {
|
|
|
85
85
|
|
|
86
86
|
`JSON.stringify` может выбросить ошибку при циклических или неподдерживаемых данных. Draft должен соответствовать сериализуемому контракту `foundation-lib`.
|
|
87
87
|
|
|
88
|
+
### Что такое draft и delivery body
|
|
89
|
+
|
|
90
|
+
Для начинающего разработчика важно не смешивать две формы:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
ErrorReportDraft
|
|
94
|
+
├── локальный lifecycle status
|
|
95
|
+
├── retry information
|
|
96
|
+
├── collected/sanitized payload
|
|
97
|
+
└── identifiers
|
|
98
|
+
|
|
99
|
+
ErrorReportDeliveryBody
|
|
100
|
+
├── плоские обязательные backend fields
|
|
101
|
+
└── payload: JSON string
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Draft принадлежит локальному хранилищу `foundation-lib`. Delivery body — одноразовый snapshot для transport adapter-а.
|
|
105
|
+
|
|
106
|
+
Нельзя отправлять draft целиком только потому, что он уже похож на body: backend contract намеренно отделён от local store contract.
|
|
107
|
+
|
|
108
|
+
### Соответствие полей
|
|
109
|
+
|
|
110
|
+
| Delivery field | Источник |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `reportId` | `draft.reportId` |
|
|
113
|
+
| `sessionId` | `draft.sessionId` |
|
|
114
|
+
| `createdUtc` | `draft.createdUtc` |
|
|
115
|
+
| `category` | `draft.category` |
|
|
116
|
+
| `errorClass` | `draft.payload.error.name` |
|
|
117
|
+
| `errorMessage` | `draft.payload.error.message` или fallback |
|
|
118
|
+
| `stackTrace` | `draft.payload.error.stackTrace` |
|
|
119
|
+
| `payload` | `JSON.stringify(normalized payload)` |
|
|
120
|
+
|
|
121
|
+
Если message содержит непустой текст с пробелами по краям, проверка считает его валидным, но в delivery сохраняется исходная строка. Функция не выполняет общий trim draft-а.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
draft.payload.error.message = " Ошибка сохранения ";
|
|
125
|
+
|
|
126
|
+
const body = createErrorReportDeliveryBody(draft);
|
|
127
|
+
body.errorMessage;
|
|
128
|
+
// " Ошибка сохранения "
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Если message пустой или whitespace-only:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
draft.payload.error.message = " ";
|
|
135
|
+
|
|
136
|
+
const body = createErrorReportDeliveryBody(draft);
|
|
137
|
+
body.errorMessage;
|
|
138
|
+
// "Неизвестная ошибка"
|
|
139
|
+
|
|
140
|
+
JSON.parse(body.payload).error.message;
|
|
141
|
+
// "Неизвестная ошибка"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Функция создаёт новые wrapper objects и не записывает fallback обратно в draft.
|
|
145
|
+
|
|
88
146
|
## `sendErrorReport`
|
|
89
147
|
|
|
90
148
|
Lifecycle:
|
|
@@ -113,6 +171,135 @@ status sent/sending ───────► текущий draft, adapter не
|
|
|
113
171
|
|
|
114
172
|
Adapter получает исходный snapshot `draft`, прочитанный до перевода store в `sending`. Используйте `body` как delivery payload, а context — для query invalidation или transport, которому нужен `QueryClient`.
|
|
115
173
|
|
|
174
|
+
### Status table
|
|
175
|
+
|
|
176
|
+
| Status draft до вызова | Вызывается adapter | Результат |
|
|
177
|
+
| --- | --- | --- |
|
|
178
|
+
| draft отсутствует | нет | `undefined` |
|
|
179
|
+
| `sent` | нет | текущий draft |
|
|
180
|
+
| `sending` | нет | текущий draft |
|
|
181
|
+
| `pending` или эквивалентный начальный | да | `sent` либо exception + `failed` |
|
|
182
|
+
| `failed` | да | повторная попытка |
|
|
183
|
+
|
|
184
|
+
Функция не создаёт draft. Если `reportId` ошибочный, это не exception: result равен `undefined`.
|
|
185
|
+
|
|
186
|
+
### Время отправки
|
|
187
|
+
|
|
188
|
+
`sentUtc` создаётся на client после успешного завершения adapter-а:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
new Date().toISOString()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Это client timestamp, а не server acknowledgement time. Для аудита используйте server time из backend record.
|
|
195
|
+
|
|
196
|
+
### Ошибка построения body
|
|
197
|
+
|
|
198
|
+
`createErrorReportDeliveryBody` вызывается внутри `try`. Если `JSON.stringify` выбросил error:
|
|
199
|
+
|
|
200
|
+
- adapter не вызывается;
|
|
201
|
+
- draft становится `failed`;
|
|
202
|
+
- `failedReason` формируется через `createErrorInfo`;
|
|
203
|
+
- исходный error снова выбрасывается caller-у.
|
|
204
|
+
|
|
205
|
+
### UI flow
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
import { useState } from "react";
|
|
209
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
210
|
+
import { sendErrorReport } from "@ryuzaki13/react-foundation-api/error-report";
|
|
211
|
+
|
|
212
|
+
function SendReportButton({ reportId }: { reportId: string }) {
|
|
213
|
+
const queryClient = useQueryClient();
|
|
214
|
+
const [isSending, setIsSending] = useState(false);
|
|
215
|
+
const [errorMessage, setErrorMessage] = useState<string>();
|
|
216
|
+
|
|
217
|
+
const handleSend = async () => {
|
|
218
|
+
setIsSending(true);
|
|
219
|
+
setErrorMessage(undefined);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const result = await sendErrorReport(reportId, {
|
|
223
|
+
adapter: deliveryAdapter,
|
|
224
|
+
queryClient
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!result) {
|
|
228
|
+
setErrorMessage("Черновик отчёта не найден");
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
setErrorMessage(
|
|
232
|
+
error instanceof Error ? error.message : "Не удалось отправить отчёт"
|
|
233
|
+
);
|
|
234
|
+
} finally {
|
|
235
|
+
setIsSending(false);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
<button type="button" disabled={isSending} onClick={handleSend}>
|
|
242
|
+
{isSending ? "Отправка…" : "Отправить отчёт"}
|
|
243
|
+
</button>
|
|
244
|
+
{errorMessage ? <p role="alert">{errorMessage}</p> : null}
|
|
245
|
+
</>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Local `isSending` нужен UI для немедленной блокировки повторного click. Draft status остаётся источником долгоживущего lifecycle, но component может не быть подписан на store.
|
|
251
|
+
|
|
252
|
+
## Варианты delivery adapter-а
|
|
253
|
+
|
|
254
|
+
### Обычный HTTP
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { httpFetchPayload } from "@ryuzaki13/react-foundation-api/http";
|
|
258
|
+
import type { ErrorReportDeliveryAdapter } from "@ryuzaki13/react-foundation-api/error-report";
|
|
259
|
+
|
|
260
|
+
export const deliveryAdapter: ErrorReportDeliveryAdapter = async (body) => {
|
|
261
|
+
await httpFetchPayload("/api/error-reports", {
|
|
262
|
+
init: {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { "Content-Type": "application/json" },
|
|
265
|
+
body: JSON.stringify(body)
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### OData FunctionImport
|
|
272
|
+
|
|
273
|
+
Если backend contract опубликован как FunctionImport, operation определяется metadata:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
import { odataFunctionImportFn } from "@ryuzaki13/react-foundation-api/odata";
|
|
277
|
+
import { wrapODataParams } from "@ryuzaki13/react-foundation-lib/odata-service";
|
|
278
|
+
|
|
279
|
+
const deliveryAdapter: ErrorReportDeliveryAdapter = async (body, { queryClient }) => {
|
|
280
|
+
await odataFunctionImportFn({
|
|
281
|
+
odata: {
|
|
282
|
+
service: "ZERROR_REPORT_SRV",
|
|
283
|
+
target: "SendErrorReport"
|
|
284
|
+
},
|
|
285
|
+
params: wrapODataParams({
|
|
286
|
+
ReportId: body.reportId,
|
|
287
|
+
Payload: body.payload
|
|
288
|
+
})
|
|
289
|
+
})({ client: queryClient });
|
|
290
|
+
};
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Это только пример mapping: точные parameter names должны соответствовать metadata вашего сервиса.
|
|
294
|
+
|
|
295
|
+
### Server function
|
|
296
|
+
|
|
297
|
+
Adapter может вызвать client-safe serverFn operation. Модуль error-report не знает о transport и не ограничивает его, пока adapter возвращает `Promise<void>`.
|
|
298
|
+
|
|
299
|
+
### Почему adapter ничего не возвращает
|
|
300
|
+
|
|
301
|
+
`sendErrorReport` управляет local draft lifecycle, а не server entity. Если backend вернул receipt, adapter может обработать его внутри: сохранить в отдельный Query cache, telemetry или domain store. Public delivery contract считает успехом fulfilled promise.
|
|
302
|
+
|
|
116
303
|
## Adapter с cache invalidation
|
|
117
304
|
|
|
118
305
|
```ts
|
|
@@ -124,10 +311,121 @@ const adapter: ErrorReportDeliveryAdapter = async (body, { queryClient }) => {
|
|
|
124
311
|
|
|
125
312
|
Если invalidation бросит ошибку, вся доставка считается неуспешной и draft станет `failed`, даже если backend уже принял отчёт. Для non-critical cache update обработайте ошибку внутри adapter-а.
|
|
126
313
|
|
|
314
|
+
Безопасный вариант для необязательного refresh:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
const adapter: ErrorReportDeliveryAdapter = async (body, { queryClient }) => {
|
|
318
|
+
await postReport(body);
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await queryClient.invalidateQueries({ queryKey: ["error-reports"] });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.warn("Отчёт отправлен, но cache не обновлён", error);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Решение зависит от contract: если fresh cache является частью успешной операции, не поглощайте ошибку.
|
|
329
|
+
|
|
127
330
|
## Повторная отправка и concurrency
|
|
128
331
|
|
|
129
332
|
После `failed` функцию можно вызвать повторно. Проверка `sending` защищает от обычного повторного нажатия, но это не распределённая блокировка: два практически одновременных вызова могут успеть прочитать старый статус до его обновления. Backend желательно сделать идемпотентным по `reportId`.
|
|
130
333
|
|
|
334
|
+
### Idempotency backend-а
|
|
335
|
+
|
|
336
|
+
Рекомендуемый server contract:
|
|
337
|
+
|
|
338
|
+
```text
|
|
339
|
+
UNIQUE(reportId)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Повтор с тем же `reportId` должен либо вернуть прежний успешный result, либо завершиться предсказуемым «already accepted», которое adapter считает успехом.
|
|
343
|
+
|
|
344
|
+
Это защищает сценарий:
|
|
345
|
+
|
|
346
|
+
1. Backend принял report.
|
|
347
|
+
2. Network response потерялся.
|
|
348
|
+
3. Client пометил draft как `failed`.
|
|
349
|
+
4. Пользователь повторил отправку.
|
|
350
|
+
|
|
351
|
+
Без idempotency server создаст duplicate.
|
|
352
|
+
|
|
353
|
+
### Несколько tabs
|
|
354
|
+
|
|
355
|
+
Status `sending` в local store не является cross-tab distributed lock, если storage implementation явно не гарантирует это. Idempotency по `reportId` остаётся обязательной защитой.
|
|
356
|
+
|
|
357
|
+
## Тестирование
|
|
358
|
+
|
|
359
|
+
### Body builder
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import { expect, it } from "vitest";
|
|
363
|
+
|
|
364
|
+
it("подставляет fallback message в оба места", () => {
|
|
365
|
+
const body = createErrorReportDeliveryBody({
|
|
366
|
+
...draftFixture,
|
|
367
|
+
payload: {
|
|
368
|
+
...draftFixture.payload,
|
|
369
|
+
error: {
|
|
370
|
+
...draftFixture.payload.error,
|
|
371
|
+
message: " "
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(body.errorMessage).toBe("Неизвестная ошибка");
|
|
377
|
+
expect(JSON.parse(body.payload).error.message).toBe("Неизвестная ошибка");
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Delivery lifecycle
|
|
382
|
+
|
|
383
|
+
В package/feature test проверяйте:
|
|
384
|
+
|
|
385
|
+
- отсутствующий id не вызывает adapter;
|
|
386
|
+
- `sent` не отправляется повторно;
|
|
387
|
+
- `sending` не отправляется повторно;
|
|
388
|
+
- success переводит status в `sent` и задаёт ISO `sentUtc`;
|
|
389
|
+
- adapter error переводит status в `failed`;
|
|
390
|
+
- исходный error сохраняет identity и выбрасывается повторно;
|
|
391
|
+
- retry после `failed` снова вызывает adapter.
|
|
392
|
+
|
|
393
|
+
Adapter удобно делать mock-функцией:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
const adapter = vi.fn<ErrorReportDeliveryAdapter>().mockResolvedValue(undefined);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Не помещайте реальные stack traces, cookies, tokens или пользовательские данные в test snapshots.
|
|
400
|
+
|
|
401
|
+
## Частые ошибки
|
|
402
|
+
|
|
403
|
+
### Передавать draft вместо body
|
|
404
|
+
|
|
405
|
+
Adapter contract уже получает подготовленный `body`. Не сериализуйте `context.draft` вместо него.
|
|
406
|
+
|
|
407
|
+
### Дважды сериализовать `payload`
|
|
408
|
+
|
|
409
|
+
`body.payload` уже string:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
JSON.stringify(body)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
правильно сериализует outer HTTP body. Не делайте `body.payload = JSON.stringify(body.payload)`.
|
|
416
|
+
|
|
417
|
+
### Считать `sending` надёжной блокировкой
|
|
418
|
+
|
|
419
|
+
Это local lifecycle guard. Server idempotency всё равно нужна.
|
|
420
|
+
|
|
421
|
+
### Поглощать adapter error в UI
|
|
422
|
+
|
|
423
|
+
Функция намеренно rethrow-ит error. Покажите feedback и оставьте возможность retry.
|
|
424
|
+
|
|
425
|
+
### Добавлять transport в shared delivery module
|
|
426
|
+
|
|
427
|
+
Endpoint и auth policy специфичны проекту. Они должны оставаться adapter-ом host application.
|
|
428
|
+
|
|
131
429
|
## Ошибки и безопасность
|
|
132
430
|
|
|
133
431
|
- Ошибка adapter-а не поглощается: вызывающий код обязан её обработать.
|
|
@@ -135,6 +433,31 @@ const adapter: ErrorReportDeliveryAdapter = async (body, { queryClient }) => {
|
|
|
135
433
|
- `queryClient` передаётся adapter-у, но модуль сам не инвалидирует queries.
|
|
136
434
|
- Функция управляет локальным draft store; не вызывайте её в среде без подходящего storage/runtime, если draft store там недоступен.
|
|
137
435
|
- Статус `sent` означает успешное завершение adapter-а, а не обязательное подтверждение бизнес-обработки backend-ом.
|
|
436
|
+
- Delivery body содержит stack trace и diagnostic payload: передавайте его только по защищённому каналу авторизованному endpoint.
|
|
437
|
+
- Не логируйте `body.payload` целиком в production.
|
|
438
|
+
- Redaction должна происходить при сборе draft в `foundation-lib`; delivery boundary не может безопасно угадать все domain secrets.
|
|
439
|
+
|
|
440
|
+
## FAQ
|
|
441
|
+
|
|
442
|
+
### Почему функции нужен `QueryClient`, даже если adapter его не использует?
|
|
443
|
+
|
|
444
|
+
Это единый adapter context. Он позволяет OData operations и cache synchronization без глобального QueryClient.
|
|
445
|
+
|
|
446
|
+
### Можно ли отправить report, которого нет в store?
|
|
447
|
+
|
|
448
|
+
Нет. `sendErrorReport` принимает id существующего draft и вернёт `undefined`.
|
|
449
|
+
|
|
450
|
+
### Можно ли изменить body перед отправкой?
|
|
451
|
+
|
|
452
|
+
Да, внутри project adapter можно построить backend-specific envelope. Не мутируйте входной body; создайте новый object.
|
|
453
|
+
|
|
454
|
+
### Когда разрешён retry?
|
|
455
|
+
|
|
456
|
+
После status `failed`. `sent` и `sending` являются no-op states.
|
|
457
|
+
|
|
458
|
+
### Кто отвечает за offline queue?
|
|
459
|
+
|
|
460
|
+
Не этот модуль. Он хранит lifecycle одного draft и вызывает adapter. Background sync/retry scheduler должен быть отдельной явно спроектированной инфраструктурой.
|
|
138
461
|
|
|
139
462
|
## Полный API
|
|
140
463
|
|