@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.
@@ -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