@andrey4emk/npm-app-back-b24 3.8.2 → 3.9.0
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 +67 -10
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +358 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +78 -0
- package/bitrix24/b24/retry.ts +478 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +278 -0
- package/bitrix24/b24/types.ts +66 -0
- package/bitrix24/b24.ts +60 -949
- package/bitrix24/eventB24.ts +233 -31
- package/package.json +10 -2
- package/sendMessage/email.ts +233 -32
- package/utils/fetchRetry.ts +70 -9
package/README.md
CHANGED
|
@@ -191,11 +191,21 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
191
191
|
|
|
192
192
|
Поведение прежнее у четырёх методов из пяти. **`callListMethod` отличается в двух местах:** он бросает внятную ошибку с именем метода там, где SDK падал `TypeError` из своих недр (портал вернул «мягкую» ошибку, ответ не список, конверт нечитаем, `next` не растёт), и у него есть потолок в 1000 страниц — при упоре в него вызов тоже бросает, а не отдаёт молча обрезанный список. Если у вас есть `try`/`catch` вокруг постраничных выборок, текст ошибки изменится; если его нет — падение станет заметнее, но не появится там, где раньше всё работало.
|
|
193
193
|
|
|
194
|
+
**`fetchListMethod` теперь тоже свой:** keyset-цикл пакета вместо `actions.v2.fetchList.make`. Форма запроса прежняя — тот же курсор по `idKey`, тот же навязанный порядок сортировки. Появился постраничный повтор (см. ниже), стали внятными ошибки (имя метода B24 в тексте вместо `TypeError` из недр SDK и `SdkError` с внутренним кодом библиотеки; если вы разбирали `error.code` у этого метода, разбор придётся заменить на чтение сообщения) и изменились правила остановки:
|
|
195
|
+
|
|
196
|
+
- **выборку завершает только пустая страница.** Раньше, как в SDK, цикл останавливался ещё и на короткой — меньше 50 строк. У портала страница, укороченная правами доступа, штатна, поэтому старое правило могло молча отдать полсотни записей вместо нескольких тысяч. Цена нового — один лишний запрос на всю выборку, всегда пустой;
|
|
197
|
+
- **нечитаемый курсор бросает.** Если в строках ответа нет числового идентификатора по ключу курсора, продолжить пагинацию нечем — вместо тихой остановки (SDK пишет предупреждение и выходит) выборка прерывается ошибкой «выборка прервана как неполная». Неполный список, отданный как полный, хуже громкого отказа;
|
|
198
|
+
- **есть потолок в 20 000 страниц** и **проверка роста курсора** — обе бросают. Портал, отбросивший неизвестный ключ фильтра, отдаёт одну и ту же страницу бесконечно, без ошибки и без строки в логе; у SDK от этого нет никакой страховки.
|
|
199
|
+
|
|
200
|
+
**Ограничение по ключу курсора.** Один и тот же ключ идёт и на чтение идентификатора из строки ответа, и в `order`/`filter`. Методы, у которых имя поля в ответе отличается от сортируемого — например `tasks.task.list`, где в ответе `id`, а в фильтре `ID`, — этим методом не выбираются: передача `idKey: "id"` уберёт ошибку, но запрос уйдёт с параметрами, которых метод не понимает. Для таких выборок берите `callListMethod` или `actions.v2.fetchList.make` с опцией `cursorIdKey`. Ключ `>ID` в вашем фильтре пагинация перетирает своим курсором — об этом теперь пишется предупреждение уровня `warn`, как и про игнорируемый `order`.
|
|
201
|
+
|
|
194
202
|
Прямой доступ к `$b24.actions.*` остаётся: это сырой SDK **без** retry и гейта идемпотентности. Если защита нужна, оборачивайте вызов в `callProtected()`.
|
|
195
203
|
|
|
196
204
|
**Retry при сетевых ошибках:**
|
|
197
205
|
|
|
198
|
-
Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`).
|
|
206
|
+
Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`). `callBatchByChunk` пакет реализует, но не ретраит.
|
|
207
|
+
|
|
208
|
+
Метод `fetchListMethod` Proxy снаружи не оборачивает — это async-генератор, обёртка в async-функцию сломала бы `for await`. Повтор у него внутренний и **постраничный**: обрыв на двенадцатой странице повторяет двенадцатую страницу, а не всю выборку. Уже отданные страницы не теряются и не дублируются, потому что курсор двигается только после успешной страницы. Раньше одиночный сбой обрывал выборку целиком, и прочитанное потребитель либо терял, либо принимал за полный результат.
|
|
199
209
|
|
|
200
210
|
`callListMethod` повторяется целиком: при сетевом сбое пагинация начинается с нулевой страницы заново. Постраничный retry был бы сменой семантики, поэтому поведение оставлено прежним.
|
|
201
211
|
|
|
@@ -208,7 +218,19 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
208
218
|
|
|
209
219
|
**Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
|
|
210
220
|
|
|
211
|
-
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала.
|
|
221
|
+
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. Обрыв приходит и когда соединение не состоялось, и когда оно случилось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
222
|
+
|
|
223
|
+
Ключевое слово ищется **в любой позиции** имени, а не только в конце: `imconnector.send.messages` и `imconnector.send.status.delivery` тоже под гейтом. После ключевого слова обязателен разделитель (`.`, `?` или конец строки), поэтому `crm.deal.addcustom` и `landing.landing.addbytemplate` повторяются как прежде.
|
|
224
|
+
|
|
225
|
+
Попадание `imconnector.send.status.delivery` под гейт — осознанная плата за правило без списка исключений. Метод лишь фиксирует доставку и отмечает сообщение прочитанным, повтор его безвреден, а отказ от повтора стоит только потерянной отметки о доставке: переписку это не ломает. Ручной же список исключений молча устаревал бы при появлении нового метода портала — ровно тот дефект, которым и был пропуск `imconnector.send.messages` мимо гейта.
|
|
226
|
+
|
|
227
|
+
**Отдельный список методов, теряющих данные при повторе.** Пока в нём один `event.offline.get`: вызов резервирует пакет очереди офлайн-событий и прячет его от следующих запросов, поэтому потерянный ответ уносит `process_id` с собой, а повтор зарезервировал бы ещё один пакет поверх первого. Сущностей метод не создаёт, и в логе это видно по фразе: `повтор отменён, вызов резервирует данные на портале (event.offline.get)`.
|
|
228
|
+
|
|
229
|
+
**Исключение для обоих ограничений — доказанный pre-connection.** Коды `ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETDOWN` означают, что соединение с целевым хостом не состоялось: дубликата повтор дать не может. Такие вызовы повторяются все пять раз, а в warn-строке появляется хвост `— соединение не состоялось, ограничение (...) снято`.
|
|
230
|
+
|
|
231
|
+
Следствие для вашего кода: после снятия гейта отказ вызова больше не означает, что на портале ничего не произошло — первая попытка могла упасть на `ECONNREFUSED`, а вторая дойти и оборваться уже на ответе. Дубликата пакет при этом не создаёт, но потребитель с собственным повтором поверх пакета создаст.
|
|
232
|
+
|
|
233
|
+
**Провал обмена refresh-токена повторов не получает.** Когда обмен падает внутри вызова, попытка ровно одна, а в лог идёт строка уровня `error` с исходным кодом и статусом ответа oauth-хоста: `обновление токена не удалось (invalid_grant/400), повтор небезопасен: сервер мог провести ротацию`. Под правило попадает **любой** отказ обмена, кроме доказанного pre-connection, — и мёртвый токен после ротации, и транзиентные 429/503 от oauth-хоста: различить их по ответу нельзя. Если ротация прошла, локальный `refresh_token` мёртв независимо от повтора и нужна переавторизация приложения на портале. DNS-сбой на oauth-хосте под это правило не попадает: соединение не состоялось, значит повторяем.
|
|
212
234
|
|
|
213
235
|
Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
|
|
214
236
|
|
|
@@ -220,6 +242,8 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
220
242
|
|
|
221
243
|
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
|
|
222
244
|
|
|
245
|
+
Над обменом стоит **сторожевой таймер** с бюджетом 2 минуты. Он нужен потому, что сам обмен идёт внутри SDK отдельным HTTP-клиентом без таймаута: зависшее соединение раньше занимало мьютекс навсегда, все следующие обновления приклеивались к мёртвому запросу, и через час процесс начинал получать 401 на каждый вызов. Сторож освобождает **только** мьютекс — сам запрос не отменяется, и если ответ всё же придёт, токены сохранятся: колбэк сохранения SDK зовёт сам. Ждущий получает ошибку «обмен refresh-токена завис», повтор делает проактивный таймер. `reinitializeB24()` (а значит и сохранение токенов с фронта через `saveAuthB24Handler`) сбрасывает мьютекс принудительно и не ждёт зависший обмен.
|
|
246
|
+
|
|
223
247
|
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но на транспортных ошибках он выключен — там одна попытка, повторяет только Proxy. При 429, 503 и неизвестных 5xx SDK повторяет по-прежнему.
|
|
224
248
|
|
|
225
249
|
Отдельно про ответы шлюза перед порталом. **502** повторяет наш слой: шлюз не смог получить ответ от upstream, запрос почти наверняка не выполнялся. **504** не повторяет никто — это «портал взял запрос и считает прямо сейчас»; повтор создающего вызова дал бы дубликат, а читающего — ничего, кроме нагрузки на портал в худший для него момент.
|
|
@@ -316,7 +340,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
316
340
|
|
|
317
341
|
- **Параметры:**
|
|
318
342
|
- `run` (`() => Promise<T>`) — сам вызов.
|
|
319
|
-
- `methodName` (`string | string[]`) — имя REST-метода B24 (**не** метода SDK) либо список имён, если внутри batch
|
|
343
|
+
- `methodName` (`string | string[]`) — имя REST-метода B24 (**не** метода SDK) либо список имён, если внутри batch. По ним гейт решает не только «создаёт ли вызов сущность», но и «не входит ли он в список методов, повтор которых теряет данные» (`event.offline.get`).
|
|
320
344
|
- **Возвращает:** промис с результатом `run`.
|
|
321
345
|
|
|
322
346
|
```js
|
|
@@ -338,7 +362,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
338
362
|
|
|
339
363
|
Без обёртки вызов `$b24.actions.*` идёт мимо защиты: ни повторов при сетевых сбоях, ни блокировки повтора для создающих методов.
|
|
340
364
|
|
|
341
|
-
- **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`.
|
|
365
|
+
- **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`. Все пять сигнатур объявлены в интерфейсе явно, а не взяты по наследству: в SDK 3.x этих методов у базового класса не будет, и без явных объявлений тип потерял бы весь фасад. Держит их тест `tests/client-type.test.ts`: он читает текст `types.ts` (пропажу объявления система типов не видит — метод пока наследуется) и сверяет кортежи параметров строгим равенством типов, поэтому потеря необязательного аргумента или расширение параметра до `any` роняют `npx tsc --noEmit`.
|
|
342
366
|
|
|
343
367
|
```ts
|
|
344
368
|
import type { B24Client } from "@andrey4emk/npm-app-back-b24";
|
|
@@ -500,7 +524,12 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
500
524
|
```
|
|
501
525
|
|
|
502
526
|
- **Особенности:**
|
|
503
|
-
- **Автоматически отбрасывает и очищает события пользователя 138** — владельца OAuth-токенов приложений. Это защита от петли: всё, что приложения
|
|
527
|
+
- **Автоматически отбрасывает и очищает события пользователя 138** — владельца OAuth-токенов приложений. Это защита от петли: всё, что приложения и роботы пишут в B24 под этой учёткой, приходит событием с `user_id: '138'`. Собственные правки платформа приложению не отдаёт, а правки соседнего приложения под тем же владельцем — отдаёт, и без фильтра они обрабатывались бы как внешние изменения по кругу. Оборотная сторона: ручные правки под этой же учёткой (администратор портала) тоже не возвращаются. **Для полной выгрузки всех изменений класс не подходит** — там читать `event.offline.get` напрямую.
|
|
528
|
+
- **Битая запись в ветке сущностей не роняет весь вызов.** Битой считается запись, которую обработать нечем: не читается идентификатор сущности `EVENT_DATA.FIELDS.ID` (в том числе когда на его месте пришёл пустой массив — так PHP сериализует пустую коллекцию) или не читается ключ очистки `MESSAGE_ID`. Такая запись отбрасывается, её `MESSAGE_ID` уходит в очистку вместе с системными, а в лог пишется одна строка `warn` с причиной. Раньше такая запись валила `get()` исключением: пакет к тому моменту уже зарезервирован порталом, `processId` наружу не ушёл, и очистить его было нечем. Запись, у которой не читается сам `MESSAGE_ID`, только логируется — передавать в `clear()` нечего, снять её с резерва может лишь `clear(processId)` целиком.
|
|
529
|
+
- **Отсутствие `EVENT_ADDITIONAL.user_id` битой записью не является.** Идентификатор сущности на месте, значит запись обработать можно: она уходит потребителю как рабочая, а в лог пишется строка `warn` о том, что системной её признать нечем. Удалять такую запись нельзя — `event.offline.clear` снимает её с резерва безвозвратно.
|
|
530
|
+
- **Если битой оказалась вся выборка целиком** (больше одной записи и ни одной рабочей), в лог уходит строка уровня `error` с числом записей и причиной первой: так выглядит не одна кривая запись, а смена формы ответа портала. Очистку это не меняет — записи по-прежнему снимаются с резерва.
|
|
531
|
+
- **Сбой `event.offline.clear` не уносит выборку.** Исключение перехватывается, пишется строка `warn`, активные записи возвращаются потребителю. Иначе он остался бы и без `processId`, и без событий, а пакет уже зарезервирован порталом. Если в ответе нет `process_id`, очистка пропускается, а в лог уходит одна строка `warn` на весь вызов: записи придут следующим опросом и будут обработаны повторно.
|
|
532
|
+
- Значение `user_id` сравнивается после нормализации, поэтому системным считается и строка `'138'`, и число `138`. Портал сегодня отдаёт это поле строкой; форма поля на защиту от петли влиять не должна.
|
|
504
533
|
- Для коннекторных событий конвертирует файлы в формат с типами `image`, `video`, `document`.
|
|
505
534
|
- Если событий нет, возвращает `data` с пустыми массивами.
|
|
506
535
|
- **Гранулярность очистки — запись очереди, а не сообщение.** Одна запись коннектора (один `MESSAGE_ID`) может нести несколько сообщений, и они приходят отдельными элементами `message` с одинаковым `messageId`. Очистка по этому ключу удаляет запись целиком: если из двух сообщений одной записи ушло одно, `clear()` заберёт и неотправленное. Строить поэлементную очистку с точностью до сообщения на `messageId` нельзя. Для дедупликации отдельных сообщений он не годится по той же причине — у сообщений одной записи он общий, для этого есть `im.id`. Обратная сторона: запись, из которой не разобрано ни одного сообщения, в `data.message` не попадёт вовсе, и очистка по собранным `messageId` её не заберёт. Безопасный режим — `clear(processId)` целиком.
|
|
@@ -694,8 +723,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
694
723
|
- `text` (string) — текстовая версия.
|
|
695
724
|
- `html` (string) — опционально, HTML-версия.
|
|
696
725
|
- `fileUrl` (string) — опционально, ссылка на файл (будет скачан и прикреплён).
|
|
697
|
-
- `fileName` (string) — опционально, имя файла для вложения.
|
|
698
|
-
|
|
726
|
+
- `fileName` (string) — опционально, имя файла для вложения. Не задано — имя выводится из последнего сегмента пути в `fileUrl`, а если и оттуда не выходит, вложение называется `attachment`.
|
|
727
|
+
- `attachments` (array) — опционально, готовые вложения вида `{ filename, content }`, где `content` — `Buffer`. Скачанное по `fileUrl` **добавляется** к ним, а не заменяет: оба поля независимы, и заполнивший оба получает оба вложения. Порядок — сначала переданные, затем скачанное.
|
|
728
|
+
- **Возвращает:** промис с объектом `{ error, info }` при успехе или `{ error, message }` при ошибке. `error: true` приходит и тогда, когда SMTP принял копию отправителю, но **отверг получателя**: в `to` всегда уходит пара «получатель + отправитель», а `sendMail` резолвится, если принят хотя бы один адрес.
|
|
699
729
|
|
|
700
730
|
```js
|
|
701
731
|
const result = await emailClient.send({
|
|
@@ -709,6 +739,10 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
709
739
|
- **Особенности:**
|
|
710
740
|
- SMTP-сервер: `smtp.yandex.ru:465` (SMTPS).
|
|
711
741
|
- Копия письма автоматически отправляется на адрес отправителя.
|
|
742
|
+
- **Адрес получателя проверяется до вызова транспорта.** Пустая строка, строка с пробелом, адрес без собаки или с пустой частью слева либо справа дают `{ error: true }`, и письмо не отправляется вовсе — раньше такой `to` давал «успешную» отправку копии себе. Проверка намеренно грубая: точка в домене не требуется, полную валидацию адреса делает почтовый сервер. Поле `to` принимает **ровно один адрес**: формы `Имя <адрес>` и перечисление через запятую не поддерживаются и дают `{ error: true }`.
|
|
743
|
+
- **Отвергнутый получатель — это ошибка.** Ответ транспорта разбирается: адрес ищется в `accepted` и `rejected`, элементы принимаются и строкой, и объектом `{ name, address }`. Текст ошибки несёт адрес и ответ сервера. Сверка идёт по **нормализованному** адресу: снимается регистр, снимаются угловые скобки, домен приводится к punycode. Иначе живой адрес объявлялся бы недоставленным — почтовый сервер возвращает адреса из конверта, а не в том виде, как их передали: `ivan@пример.рф` приходит обратно как `ivan@xn--e1afmkfd.xn--p1ai`, а `<a@b.ru>` — как `a@b.ru`. Если транспорт не сообщил ни `accepted`, ни `rejected`, результат считается успешным и в лог уходит одна строка `warn`: объявить доставленное письмо ошибкой было бы хуже исходного дефекта.
|
|
744
|
+
- **Входной объект не мутируется.** Прежняя реализация записывала вложения обратно в переданный `dataMail`, затирая `attachments` вызывающего.
|
|
745
|
+
- **Второй аргумент конструктора — подмена транспорта.** `new Email(auth, transport)` принимает любой объект с методом `sendMail(options)` (тип `MailTransport`). Нужен тестам и потребителю со своим SMTP; без него класс поднимает транспорт Яндекса сам.
|
|
712
746
|
- Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
|
|
713
747
|
- Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
|
|
714
748
|
- Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
|
|
@@ -880,7 +914,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
880
914
|
- **Свой `options.signal` отключает наш дефолт.** Если вызывающий передал сигнал именно в `options`, а `timeoutMs` не задан, пакет своего таймаута не добавляет — чужой осознанный бюджет не обрезается. Явно переданный `timeoutMs` применяется всегда, тогда побеждает тот сигнал, который сработал раньше. Отмена по чужому сигналу **никогда не повторяется**; различаем её по объекту сигнала, а не по типу ошибки: причиной отмены может быть любой объект, включая `TypeError`.
|
|
881
915
|
- **Сигнал у объекта `Request` на дефолт не влияет.** Он участвует в отмене — сигналы склеиваются через `AbortSignal.any`, иначе `fetch(request, init)` затёр бы его, — но признаком «вызывающий сам управляет временем» не считается: у любого `Request` сигнал есть всегда, даже когда его никто не задавал. Учитывать его значило бы молча снять таймаут со всех вызовов такой формы. Нужен свой бюджет при вызове с `Request` — передавайте `timeoutMs` пятым аргументом.
|
|
882
916
|
- **Таймаут при чтении тела не повторяется и не логируется.** Бюджет действует до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` после возврата из `fetchRetry` могут упасть с `TimeoutError` уже у вас. Функция об этом не знает: повторов там нет, записи в лог тоже. Читаете большое тело — задавайте `timeoutMs` с запасом на скачивание.
|
|
883
|
-
- **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов)
|
|
917
|
+
- **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов) берут свои значения из `FETCH_TIMEOUTS`, и каждое из них переопределяется отдельной переменной (см. ниже). Значение должно быть целым от 0 до 2 147 483 647 — непригодное отбрасывается с записью в лог, а не выключает защиту молча. Значение от 1 до 999 применяется **как есть**, но с записью `warn`: столько миллисекунд не хватит ни одному живому запросу, и это типовая путаница секунд с миллисекундами. Ноль под правило не попадает — это задокументированное выключение таймаута.
|
|
884
918
|
- **Ограничение при вызове с объектом `Request`:** тело такого запроса читается первой попыткой, и повтор упадёт с `body used already`. Существовало и до таймаутов; для повторяемых запросов передавайте строку URL и `options`.
|
|
885
919
|
|
|
886
920
|
- **`fetchWithTimeout(url, options?, timeoutMs?)`** — один запрос с бюджетом времени, **без повторов и без логирования**. Для неидемпотентных операций: отправка сообщения, создание сущности — там, где повтор мог бы продублировать действие, но защита от зависшего соединения нужна. Правила про чужой сигнал те же, что у `fetchRetry`. Внутри пакета через неё идут все отправки ChatApp, Wappi и SMSGold.
|
|
@@ -895,7 +929,20 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
895
929
|
);
|
|
896
930
|
```
|
|
897
931
|
|
|
898
|
-
- **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется
|
|
932
|
+
- **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется сам. Каждый переопределяется своей переменной окружения:
|
|
933
|
+
|
|
934
|
+
| Ключ | Переменная | Дефолт, мс | Где применяется |
|
|
935
|
+
| ---------- | -------------------------- | ---------- | ------------------------ |
|
|
936
|
+
| `quick` | `FETCH_TIMEOUT_QUICK_MS` | 10 000 | токены |
|
|
937
|
+
| `api` | `FETCH_TIMEOUT_API_MS` | 20 000 | короткое чтение |
|
|
938
|
+
| `send` | `FETCH_TIMEOUT_SEND_MS` | 30 000 | отправка |
|
|
939
|
+
| `transfer` | `FETCH_TIMEOUT_TRANSFER_MS`| 60 000 | файлы и вложения |
|
|
940
|
+
|
|
941
|
+
Значения читаются **один раз при загрузке модуля**, поэтому менять их нужно до старта процесса. Проверка та же, что у `FETCH_TIMEOUT_MS`: целое от 0 до 2 147 483 647, непригодное значение даёт строку `warn` и дефолт.
|
|
942
|
+
|
|
943
|
+
**`0` выключает таймаут для этого бюджета целиком.** У `send` и `transfer` это снимает заодно защиту «неизвестный статус доставки»: зависшая отправка снова держится до таймаутов undici, а `unknownDelivery` в `Smsgold` не выставится никогда. Пользоваться нулём только как аварийным рычагом.
|
|
944
|
+
|
|
945
|
+
**`DEFAULT_FETCH_TIMEOUT_MS`** — действующий дефолт `fetchRetry` (60 000 либо значение `FETCH_TIMEOUT_MS`).
|
|
899
946
|
|
|
900
947
|
- **`isTimeoutError(error)` / `isAbortError(error)`** — различают «не дождались» и «отменили». Ошибка таймаута не оборачивается в свою: наружу идёт исходный `DOMException` с `name: "TimeoutError"` (отмена без причины — `"AbortError"`), проверки читают именно `name`.
|
|
901
948
|
|
|
@@ -922,6 +969,10 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
922
969
|
| `APP_NAME` | Название приложения (используется в описании задач) |
|
|
923
970
|
| `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
|
|
924
971
|
| `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
|
|
972
|
+
| `FETCH_TIMEOUT_QUICK_MS` | Бюджет `FETCH_TIMEOUTS.quick` — токены, мс (по умолчанию `10000`) |
|
|
973
|
+
| `FETCH_TIMEOUT_API_MS` | Бюджет `FETCH_TIMEOUTS.api` — короткое чтение, мс (по умолчанию `20000`) |
|
|
974
|
+
| `FETCH_TIMEOUT_SEND_MS` | Бюджет `FETCH_TIMEOUTS.send` — отправка, мс (по умолчанию `30000`) |
|
|
975
|
+
| `FETCH_TIMEOUT_TRANSFER_MS` | Бюджет `FETCH_TIMEOUTS.transfer` — файлы и вложения, мс (по умолчанию `60000`) |
|
|
925
976
|
| `CHATAPP_EMAIL` | Email аккаунта ChatApp |
|
|
926
977
|
| `CHATAPP_PASS` | Пароль аккаунта ChatApp |
|
|
927
978
|
| `CHATAPP_APP_ID` | ID приложения в ChatApp |
|
|
@@ -937,6 +988,12 @@ APP_NAME=MyApp
|
|
|
937
988
|
CONFIG_DIR=../config
|
|
938
989
|
FETCH_TIMEOUT_MS=60000
|
|
939
990
|
|
|
991
|
+
# Именованные бюджеты пакета — задавать только при необходимости, иначе действуют дефолты
|
|
992
|
+
# FETCH_TIMEOUT_QUICK_MS=10000
|
|
993
|
+
# FETCH_TIMEOUT_API_MS=20000
|
|
994
|
+
# FETCH_TIMEOUT_SEND_MS=30000
|
|
995
|
+
# FETCH_TIMEOUT_TRANSFER_MS=60000
|
|
996
|
+
|
|
940
997
|
CHATAPP_EMAIL=your-email@example.com
|
|
941
998
|
CHATAPP_PASS=your-password
|
|
942
999
|
CHATAPP_APP_ID=your-app-id
|
|
@@ -944,7 +1001,7 @@ CHATAPP_APP_ID=your-app-id
|
|
|
944
1001
|
|
|
945
1002
|
## Скрипты
|
|
946
1003
|
|
|
947
|
-
- `npm run test` —
|
|
1004
|
+
- `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=20.3.0"` — это требование к потребителю пакета, а не к разработке). Сети тесты не касаются, в тарбол папка не попадает.
|
|
948
1005
|
- `npm run pack:dry` — предпросмотр содержимого пакета перед публикацией.
|
|
949
1006
|
|
|
950
1007
|
## Лицензия
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import dotEnv from "dotenv";
|
|
5
|
+
dotEnv.config();
|
|
6
|
+
|
|
7
|
+
// ==================== Константы ====================
|
|
8
|
+
|
|
9
|
+
export const CONFIG_DIR = process.env.CONFIG_DIR || "../config";
|
|
10
|
+
export const APP_ENV = process.env.APP_ENV || "PROD";
|
|
11
|
+
export const CLIENT_ID = process.env.APP_B24_CLIENT_ID;
|
|
12
|
+
export const CLIENT_SECRET = process.env.APP_B24_CLIENT_SECRET;
|
|
13
|
+
|
|
14
|
+
export const confAuthB24 = new Conf({
|
|
15
|
+
cwd: path.resolve(CONFIG_DIR),
|
|
16
|
+
configName: "authB24",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ==================== Утилиты ====================
|
|
20
|
+
|
|
21
|
+
/** Убирает протокол из домена (https://example.bitrix24.ru → example.bitrix24.ru) */
|
|
22
|
+
export function cleanDomain(domain: string): string {
|
|
23
|
+
return domain.replace(/^https?:\/\//, "");
|
|
24
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { Result } from "@bitrix24/b24jssdk";
|
|
2
|
+
import type { AjaxResult, TypeCallParams, B24OAuth } from "@bitrix24/b24jssdk";
|
|
3
|
+
import { logs } from "../../logs/logs.ts";
|
|
4
|
+
import { getRetryBlockReason, runWithRetry } from "./retry.ts";
|
|
5
|
+
import type { FacadeMethods, BatchCalls, V2Envelope } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// ==================== Константы ====================
|
|
8
|
+
|
|
9
|
+
/** Потолок числа страниц в callListMethod — страховка от бесконечной пагинации */
|
|
10
|
+
export const LIST_MAX_PAGES = 1000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Потолок числа страниц keyset-цикла в fetchListMethod — страховка от зацикливания,
|
|
14
|
+
* а не лимит выборки.
|
|
15
|
+
*
|
|
16
|
+
* Своя константа, не общая LIST_MAX_PAGES: у callListMethod потолок продиктован ростом
|
|
17
|
+
* массива в памяти, он копит весь список. fetchListMethod — генератор, он существует
|
|
18
|
+
* ровно для выборок, которые в память не помещаются, и потолок в 1000 страниц оборвал бы
|
|
19
|
+
* законную выборку на крупном портале. Миллион записей недостижим для законного вызова
|
|
20
|
+
* и достижим для зацикленного за секунды.
|
|
21
|
+
*/
|
|
22
|
+
export const FETCH_LIST_MAX_PAGES = 20000;
|
|
23
|
+
|
|
24
|
+
// ==================== Разбор ответа SDK ====================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Достаёт result из ответа SDK.
|
|
28
|
+
*
|
|
29
|
+
* SDK при «мягких» ошибках (ERROR_ENTITY_NOT_FOUND, BITRIX_REST_V3_EXCEPTION_*)
|
|
30
|
+
* не бросает исключение, а возвращает AjaxResult с ошибкой.
|
|
31
|
+
* Функция превращает такой ответ в понятную ошибку вместо падения на undefined.
|
|
32
|
+
* Также отсекает случай, когда запрос успешен, но result пустой (null/undefined).
|
|
33
|
+
*
|
|
34
|
+
* @param response — результат $b24.callMethod()
|
|
35
|
+
* @param methodName — имя метода B24, попадёт в текст ошибки
|
|
36
|
+
*/
|
|
37
|
+
export function getResultData<T = any>(response: AjaxResult, methodName: string): T {
|
|
38
|
+
if (!response.isSuccess) {
|
|
39
|
+
const messages = response.getErrorMessages().join("; ") || "неизвестная ошибка";
|
|
40
|
+
throw new Error(`${methodName}: Bitrix24 вернул ошибку — ${messages}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const data = response.getData();
|
|
44
|
+
if (data?.result == null) {
|
|
45
|
+
throw new Error(`${methodName}: Bitrix24 вернул пустой ответ`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return data.result as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Читает сырой конверт ответа из AjaxResult.
|
|
53
|
+
*
|
|
54
|
+
* `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
|
|
55
|
+
* в ней нет намеренно (в restApi:v3 их не существует). С версии 2.2.0 SDK снял
|
|
56
|
+
* `isMore()`/`getTotal()`/`getNext()` с удаления и объявил их постоянными читателями
|
|
57
|
+
* конверта restApi:v2, но числового смещения среди них так и нет: `isMore()` отвечает
|
|
58
|
+
* только «есть ли ещё», а `getNext(http)` сам делает следующий запрос, мимо нашего
|
|
59
|
+
* прогресса и retry. Поэтому конверт читаем напрямую: `_data` объявлен `protected`,
|
|
60
|
+
* а не приватным полем класса, поэтому в рантайме доступен.
|
|
61
|
+
*
|
|
62
|
+
* Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
|
|
63
|
+
* она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
|
|
64
|
+
*/
|
|
65
|
+
export function readV2Envelope(response: AjaxResult, method: string): V2Envelope {
|
|
66
|
+
const envelope = (response as unknown as { _data?: unknown })._data;
|
|
67
|
+
|
|
68
|
+
if (!envelope || typeof envelope !== "object") {
|
|
69
|
+
throw new Error(`${method}: не удалось прочитать конверт ответа Bitrix24 (AjaxResult._data недоступен) — изменился внутренний формат SDK`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return envelope as V2Envelope;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ==================== Фасад поверх actions.v2.* ====================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Собственные реализации пяти методов, которые SDK 2.x помечает к удалению.
|
|
79
|
+
* Повторяют `AbstractB24` дословно, включая дефолты и порядок аргументов.
|
|
80
|
+
*
|
|
81
|
+
* Позиционные сигнатуры сохранены один в один: на этом держится то, что гейт
|
|
82
|
+
* идемпотентности (`getRetryBlockReason`) видит те же аргументы, что и раньше,
|
|
83
|
+
* — retry оборачивает фасад снаружи, а преобразование в объект опций
|
|
84
|
+
* происходит уже внутри.
|
|
85
|
+
*/
|
|
86
|
+
export function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any> {
|
|
87
|
+
// Геттер actions при каждом обращении проверяет инициализацию экземпляра —
|
|
88
|
+
// читаем его в момент вызова, а не один раз при создании фасада
|
|
89
|
+
const v2 = () => target.actions.v2;
|
|
90
|
+
|
|
91
|
+
const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
|
|
92
|
+
const merged: TypeCallParams = { ...params };
|
|
93
|
+
|
|
94
|
+
// Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod.
|
|
95
|
+
// typeof, а не только Number.isInteger: последний ничего не сужает, и при
|
|
96
|
+
// exactOptionalPropertyTypes у потребителя присваивание number | undefined не проходит
|
|
97
|
+
if (!("start" in merged && Number.isInteger(merged.start)) && typeof start === "number" && Number.isInteger(start)) {
|
|
98
|
+
merged.start = start;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return v2().call.make<T>({ method, params: merged });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Собственный офсетный цикл, а не `actions.v2.callList.make`.
|
|
106
|
+
*
|
|
107
|
+
* У `callList.make` keyset-пагинация: он шлёт `start: -1`, навязывает
|
|
108
|
+
* `order: { ID: 'ASC' }` и добавляет в фильтр `>ID`. Пользовательский `order`
|
|
109
|
+
* при этом молча игнорируется, а колбэка `progress` там нет вовсе. Делегирование
|
|
110
|
+
* туда изменило бы поведение постраничных выборок у потребителей.
|
|
111
|
+
*/
|
|
112
|
+
const callListMethod = async (
|
|
113
|
+
method: string,
|
|
114
|
+
params?: object,
|
|
115
|
+
progress?: null | ((progress: number) => void),
|
|
116
|
+
customKeyForResult?: string | null
|
|
117
|
+
): Promise<Result> => {
|
|
118
|
+
const result = new Result();
|
|
119
|
+
const onProgress = typeof progress === "function" ? progress : null;
|
|
120
|
+
|
|
121
|
+
onProgress?.(0);
|
|
122
|
+
|
|
123
|
+
const list: unknown[] = [];
|
|
124
|
+
let start = 0;
|
|
125
|
+
let completed = false;
|
|
126
|
+
|
|
127
|
+
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
|
|
128
|
+
const response = await v2().call.make({ method, params: { ...params, start } });
|
|
129
|
+
|
|
130
|
+
// SDK в своём callListMethod этой проверки не делает и падает TypeError
|
|
131
|
+
// на getData().result, когда портал вернул «мягкую» ошибку
|
|
132
|
+
if (!response.isSuccess) {
|
|
133
|
+
throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Читаем конверт целиком: getData() режет ответ до { result, time },
|
|
137
|
+
// а пагинация держится на next, которого там нет
|
|
138
|
+
const envelope = readV2Envelope(response, method);
|
|
139
|
+
const payload = envelope.result;
|
|
140
|
+
const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
|
|
141
|
+
|
|
142
|
+
if (!Array.isArray(chunk)) {
|
|
143
|
+
throw new Error(`${method}: ответ не является списком`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const item of chunk) {
|
|
147
|
+
list.push(item);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Единственное правило остановки — отсутствие next в конверте. Считать
|
|
151
|
+
// смещение самим (start += chunk.length) нельзя: портал ставит next = start + 50
|
|
152
|
+
// независимо от того, сколько строк реально отдал, и на странице, укороченной
|
|
153
|
+
// правами доступа или фильтром, собственный счётчик отстаёт — перекрытие
|
|
154
|
+
// читается второй раз и записи дублируются. Метод, который игнорирует start
|
|
155
|
+
// и отдаёт весь список одной страницей, next не присылает и останавливается здесь же.
|
|
156
|
+
if (!Number.isInteger(envelope.next)) {
|
|
157
|
+
completed = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const nextStart = Number(envelope.next);
|
|
162
|
+
|
|
163
|
+
// next обязан расти. Портал, вернувший прежнее или меньшее смещение,
|
|
164
|
+
// иначе гонял бы одну и ту же страницу до потолка, раздувая список
|
|
165
|
+
if (nextStart <= start) {
|
|
166
|
+
throw new Error(`${method}: портал вернул непродвигающийся next (${nextStart}) при start ${start} — пагинация зациклилась`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
start = nextStart;
|
|
170
|
+
|
|
171
|
+
if (onProgress) {
|
|
172
|
+
const total = Number(envelope.total) || 0;
|
|
173
|
+
onProgress(total > 0 ? Math.round((100 * list.length) / total) : 100);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Обрезанный список, отданный как успешный, — худший исход: потребитель
|
|
178
|
+
// примет неполную выборку за полную. Поэтому бросаем, а не логируем:
|
|
179
|
+
// addError() на базовом Result бесполезен — getData() отдаёт данные
|
|
180
|
+
// независимо от наличия ошибок, и существующие потребители её не увидят
|
|
181
|
+
if (!completed) {
|
|
182
|
+
throw new Error(`${method}: достигнут потолок в ${LIST_MAX_PAGES} страниц, портал продолжает отдавать next — выборка прервана как неполная`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
onProgress?.(100);
|
|
186
|
+
result.setData(list);
|
|
187
|
+
return result;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Собственный keyset-цикл, а не `actions.v2.fetchList.make`.
|
|
192
|
+
*
|
|
193
|
+
* Делегирование не давало повторить страницу: генератор нельзя обернуть
|
|
194
|
+
* `withRetry` снаружи (обёртка в async-функцию ломает `for await`), поэтому
|
|
195
|
+
* одиночный сетевой сбой на двенадцатой странице обрывал всю выборку, а уже
|
|
196
|
+
* прочитанные одиннадцать потребитель либо терял, либо принимал за полную.
|
|
197
|
+
* Здесь повторяется страница: курсор двигается только после успешной страницы,
|
|
198
|
+
* поэтому отданные страницы не теряются и не дублируются.
|
|
199
|
+
*
|
|
200
|
+
* Форма запроса взята из `fetch-list.mjs`: `start: -1`, навязанный
|
|
201
|
+
* `order: { <курсор>: "ASC" }`, фильтр `>курсор`. Отступления от SDK — пять,
|
|
202
|
+
* все намеренные:
|
|
203
|
+
* 1. остановка только на пустой странице, а не на короткой: страница, укороченная
|
|
204
|
+
* правами доступа, у портала штатна, и правило SDK молча обрезало бы выборку;
|
|
205
|
+
* 2. нечитаемый курсор — бросок, а не предупреждение и тихая остановка:
|
|
206
|
+
* после пункта 1 он означает гарантированно неполную выборку;
|
|
207
|
+
* 3. потолок страниц (`FETCH_LIST_MAX_PAGES`) — у SDK цикл ничем не ограничен;
|
|
208
|
+
* 4. проверка роста курсора — портал, отбросивший неизвестный ключ фильтра,
|
|
209
|
+
* иначе отдавал бы одну и ту же страницу вечно;
|
|
210
|
+
* 5. пустая строка в `customKeyForResult` трактуется как «ключ не передан».
|
|
211
|
+
*
|
|
212
|
+
* Плюс диагностика: ошибки называют метод B24 вместо `TypeError` из недр SDK
|
|
213
|
+
* и `SdkError` с кодом SDK.
|
|
214
|
+
*
|
|
215
|
+
* Ограничение: один и тот же ключ идёт и на чтение курсора, и в `order`/`filter`.
|
|
216
|
+
* Методы, у которых имя поля в ответе отличается от сортируемого (`tasks.task.list`:
|
|
217
|
+
* ответ `id`, фильтр `ID`), этим методом не выбираются — для них `callListMethod`
|
|
218
|
+
* или `actions.v2.fetchList.make` с `cursorIdKey`.
|
|
219
|
+
*/
|
|
220
|
+
async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
|
|
221
|
+
// Отдельного cursorIdKey, как в опциях SDK, у нас нет и не будет: позиционная
|
|
222
|
+
// сигнатура deprecated-метода его не содержала, а добавление пятого аргумента —
|
|
223
|
+
// расширение публичного API. У SDK он по умолчанию тоже равен idKey
|
|
224
|
+
const cursorKey = idKey ?? "ID";
|
|
225
|
+
const moreKey = `>${cursorKey}`;
|
|
226
|
+
const source: Record<string, unknown> = { ...(params ?? {}) };
|
|
227
|
+
|
|
228
|
+
// Раньше это предупреждение приходило от логгера SDK, теперь пишем сами
|
|
229
|
+
if ("order" in source && source["order"]) {
|
|
230
|
+
logs.add(`${method}: параметр order игнорируется — keyset-пагинация сортирует по «${cursorKey}» ASC, сужать выборку следует через filter`, "warn");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { order: _ignoredOrder, filter: userFilter, ...restParams } = source;
|
|
234
|
+
|
|
235
|
+
// Ключ курсора в пользовательском фильтре перетирается на каждой странице.
|
|
236
|
+
// Молчать здесь нельзя: рядом про order предупреждение есть, и асимметрия
|
|
237
|
+
// читалась бы как «фильтр по курсору пользователю оставлен»
|
|
238
|
+
if (userFilter && typeof userFilter === "object" && moreKey in (userFilter as Record<string, unknown>)) {
|
|
239
|
+
logs.add(`${method}: ключ «${moreKey}» в фильтре перетирается курсором пагинации — сузить выборку по нему нельзя`, "warn");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Курсор: значение, от которого идёт следующая страница. Оно же лежит в фильтре
|
|
243
|
+
// запроса, отдельная переменная нужна для проверки роста
|
|
244
|
+
let cursor = 0;
|
|
245
|
+
|
|
246
|
+
// Фильтр — новый объект: мутировать переданный вызывающим нельзя, SDK этого тоже
|
|
247
|
+
// не делает, а курсор в этом объекте переписывается на каждой странице
|
|
248
|
+
const requestParams: Record<string, unknown> & { filter: Record<string, unknown> } = {
|
|
249
|
+
...restParams,
|
|
250
|
+
order: { [cursorKey]: "ASC" },
|
|
251
|
+
filter: { ...((userFilter as Record<string, unknown> | undefined) ?? {}), [moreKey]: cursor },
|
|
252
|
+
start: -1,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Список — чтение и дубликатов не создаёт, но гейт пусть стоит: если потребитель
|
|
256
|
+
// передаст создающий метод, повтор должен блокироваться, как везде.
|
|
257
|
+
// Состав вызова между страницами не меняется, поэтому считаем один раз
|
|
258
|
+
const blockReason = getRetryBlockReason("callMethod", [method]);
|
|
259
|
+
|
|
260
|
+
// Страховка от вечного цикла: портал, отбросивший неизвестный ключ фильтра,
|
|
261
|
+
// отдаёт одну и ту же страницу без ошибки и без строки в логе
|
|
262
|
+
let completed = false;
|
|
263
|
+
|
|
264
|
+
for (let page = 1; page <= FETCH_LIST_MAX_PAGES; page++) {
|
|
265
|
+
// Замыкание читает requestParams в момент вызова: объект один и тот же,
|
|
266
|
+
// курсор в нём двигается только после успешной страницы — поэтому повтор
|
|
267
|
+
// попадает ровно в ту же страницу, а не в следующую
|
|
268
|
+
const response = await runWithRetry(
|
|
269
|
+
() => v2().call.make<any>({ method, params: requestParams as TypeCallParams }),
|
|
270
|
+
"$b24.fetchListMethod",
|
|
271
|
+
blockReason
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Формулировка дословно как в callListMethod — один стиль ошибок на оба
|
|
275
|
+
// списочных метода. Сетевой такая ошибка не считается, повтора не будет:
|
|
276
|
+
// «мягкая» ошибка портала от повтора не исправится
|
|
277
|
+
if (!response.isSuccess) {
|
|
278
|
+
throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const data = response.getData();
|
|
282
|
+
if (!data) {
|
|
283
|
+
completed = true;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const payload = data.result;
|
|
288
|
+
const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
|
|
289
|
+
|
|
290
|
+
// Отступление от SDK намеренное и такое же, как в callListMethod:
|
|
291
|
+
// там на этом месте падал TypeError из недр библиотеки
|
|
292
|
+
if (!Array.isArray(chunk)) {
|
|
293
|
+
throw new Error(`${method}: ответ не является списком`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Единственное правило остановки — пустая страница. SDK на этом месте
|
|
297
|
+
// останавливается ещё и на короткой (меньше 50 строк), но у портала
|
|
298
|
+
// страница, укороченная правами доступа, штатна — из-за неё в callListMethod
|
|
299
|
+
// запрещено считать смещение самим. Цена нового правила — один лишний
|
|
300
|
+
// запрос на всю выборку, всегда пустой; цена старого — молча отданные
|
|
301
|
+
// сорок семь записей вместо нескольких тысяч
|
|
302
|
+
if (chunk.length === 0) {
|
|
303
|
+
completed = true;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
yield chunk;
|
|
308
|
+
|
|
309
|
+
// noUncheckedIndexedAccess: индексация массива даёт T | undefined
|
|
310
|
+
const lastItem = chunk[chunk.length - 1] as Record<string, unknown> | undefined;
|
|
311
|
+
const cursorValue = Number.parseInt(String(lastItem?.[cursorKey]), 10);
|
|
312
|
+
|
|
313
|
+
// Страница непустая, а продолжить её нечем: остановиться тихо — значит
|
|
314
|
+
// отдать неполную выборку как полную. Бросаем по тому же правилу,
|
|
315
|
+
// что и callListMethod
|
|
316
|
+
if (!Number.isFinite(cursorValue)) {
|
|
317
|
+
throw new Error(`${method}: в строках ответа нет числового идентификатора по ключу «${cursorKey}» — выборка прервана как неполная`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Курсор обязан расти. Портал, отбросивший неизвестный ключ фильтра,
|
|
321
|
+
// иначе гонял бы одну и ту же страницу до потолка
|
|
322
|
+
if (cursorValue <= cursor) {
|
|
323
|
+
throw new Error(`${method}: портал вернул непродвигающийся курсор (${cursorValue}) при предыдущем ${cursor} — пагинация зациклилась`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
cursor = cursorValue;
|
|
327
|
+
requestParams.filter[moreKey] = cursor;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Обоснование броска то же, что у потолка в callListMethod: обрезанная выборка,
|
|
331
|
+
// отданная как полная, — худший исход. Тихая остановка здесь неотличима
|
|
332
|
+
// от нормального завершения `for await`
|
|
333
|
+
if (!completed) {
|
|
334
|
+
throw new Error(`${method}: достигнут потолок в ${FETCH_LIST_MAX_PAGES} страниц, портал продолжает отдавать данные — выборка прервана как неполная`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result> => {
|
|
339
|
+
// Дефолты подставляем мы: batch.make своих не имеет, он просто
|
|
340
|
+
// расширяет переданный объект опций версией API
|
|
341
|
+
return v2().batch.make({
|
|
342
|
+
calls: calls as BatchCalls,
|
|
343
|
+
options: {
|
|
344
|
+
isHaltOnError: isHaltOnError ?? true,
|
|
345
|
+
returnAjaxResult: returnAjaxResult ?? false,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result> => {
|
|
351
|
+
// isHaltOnError передаём как есть, без ?? true — дословно по AbstractB24.
|
|
352
|
+
// returnAjaxResult не передаём: batchByChunk.make жёстко ставит false сам,
|
|
353
|
+
// а его тип опций этот ключ не принимает
|
|
354
|
+
return v2().batchByChunk.make({ calls, options: { isHaltOnError } });
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return { callMethod, callListMethod, fetchListMethod, callBatch, callBatchByChunk } satisfies FacadeMethods;
|
|
358
|
+
}
|