@ryuzaki13/react-foundation-api 1.1.17 → 1.1.19
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/foundationApi.mdx +2 -8
- 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/async/README.mdx
CHANGED
|
@@ -15,6 +15,19 @@ import { Meta } from "@storybook/addon-docs/blocks";
|
|
|
15
15
|
|
|
16
16
|
Обе функции выполняют задачи параллельно. Они не создают web workers и не ускоряют CPU-bound JavaScript: Promise-concurrency полезна прежде всего для I/O — HTTP, чтения storage и других async операций.
|
|
17
17
|
|
|
18
|
+
### Ментальная модель concurrency
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
Параллельно в Promise-смысле:
|
|
22
|
+
request A ───────────────►
|
|
23
|
+
request B ─────────►
|
|
24
|
+
request C ─────────────────────►
|
|
25
|
+
|
|
26
|
+
JavaScript thread при этом остаётся один.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Пока request ждёт сеть, event loop может продолжить другую задачу. Но три тяжёлых синхронных вычисления всё равно блокируют один thread по очереди.
|
|
30
|
+
|
|
18
31
|
## Импорт
|
|
19
32
|
|
|
20
33
|
```ts
|
|
@@ -115,6 +128,74 @@ TypeScript сужает union по `status`.
|
|
|
115
128
|
|
|
116
129
|
Пустой список возвращает успешный пустой результат. `runAsyncTasks` не имеет встроенного concurrency limit и AbortSignal.
|
|
117
130
|
|
|
131
|
+
### Полные типы task/result
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
type AsyncTask<TResult = unknown> = {
|
|
135
|
+
readonly name: string;
|
|
136
|
+
readonly run: () => Promise<TResult>;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
type AsyncTaskSuccess<TResult> = {
|
|
140
|
+
readonly status: "fulfilled";
|
|
141
|
+
readonly index: number;
|
|
142
|
+
readonly name: string;
|
|
143
|
+
readonly data: TResult;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type AsyncTaskFailure = {
|
|
147
|
+
readonly status: "rejected";
|
|
148
|
+
readonly index: number;
|
|
149
|
+
readonly name: string;
|
|
150
|
+
readonly error: unknown;
|
|
151
|
+
};
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Имена не обязаны быть уникальными. Для надёжной identity используйте `index`, если caller допускает повторяющиеся names.
|
|
155
|
+
|
|
156
|
+
### Синхронный throw внутри `run`
|
|
157
|
+
|
|
158
|
+
Хотя тип обещает Promise, runtime callback может бросить сразу:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
{
|
|
162
|
+
name: "invalid",
|
|
163
|
+
run: () => {
|
|
164
|
+
throw new Error("Ошибка до Promise");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Runner ловит такой throw так же, как rejected Promise, и помещает его в `AsyncTasksError.result.errors`.
|
|
170
|
+
|
|
171
|
+
### Все задачи действительно стартуют сразу
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
await runAsyncTasks(tenThousandRequests);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
создаст попытку запуска всех 10 000 operations. Это может перегрузить browser, backend или connection pool. Для большого массива используйте `runConcurrentTasks`.
|
|
178
|
+
|
|
179
|
+
### Homogeneous result type
|
|
180
|
+
|
|
181
|
+
Один вызов использует общий `TResult`. Если задачи возвращают принципиально разные shapes, создайте union/discriminated result либо выполняйте независимые batches:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
type StartupResult =
|
|
185
|
+
| { kind: "profile"; value: Profile }
|
|
186
|
+
| { kind: "settings"; value: Settings };
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Default error message
|
|
190
|
+
|
|
191
|
+
Если `options.errorMessage` отсутствует:
|
|
192
|
+
|
|
193
|
+
```text
|
|
194
|
+
Не удалось выполнить все асинхронные задачи.
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`AsyncTasksError.name` равен `"AsyncTasksError"`, а `result` содержит полный batch snapshot.
|
|
198
|
+
|
|
118
199
|
## `runConcurrentTasks`
|
|
119
200
|
|
|
120
201
|
### Ограничение одновременных запросов
|
|
@@ -193,6 +274,36 @@ type ConcurrentTaskProgress<TInput, TResult> = {
|
|
|
193
274
|
|
|
194
275
|
`percentage` округляется через `Math.round`.
|
|
195
276
|
|
|
277
|
+
### Точная последовательность callbacks
|
|
278
|
+
|
|
279
|
+
Для успешной task:
|
|
280
|
+
|
|
281
|
+
```text
|
|
282
|
+
task resolved
|
|
283
|
+
→ сохранить fulfilled item
|
|
284
|
+
→ увеличить succeeded
|
|
285
|
+
→ onItemSuccess(item)
|
|
286
|
+
→ уменьшить running
|
|
287
|
+
→ увеличить completed
|
|
288
|
+
→ onProgress(progress)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Для rejected task аналогично вызывается `onItemError`, затем `onProgress`.
|
|
292
|
+
|
|
293
|
+
Callbacks синхронные: runner не `await`-ит Promise, который callback мог случайно вернуть. Делайте их быстрыми state/logging callbacks.
|
|
294
|
+
|
|
295
|
+
Критически важно не бросать из callbacks. Если `onItemSuccess` бросит, worker попадёт в `catch`, перезапишет item как rejected и вызовет `onItemError`. Если затем бросит `onItemError` или `onProgress`, весь runner может reject. Кроме того, промежуточные progress counters могут стать нелогичными. Оборачивайте необязательную telemetry:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
onItemSuccess(item) {
|
|
299
|
+
try {
|
|
300
|
+
analytics.track("uploaded", { index: item.index });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.warn("Telemetry недоступна", error);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
196
307
|
### Abort
|
|
197
308
|
|
|
198
309
|
```ts
|
|
@@ -218,10 +329,133 @@ const result = await promise;
|
|
|
218
329
|
|
|
219
330
|
Abort не является ошибкой runner-а и сам по себе не приводит к `throw`.
|
|
220
331
|
|
|
332
|
+
### Signal уже aborted до запуска
|
|
333
|
+
|
|
334
|
+
Для непустого input ни одна task не стартует:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const controller = new AbortController();
|
|
338
|
+
controller.abort();
|
|
339
|
+
|
|
340
|
+
const result = await runConcurrentTasks([1, 2, 3], task, {
|
|
341
|
+
signal: controller.signal
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
result.aborted; // true
|
|
347
|
+
result.settled; // []
|
|
348
|
+
result.unprocessed; // все три input с индексами
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Initial progress event `0%` не генерируется.
|
|
352
|
+
|
|
353
|
+
### Abort уже начатых tasks
|
|
354
|
+
|
|
355
|
+
Runner только прекращает выдавать новые inputs workers. Реальная отмена зависит от task:
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
// Signal используется — fetch может прерваться.
|
|
359
|
+
async (url, { signal }) => fetch(url, { signal })
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
// Signal игнорируется — task завершится несмотря на abort batch-а.
|
|
364
|
+
async (url) => fetch(url)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Если task с signal отклоняется AbortError, она попадёт в `errors`, а ещё не стартовавшие inputs — в `unprocessed`.
|
|
368
|
+
|
|
221
369
|
### Порядок результата
|
|
222
370
|
|
|
223
371
|
Tasks могут завершаться в любом порядке, callbacks идут в порядке фактического завершения. Итоговые `settled`, `successes`, `errors` и `unprocessed` собираются по исходным индексам, поэтому результат детерминирован относительно input.
|
|
224
372
|
|
|
373
|
+
### Полный result
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
type RunConcurrentTasksResult<TInput, TResult> = {
|
|
377
|
+
settled: ConcurrentTaskSettled<TInput, TResult>[];
|
|
378
|
+
successes: ConcurrentTaskSuccess<TInput, TResult>[];
|
|
379
|
+
errors: ConcurrentTaskError<TInput>[];
|
|
380
|
+
unprocessed: ConcurrentTaskUnprocessed<TInput>[];
|
|
381
|
+
aborted: boolean;
|
|
382
|
+
summary: {
|
|
383
|
+
total: number;
|
|
384
|
+
completed: number;
|
|
385
|
+
succeeded: number;
|
|
386
|
+
failed: number;
|
|
387
|
+
unprocessed: number;
|
|
388
|
+
};
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Invariant после нормального завершения:
|
|
393
|
+
|
|
394
|
+
```text
|
|
395
|
+
summary.total = summary.completed + summary.unprocessed
|
|
396
|
+
summary.completed = summary.succeeded + summary.failed
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Пустой input
|
|
400
|
+
|
|
401
|
+
Возвращается empty result. Если signal уже aborted, `aborted` будет `true`; иначе `false`. `percentage` отсутствует в final summary, а progress callback не вызывается.
|
|
402
|
+
|
|
403
|
+
## Практический пример: upload batch
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
type UploadInput = {
|
|
407
|
+
id: string;
|
|
408
|
+
file: File;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const result = await runConcurrentTasks(
|
|
412
|
+
files,
|
|
413
|
+
async ({ id, file }, { signal }) => {
|
|
414
|
+
const body = new FormData();
|
|
415
|
+
body.set("file", file);
|
|
416
|
+
|
|
417
|
+
const response = await fetch(`/api/files/${encodeURIComponent(id)}`, {
|
|
418
|
+
method: "PUT",
|
|
419
|
+
body,
|
|
420
|
+
signal
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
throw new Error(`HTTP ${response.status}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { id, uploaded: true };
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
concurrency: 3,
|
|
431
|
+
signal: controller.signal,
|
|
432
|
+
onProgress: ({ completed, total, percentage }) => {
|
|
433
|
+
console.log(`${completed}/${total}: ${percentage}%`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Retry только failures
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const retryInputs = result.errors.map((item) => item.input);
|
|
443
|
+
|
|
444
|
+
const retryResult = await runConcurrentTasks(retryInputs, uploadTask, {
|
|
445
|
+
concurrency: 2
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
У write operations retry безопасен только при idempotent server contract. Потерянный response не доказывает, что первая запись не произошла.
|
|
450
|
+
|
|
451
|
+
### Продолжить unprocessed после abort
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
const remainingInputs = result.unprocessed.map((item) => item.input);
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Не добавляйте автоматически `errors` к `unprocessed`: rejected task уже запускалась и могла частично изменить backend.
|
|
458
|
+
|
|
225
459
|
## `ConcurrencyPartialError`
|
|
226
460
|
|
|
227
461
|
`runConcurrentTasks` сам не создаёт этот error. Класс нужен, если слой приложения хочет преобразовать частичный результат в исключение:
|
|
@@ -236,6 +470,125 @@ if (result.errors.length > 0 || result.aborted) {
|
|
|
236
470
|
|
|
237
471
|
Не путайте его с `AsyncTasksError`: первый создаёт consumer по своей policy, второй автоматически бросает `runAsyncTasks`.
|
|
238
472
|
|
|
473
|
+
`ConcurrencyPartialError.name` равен `"ConcurrencyPartialError"`, field `result` сохраняет переданный snapshot без преобразований.
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
try {
|
|
477
|
+
const result = await runConcurrentTasks(inputs, task);
|
|
478
|
+
|
|
479
|
+
if (result.errors.length || result.aborted) {
|
|
480
|
+
throw new ConcurrencyPartialError(
|
|
481
|
+
"Не все файлы обработаны",
|
|
482
|
+
result
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (error instanceof ConcurrencyPartialError) {
|
|
487
|
+
showPartialResult(error.result);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## Выбор `concurrency`
|
|
496
|
+
|
|
497
|
+
Нет универсального максимума:
|
|
498
|
+
|
|
499
|
+
| Тип работы | Стартовая оценка |
|
|
500
|
+
| --- | --- |
|
|
501
|
+
| Тяжёлые SAP write requests | 2–4 |
|
|
502
|
+
| Небольшие independent reads | 4–8 |
|
|
503
|
+
| Local async storage | зависит от adapter-а |
|
|
504
|
+
| CPU-bound parsing | runner не решает задачу |
|
|
505
|
+
|
|
506
|
+
Начните с малого значения, измерьте latency/error rate и согласуйте лимит с backend.
|
|
507
|
+
|
|
508
|
+
`concurrency: 1` полезен для последовательной обработки независимых элементов с единым result contract. Но если шаг B зависит от output A, обычный последовательный workflow будет понятнее.
|
|
509
|
+
|
|
510
|
+
## Тестирование
|
|
511
|
+
|
|
512
|
+
### `runAsyncTasks`
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
it("сохраняет partial result в AsyncTasksError", async () => {
|
|
516
|
+
const promise = runAsyncTasks([
|
|
517
|
+
{ name: "ok", run: async () => 1 },
|
|
518
|
+
{ name: "failed", run: async () => { throw new Error("boom"); } }
|
|
519
|
+
]);
|
|
520
|
+
|
|
521
|
+
await expect(promise).rejects.toMatchObject({
|
|
522
|
+
name: "AsyncTasksError",
|
|
523
|
+
result: {
|
|
524
|
+
summary: { total: 2, succeeded: 1, failed: 1 }
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Проверка лимита concurrency
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
it("не запускает больше двух tasks одновременно", async () => {
|
|
534
|
+
let running = 0;
|
|
535
|
+
let maximum = 0;
|
|
536
|
+
|
|
537
|
+
await runConcurrentTasks(
|
|
538
|
+
[1, 2, 3, 4],
|
|
539
|
+
async () => {
|
|
540
|
+
running += 1;
|
|
541
|
+
maximum = Math.max(maximum, running);
|
|
542
|
+
await Promise.resolve();
|
|
543
|
+
running -= 1;
|
|
544
|
+
return true;
|
|
545
|
+
},
|
|
546
|
+
{ concurrency: 2 }
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
expect(maximum).toBe(2);
|
|
550
|
+
});
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Покройте:
|
|
554
|
+
|
|
555
|
+
- empty input;
|
|
556
|
+
- validation `concurrency`;
|
|
557
|
+
- sync throw и rejected Promise;
|
|
558
|
+
- input order result-а;
|
|
559
|
+
- completion order callbacks;
|
|
560
|
+
- abort до старта;
|
|
561
|
+
- abort во время работы;
|
|
562
|
+
- task, игнорирующую signal;
|
|
563
|
+
- unprocessed indices;
|
|
564
|
+
- custom partial-error policy.
|
|
565
|
+
|
|
566
|
+
## Частые ошибки
|
|
567
|
+
|
|
568
|
+
### Использовать `runAsyncTasks` для тысяч requests
|
|
569
|
+
|
|
570
|
+
Он стартует всё сразу. Нужен bounded runner.
|
|
571
|
+
|
|
572
|
+
### Считать abort мгновенной отменой
|
|
573
|
+
|
|
574
|
+
Task обязана использовать signal.
|
|
575
|
+
|
|
576
|
+
### Бросать из progress callback
|
|
577
|
+
|
|
578
|
+
Callbacks являются частью worker execution и могут сломать batch.
|
|
579
|
+
|
|
580
|
+
### Считать individual errors исключением bounded runner-а
|
|
581
|
+
|
|
582
|
+
Читайте `result.errors`. Сам runner их не бросает.
|
|
583
|
+
|
|
584
|
+
### Мутировать input
|
|
585
|
+
|
|
586
|
+
Result сохраняет ссылку на исходный input. Mutation может неожиданно изменить UI/result history.
|
|
587
|
+
|
|
588
|
+
### Использовать batch как Query cache
|
|
589
|
+
|
|
590
|
+
Runner orchestrates promises, но не заменяет query identity, stale policy и cache ownership.
|
|
591
|
+
|
|
239
592
|
## Практические рекомендации
|
|
240
593
|
|
|
241
594
|
- Не ставьте слишком большой concurrency: browser, proxy и backend имеют свои лимиты.
|
|
@@ -245,6 +598,28 @@ if (result.errors.length > 0 || result.aborted) {
|
|
|
245
598
|
- Не используйте runner вместо TanStack Query cache для одного и того же resource.
|
|
246
599
|
- Если задача зависит от результата предыдущей, это не независимый batch: выполните зависимые шаги последовательно.
|
|
247
600
|
|
|
601
|
+
## FAQ
|
|
602
|
+
|
|
603
|
+
### Сохраняется ли порядок?
|
|
604
|
+
|
|
605
|
+
В final arrays — да, по input index. В callbacks — нет, они идут по завершению.
|
|
606
|
+
|
|
607
|
+
### Останавливается ли batch на первой task error?
|
|
608
|
+
|
|
609
|
+
Нет. Оба runner-а дают уже запущенным tasks завершиться. `runAsyncTasks` бросает aggregated error после всего batch, bounded runner возвращает errors.
|
|
610
|
+
|
|
611
|
+
### Можно ли менять concurrency во время запуска?
|
|
612
|
+
|
|
613
|
+
Нет. Значение фиксируется при старте.
|
|
614
|
+
|
|
615
|
+
### Есть ли automatic retry?
|
|
616
|
+
|
|
617
|
+
Нет. Он должен быть явным и безопасным для operation.
|
|
618
|
+
|
|
619
|
+
### Почему `error` имеет type `unknown`?
|
|
620
|
+
|
|
621
|
+
Promise может reject любым JS value. Сужайте/нормализуйте его перед UI/logging.
|
|
622
|
+
|
|
248
623
|
## Полный API
|
|
249
624
|
|
|
250
625
|
| Группа | Exports |
|