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