@verdeect/kit 0.0.0-stage → 1.0.0-rc.2

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.
Files changed (216) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +804 -2
  3. package/alerts/verdeect-kit.rules.yml +133 -0
  4. package/dist/cli/commands/crypto.d.ts +9 -0
  5. package/dist/cli/commands/crypto.js +87 -0
  6. package/dist/cli/commands/db.d.ts +11 -0
  7. package/dist/cli/commands/db.js +151 -0
  8. package/dist/cli/commands/openapi.d.ts +9 -0
  9. package/dist/cli/commands/openapi.js +144 -0
  10. package/dist/cli/commands/rabbitmq.d.ts +11 -0
  11. package/dist/cli/commands/rabbitmq.js +124 -0
  12. package/dist/cli/commands/redis.d.ts +11 -0
  13. package/dist/cli/commands/redis.js +119 -0
  14. package/dist/cli/main.d.ts +2 -0
  15. package/dist/cli/main.js +31 -0
  16. package/dist/config/cli-schemas.d.ts +29 -0
  17. package/dist/config/cli-schemas.js +15 -0
  18. package/dist/config/define-env.d.ts +21 -0
  19. package/dist/config/define-env.js +43 -0
  20. package/dist/config/fragments.d.ts +105 -0
  21. package/dist/config/fragments.js +86 -0
  22. package/dist/config/helpers.d.ts +26 -0
  23. package/dist/config/helpers.js +62 -0
  24. package/dist/config/index.d.ts +7 -0
  25. package/dist/config/index.js +7 -0
  26. package/dist/config/kit-config.module.d.ts +20 -0
  27. package/dist/config/kit-config.module.js +23 -0
  28. package/dist/config/parse-env.d.ts +14 -0
  29. package/dist/config/parse-env.js +28 -0
  30. package/dist/crypto/cipher.d.ts +61 -0
  31. package/dist/crypto/cipher.js +136 -0
  32. package/dist/crypto/index.d.ts +2 -0
  33. package/dist/crypto/index.js +2 -0
  34. package/dist/database/batches.d.ts +42 -0
  35. package/dist/database/batches.js +49 -0
  36. package/dist/database/db.d.ts +93 -0
  37. package/dist/database/db.js +234 -0
  38. package/dist/database/if-newer.d.ts +9 -0
  39. package/dist/database/if-newer.js +15 -0
  40. package/dist/database/index.d.ts +17 -0
  41. package/dist/database/index.js +13 -0
  42. package/dist/database/kit-database.module.d.ts +47 -0
  43. package/dist/database/kit-database.module.js +181 -0
  44. package/dist/database/migrations.d.ts +42 -0
  45. package/dist/database/migrations.js +142 -0
  46. package/dist/database/pg.d.ts +20 -0
  47. package/dist/database/pg.js +19 -0
  48. package/dist/database/prepare-database.d.ts +39 -0
  49. package/dist/database/prepare-database.js +75 -0
  50. package/dist/database/run-exclusive.d.ts +19 -0
  51. package/dist/database/run-exclusive.js +62 -0
  52. package/dist/database/tenant.d.ts +34 -0
  53. package/dist/database/tenant.js +28 -0
  54. package/dist/http/api/api.decorator.d.ts +18 -0
  55. package/dist/http/api/api.decorator.js +9 -0
  56. package/dist/http/api/openapi-document.d.ts +40 -0
  57. package/dist/http/api/openapi-document.js +452 -0
  58. package/dist/http/bootstrap-service.d.ts +33 -0
  59. package/dist/http/bootstrap-service.js +107 -0
  60. package/dist/http/create-http-app.d.ts +38 -0
  61. package/dist/http/create-http-app.js +137 -0
  62. package/dist/http/errors.d.ts +118 -0
  63. package/dist/http/errors.js +94 -0
  64. package/dist/http/index.d.ts +12 -0
  65. package/dist/http/index.js +8 -0
  66. package/dist/http/kit-exception.filter.d.ts +18 -0
  67. package/dist/http/kit-exception.filter.js +151 -0
  68. package/dist/http/kit-http-core.module.d.ts +19 -0
  69. package/dist/http/kit-http-core.module.js +122 -0
  70. package/dist/http/pagination.d.ts +30 -0
  71. package/dist/http/pagination.js +73 -0
  72. package/dist/http/zod-validation.pipe.d.ts +15 -0
  73. package/dist/http/zod-validation.pipe.js +47 -0
  74. package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
  75. package/dist/idempotency/idempotency.interceptor.js +317 -0
  76. package/dist/idempotency/idempotent.decorator.d.ts +7 -0
  77. package/dist/idempotency/idempotent.decorator.js +16 -0
  78. package/dist/idempotency/index.d.ts +7 -0
  79. package/dist/idempotency/index.js +6 -0
  80. package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
  81. package/dist/idempotency/kit-idempotency.module.js +173 -0
  82. package/dist/idempotency/peer.d.ts +1 -0
  83. package/dist/idempotency/peer.js +4 -0
  84. package/dist/idempotency/table.d.ts +203 -0
  85. package/dist/idempotency/table.js +86 -0
  86. package/dist/internal/cleanup-result.d.ts +7 -0
  87. package/dist/internal/cleanup-result.js +12 -0
  88. package/dist/internal/ids.d.ts +13 -0
  89. package/dist/internal/ids.js +41 -0
  90. package/dist/internal/metadata.d.ts +12 -0
  91. package/dist/internal/metadata.js +3 -0
  92. package/dist/internal/outbox-policy.d.ts +8 -0
  93. package/dist/internal/outbox-policy.js +10 -0
  94. package/dist/internal/peer.d.ts +16 -0
  95. package/dist/internal/peer.js +56 -0
  96. package/dist/internal/pg-errors.d.ts +21 -0
  97. package/dist/internal/pg-errors.js +115 -0
  98. package/dist/internal/preparsed-env.d.ts +4 -0
  99. package/dist/internal/preparsed-env.js +14 -0
  100. package/dist/internal/rabbitmq-definitions.d.ts +85 -0
  101. package/dist/internal/rabbitmq-definitions.js +73 -0
  102. package/dist/internal/redis-acl.d.ts +21 -0
  103. package/dist/internal/redis-acl.js +30 -0
  104. package/dist/internal/service-name.d.ts +4 -0
  105. package/dist/internal/service-name.js +10 -0
  106. package/dist/internal/sql-identifier.d.ts +5 -0
  107. package/dist/internal/sql-identifier.js +12 -0
  108. package/dist/internal/tokens.d.ts +10 -0
  109. package/dist/internal/tokens.js +12 -0
  110. package/dist/internal/tx-context.d.ts +5 -0
  111. package/dist/internal/tx-context.js +9 -0
  112. package/dist/messaging/cleanup.d.ts +49 -0
  113. package/dist/messaging/cleanup.js +129 -0
  114. package/dist/messaging/config.d.ts +43 -0
  115. package/dist/messaging/config.js +8 -0
  116. package/dist/messaging/define-event.d.ts +29 -0
  117. package/dist/messaging/define-event.js +38 -0
  118. package/dist/messaging/envelope.d.ts +66 -0
  119. package/dist/messaging/envelope.js +49 -0
  120. package/dist/messaging/errors.d.ts +45 -0
  121. package/dist/messaging/errors.js +63 -0
  122. package/dist/messaging/inbox.d.ts +4 -0
  123. package/dist/messaging/inbox.js +9 -0
  124. package/dist/messaging/index.d.ts +13 -0
  125. package/dist/messaging/index.js +10 -0
  126. package/dist/messaging/kit-messaging.module.d.ts +39 -0
  127. package/dist/messaging/kit-messaging.module.js +309 -0
  128. package/dist/messaging/outbox-relay.d.ts +67 -0
  129. package/dist/messaging/outbox-relay.js +260 -0
  130. package/dist/messaging/outbox-store.d.ts +75 -0
  131. package/dist/messaging/outbox-store.js +126 -0
  132. package/dist/messaging/peer.d.ts +1 -0
  133. package/dist/messaging/peer.js +5 -0
  134. package/dist/messaging/rabbit-connection.d.ts +144 -0
  135. package/dist/messaging/rabbit-connection.js +472 -0
  136. package/dist/messaging/redelivery.d.ts +6 -0
  137. package/dist/messaging/redelivery.js +17 -0
  138. package/dist/messaging/subscriber.d.ts +102 -0
  139. package/dist/messaging/subscriber.js +412 -0
  140. package/dist/messaging/tables.d.ts +315 -0
  141. package/dist/messaging/tables.js +72 -0
  142. package/dist/messaging/topology.d.ts +56 -0
  143. package/dist/messaging/topology.js +56 -0
  144. package/dist/observability/fail-startup.d.ts +14 -0
  145. package/dist/observability/fail-startup.js +51 -0
  146. package/dist/observability/health-registry.d.ts +34 -0
  147. package/dist/observability/health-registry.js +81 -0
  148. package/dist/observability/index.d.ts +16 -0
  149. package/dist/observability/index.js +10 -0
  150. package/dist/observability/lifecycle.d.ts +52 -0
  151. package/dist/observability/lifecycle.js +151 -0
  152. package/dist/observability/logger-options.d.ts +54 -0
  153. package/dist/observability/logger-options.js +91 -0
  154. package/dist/observability/metrics-server.d.ts +33 -0
  155. package/dist/observability/metrics-server.js +108 -0
  156. package/dist/observability/metrics.d.ts +30 -0
  157. package/dist/observability/metrics.js +53 -0
  158. package/dist/observability/redaction.d.ts +23 -0
  159. package/dist/observability/redaction.js +65 -0
  160. package/dist/observability/sanitize-error.d.ts +47 -0
  161. package/dist/observability/sanitize-error.js +194 -0
  162. package/dist/observability/sentry.d.ts +64 -0
  163. package/dist/observability/sentry.js +233 -0
  164. package/dist/redis/channels.d.ts +34 -0
  165. package/dist/redis/channels.js +128 -0
  166. package/dist/redis/client.d.ts +42 -0
  167. package/dist/redis/client.js +85 -0
  168. package/dist/redis/index.d.ts +7 -0
  169. package/dist/redis/index.js +7 -0
  170. package/dist/redis/kit-redis.module.d.ts +18 -0
  171. package/dist/redis/kit-redis.module.js +131 -0
  172. package/dist/redis/peer.d.ts +1 -0
  173. package/dist/redis/peer.js +4 -0
  174. package/dist/redis/rate-limiter.d.ts +49 -0
  175. package/dist/redis/rate-limiter.js +139 -0
  176. package/dist/testing/count-queries.d.ts +30 -0
  177. package/dist/testing/count-queries.js +58 -0
  178. package/dist/testing/create-test-app.d.ts +26 -0
  179. package/dist/testing/create-test-app.js +32 -0
  180. package/dist/testing/expect-matches-api.d.ts +8 -0
  181. package/dist/testing/expect-matches-api.js +49 -0
  182. package/dist/testing/expect-problem.d.ts +24 -0
  183. package/dist/testing/expect-problem.js +23 -0
  184. package/dist/testing/images.d.ts +10 -0
  185. package/dist/testing/images.js +14 -0
  186. package/dist/testing/index.d.ts +23 -0
  187. package/dist/testing/index.js +17 -0
  188. package/dist/testing/launch-command.d.ts +52 -0
  189. package/dist/testing/launch-command.js +510 -0
  190. package/dist/testing/outbox-coverage.d.ts +26 -0
  191. package/dist/testing/outbox-coverage.js +98 -0
  192. package/dist/testing/rabbitmq-admin.d.ts +81 -0
  193. package/dist/testing/rabbitmq-admin.js +146 -0
  194. package/dist/testing/redis-worker.d.ts +10 -0
  195. package/dist/testing/redis-worker.js +15 -0
  196. package/dist/testing/repo-conventions.d.ts +10 -0
  197. package/dist/testing/repo-conventions.js +524 -0
  198. package/dist/testing/repo-files.d.ts +113 -0
  199. package/dist/testing/repo-files.js +401 -0
  200. package/dist/testing/scratch-database.d.ts +19 -0
  201. package/dist/testing/scratch-database.js +75 -0
  202. package/dist/testing/start-test-stack.d.ts +85 -0
  203. package/dist/testing/start-test-stack.js +342 -0
  204. package/dist/testing/tcp-proxy.d.ts +40 -0
  205. package/dist/testing/tcp-proxy.js +164 -0
  206. package/dist/testing/tenant-isolation.d.ts +31 -0
  207. package/dist/testing/tenant-isolation.js +205 -0
  208. package/dist/testing/test-events.d.ts +37 -0
  209. package/dist/testing/test-events.js +150 -0
  210. package/dist/testing/timeout-scale.d.ts +4 -0
  211. package/dist/testing/timeout-scale.js +17 -0
  212. package/dist/testing/timing-guard.d.ts +22 -0
  213. package/dist/testing/timing-guard.js +54 -0
  214. package/dist/testing/worker-scope.d.ts +31 -0
  215. package/dist/testing/worker-scope.js +68 -0
  216. package/package.json +177 -4
@@ -0,0 +1,102 @@
1
+ import type { Db, DbTx } from '../database/db.js';
2
+ import type { KitMetrics } from '../observability/metrics.js';
3
+ import type { KitEvent } from './define-event.js';
4
+ import { type Envelope } from './envelope.js';
5
+ import { type MessagingLog, type RabbitConnection } from './rabbit-connection.js';
6
+ import type { KitInboxTable } from './tables.js';
7
+ /**
8
+ * Обработчик события. `tx` — транзакция `Db` с `DbContext` события (`tenant` конверта, `traceId` конверта или `id`,
9
+ * `actor: {channel: 'system'}`); inbox уже занят в ней — изменения обработчика и отметка inbox фиксируются одним
10
+ * `COMMIT`. При `dedupe: 'none'` `tx = null`: БД нет (роль BFF), обработчик сам идемпотентен. Порядок по `occurredAt` —
11
+ * `ifNewer` из `/database`. Исключение: `InvalidMessageError` — DLQ, `ExpectedMessageError` — повтор, прочее — повтор,
12
+ * после 5 повторов DLQ.
13
+ */
14
+ export type KitEventHandler<E extends KitEvent> = (event: Envelope<E>, tx: DbTx | null) => Promise<void>;
15
+ /** Обработчик события на каждой реплике (`onReplica`): без inbox, повторов и DLQ. */
16
+ export type KitReplicaHandler<E extends KitEvent> = (event: Envelope<E>) => Promise<void>;
17
+ /** Зависимости подписчика (внутренние). */
18
+ export interface KitSubscriberDeps {
19
+ /** Имя экземпляра. */
20
+ readonly service: string;
21
+ /** REPLICA_ID. */
22
+ readonly replica: string;
23
+ /** Защита от повторов. */
24
+ readonly dedupe: 'inbox' | 'none';
25
+ /** Таблица inbox при `dedupe: 'inbox'`. */
26
+ readonly inbox?: KitInboxTable;
27
+ /** `Db` при `dedupe: 'inbox'`. */
28
+ readonly db?: Db;
29
+ /** Соединение. */
30
+ readonly connection: RabbitConnection;
31
+ /** TTL `.retry` и пауза повтора, с. */
32
+ readonly retrySeconds: number;
33
+ /** Метрики. */
34
+ readonly metrics?: KitMetrics;
35
+ /** Логгер. */
36
+ readonly log?: MessagingLog;
37
+ }
38
+ /**
39
+ * Подписчик сервиса (§3.4): `on(Event, handler)` — очередь `<svc>` с inbox, повторами через `<svc>.retry` и DLQ
40
+ * `<svc>.dlq`; `onReplica(Event, handler)` — очередь реплики `<svc>.replica.<REPLICA_ID>`. Сервис внедряет
41
+ * `KitSubscriber` и регистрирует обработчики в `onModuleInit` своего провайдера.
42
+ */
43
+ export declare class KitSubscriber {
44
+ private readonly deps;
45
+ private readonly log;
46
+ private readonly queues;
47
+ private readonly handlers;
48
+ private readonly replicaHandlers;
49
+ private readonly inFlight;
50
+ private main;
51
+ private replica;
52
+ private started;
53
+ private stopping;
54
+ /** @internal Создаёт KitMessagingModule. */
55
+ constructor(deps: KitSubscriberDeps);
56
+ /**
57
+ * Обработчик события из очереди `<svc>`.
58
+ * @throws {TypeError} тип уже зарегистрирован или приложение уже запущено.
59
+ */
60
+ on<E extends KitEvent>(event: E, handler: KitEventHandler<E>): void;
61
+ /**
62
+ * Обработчик события на каждой реплике: очередь `<svc>.replica.<REPLICA_ID>` (exclusive, auto-delete), без inbox,
63
+ * повторов и DLQ. Неразобранное сообщение или ошибка обработчика — без повтора, сообщение не сохраняется: `error`-лог,
64
+ * событие `captureThrottled`, метрика `result: 'dlq'` (П24).
65
+ * @throws {TypeError} тип уже зарегистрирован или приложение уже запущено.
66
+ */
67
+ onReplica<E extends KitEvent>(event: E, handler: KitReplicaHandler<E>): void;
68
+ /** Топология, потребители и проверка TTL `.retry` до прослушивания порта (AC-5.19). */
69
+ onApplicationBootstrap(): Promise<void>;
70
+ /** Шаг 3 остановки: снять потребителей → дождаться доставок → закрыть каналы, в общем сроке. */
71
+ beforeApplicationShutdown(): Promise<void>;
72
+ private register;
73
+ /**
74
+ * Первый исход подключения и настройки канала, не дольше RABBITMQ_RETRY_SECONDS (П8): другой TTL `.retry` —
75
+ * отказ запуска; брокер недоступен или другая ошибка стадии — запуск продолжается (`/health` rabbitmq down).
76
+ */
77
+ private awaitStartupOutcome;
78
+ private setupQueues;
79
+ private openReplica;
80
+ /** Доставка после начала остановки не обрабатывается: без ack брокер вернёт её при закрытии канала. */
81
+ private track;
82
+ private handle;
83
+ /** true — повтор `id` (inbox занят), обработчик не вызван. */
84
+ private invoke;
85
+ private typedEnvelope;
86
+ private onHandlerError;
87
+ /** Итог доставки: метрика и debug-строка; null — доставка прошлого канала (итога нет). */
88
+ private finish;
89
+ /** Отказ без возврата: dead-letter → `.retry` → через TTL снова в `<svc>`. */
90
+ private toRetry;
91
+ /** Копия в `<svc>.dlq` с `x-dlq-reason` и ack исходного после подтверждения копии; сбой копии — повтор. */
92
+ private toDlq;
93
+ private reportDlq;
94
+ private isCurrent;
95
+ /** Реплика (noAck): разбор, признак tenant, data, обработчик; неудача — без повторов и DLQ (П24). */
96
+ private handleReplica;
97
+ /**
98
+ * Реплика без повтора и DLQ — сообщение не сохраняется (§3.4 v1.15, П24): `error`-лог, событие `captureThrottled`,
99
+ * метрика `result: 'dlq'`.
100
+ */
101
+ private dropReplica;
102
+ }
@@ -0,0 +1,412 @@
1
+ // KitSubscriber (prd.md §3.4, UC-6, AC-5.6–AC-5.9, AC-5.11, AC-5.15, AC-5.17, AC-5.19, AC-9.5). Эталон — O
2
+ // rabbit-subscriber.ts (setupQueues, toDlq, остановка), inbox-guard; из P — очередь реплики (policy-changed.consumer),
3
+ // метрика *_events_consumed_total; из B — retryCount(headers, queue). По PRD: x-dlq-reason (П7), inbox в транзакции
4
+ // подписчика (П6), проверка data и признака tenant, dedupe 'none' без БД, отказ запуска при другом TTL .retry (П8).
5
+ // Тип — по envelope.type, не по routing key (после повтора ключ — имя очереди). Тело сообщения не логируется.
6
+ import { setTimeout as sleep } from 'node:timers/promises';
7
+ import { sanitizeError } from '../observability/sanitize-error.js';
8
+ import { captureError, captureThrottled } from '../observability/sentry.js';
9
+ import { parseEnvelope, tenantViolation } from './envelope.js';
10
+ import { ExpectedMessageError, InvalidMessageError, MessagingStartupError } from './errors.js';
11
+ import { claimInbox } from './inbox.js';
12
+ import { nestLog, } from './rabbit-connection.js';
13
+ import { decideRedelivery, retryCount } from './redelivery.js';
14
+ import { CONSUMER_PREFETCH, DLQ_REASON_HEADER, EVENTS_EXCHANGE, queuesOf, replicaQueueOf, } from './topology.js';
15
+ const MAIN_CHANNEL = 'subscriber';
16
+ const REPLICA_CHANNEL = 'subscriber-replica';
17
+ const UNKNOWN_TYPE = 'unknown';
18
+ /** Причины разбора — событие GlitchTip с ограничением частоты (П7). */
19
+ const PARSE_REASONS = new Set(['not-json', 'envelope', 'unsubscribed', 'tenant', 'data']);
20
+ function is406TtlMismatch(error) {
21
+ const { code, message } = (error ?? {});
22
+ return code === 406 && typeof message === 'string' && message.includes('x-message-ttl');
23
+ }
24
+ /**
25
+ * Подписчик сервиса (§3.4): `on(Event, handler)` — очередь `<svc>` с inbox, повторами через `<svc>.retry` и DLQ
26
+ * `<svc>.dlq`; `onReplica(Event, handler)` — очередь реплики `<svc>.replica.<REPLICA_ID>`. Сервис внедряет
27
+ * `KitSubscriber` и регистрирует обработчики в `onModuleInit` своего провайдера.
28
+ */
29
+ export class KitSubscriber {
30
+ deps;
31
+ log;
32
+ queues;
33
+ handlers = new Map();
34
+ replicaHandlers = new Map();
35
+ inFlight = new Set();
36
+ main = null;
37
+ replica = null;
38
+ started = false;
39
+ stopping = false;
40
+ /** @internal Создаёт KitMessagingModule. */
41
+ constructor(deps) {
42
+ this.deps = deps;
43
+ this.log = deps.log ?? nestLog('KitSubscriber');
44
+ this.queues = queuesOf(deps.service);
45
+ }
46
+ /**
47
+ * Обработчик события из очереди `<svc>`.
48
+ * @throws {TypeError} тип уже зарегистрирован или приложение уже запущено.
49
+ */
50
+ on(event, handler) {
51
+ this.register(this.handlers, 'on', event, handler);
52
+ }
53
+ /**
54
+ * Обработчик события на каждой реплике: очередь `<svc>.replica.<REPLICA_ID>` (exclusive, auto-delete), без inbox,
55
+ * повторов и DLQ. Неразобранное сообщение или ошибка обработчика — без повтора, сообщение не сохраняется: `error`-лог,
56
+ * событие `captureThrottled`, метрика `result: 'dlq'` (П24).
57
+ * @throws {TypeError} тип уже зарегистрирован или приложение уже запущено.
58
+ */
59
+ onReplica(event, handler) {
60
+ this.register(this.replicaHandlers, 'onReplica', event, (envelope) => handler(envelope));
61
+ }
62
+ /** Топология, потребители и проверка TTL `.retry` до прослушивания порта (AC-5.19). */
63
+ async onApplicationBootstrap() {
64
+ this.started = true;
65
+ const { connection } = this.deps;
66
+ const types = [...this.handlers.keys()];
67
+ this.main = connection.createChannel({
68
+ name: MAIN_CHANNEL,
69
+ confirm: true,
70
+ setup: (channel) => this.setupQueues(channel, types),
71
+ });
72
+ if (types.length > 0) {
73
+ // Без соединения consume только регистрирует потребителя: он подключится при connect.
74
+ this.main
75
+ .consume(this.queues.main, (msg) => this.track(msg))
76
+ .catch((error) => {
77
+ this.log.warn({ err: sanitizeError(error) }, 'messaging: consume не удался');
78
+ });
79
+ }
80
+ if (this.replicaHandlers.size > 0) {
81
+ this.openReplica();
82
+ }
83
+ await this.awaitStartupOutcome();
84
+ }
85
+ /** Шаг 3 остановки: снять потребителей → дождаться доставок → закрыть каналы, в общем сроке. */
86
+ async beforeApplicationShutdown() {
87
+ this.stopping = true;
88
+ const { main, replica } = this;
89
+ if (main === null && replica === null) {
90
+ return;
91
+ }
92
+ let stage = 'cancel';
93
+ const steps = (async () => {
94
+ await main?.cancelConsumers();
95
+ await replica?.cancelConsumers();
96
+ this.log.warn({ inFlight: this.inFlight.size }, 'messaging: потребители сняты');
97
+ stage = 'handlers';
98
+ await Promise.allSettled([...this.inFlight]);
99
+ this.log.warn({}, 'messaging: доставки завершены');
100
+ stage = 'close';
101
+ this.main = null;
102
+ this.replica = null;
103
+ await main?.close();
104
+ await replica?.close();
105
+ return true;
106
+ })().catch((error) => {
107
+ this.log.warn({ stage, err: sanitizeError(error) }, 'messaging: шаг остановки не удался');
108
+ return true;
109
+ });
110
+ const done = await Promise.race([steps, this.deps.connection.shutdownDeadline()]);
111
+ if (!done) {
112
+ this.main = null;
113
+ this.replica = null;
114
+ this.log.warn({ stage, inFlight: this.inFlight.size }, 'messaging: срок остановки подписчика вышел, идём дальше');
115
+ }
116
+ }
117
+ register(registry, method, event, handler) {
118
+ if (this.started) {
119
+ throw new TypeError(`KitSubscriber.${method}: регистрация — до запуска приложения (onModuleInit)`);
120
+ }
121
+ if (registry.has(event.type)) {
122
+ throw new TypeError(`KitSubscriber.${method}: обработчик ${event.type} уже зарегистрирован`);
123
+ }
124
+ registry.set(event.type, { event, handler });
125
+ }
126
+ /**
127
+ * Первый исход подключения и настройки канала, не дольше RABBITMQ_RETRY_SECONDS (П8): другой TTL `.retry` —
128
+ * отказ запуска; брокер недоступен или другая ошибка стадии — запуск продолжается (`/health` rabbitmq down).
129
+ */
130
+ async awaitStartupOutcome() {
131
+ const { connection, retrySeconds } = this.deps;
132
+ const limit = () => sleep(retrySeconds * 1000, 'timeout', { ref: false });
133
+ const attempt = await Promise.race([connection.firstAttempt(), limit()]);
134
+ if (attempt !== 'connected') {
135
+ return;
136
+ }
137
+ const outcome = await Promise.race([connection.channelOutcome(MAIN_CHANNEL), limit()]);
138
+ if (outcome !== 'timeout' && outcome.kind === 'fatal') {
139
+ throw outcome.error;
140
+ }
141
+ }
142
+ async setupQueues(channel, types) {
143
+ const q = this.queues;
144
+ await channel.assertQueue(q.main, {
145
+ durable: true,
146
+ deadLetterExchange: '',
147
+ deadLetterRoutingKey: q.retry,
148
+ });
149
+ try {
150
+ await channel.assertQueue(q.retry, {
151
+ durable: true,
152
+ messageTtl: this.deps.retrySeconds * 1000,
153
+ deadLetterExchange: '',
154
+ deadLetterRoutingKey: q.main,
155
+ });
156
+ }
157
+ catch (error) {
158
+ if (is406TtlMismatch(error)) {
159
+ throw new MessagingStartupError('MESSAGING_RETRY_TTL_MISMATCH', `очередь ${q.retry} объявлена с другим TTL — пересоздайте очередь`, { queue: q.retry });
160
+ }
161
+ throw error;
162
+ }
163
+ await channel.assertQueue(q.dlq, { durable: true });
164
+ // prefetch — с ожиданием: опция consume amqp-connection-manager 5.0.1 вызывает prefetch без await (O).
165
+ await channel.prefetch(CONSUMER_PREFETCH, false);
166
+ for (const type of types) {
167
+ await channel.bindQueue(q.main, EVENTS_EXCHANGE, type);
168
+ }
169
+ this.log.info({ queue: q.main, bindings: types }, 'messaging: очереди подписчика готовы');
170
+ }
171
+ openReplica() {
172
+ const queue = replicaQueueOf(this.deps.service, this.deps.replica);
173
+ const types = [...this.replicaHandlers.keys()];
174
+ this.replica = this.deps.connection.createChannel({
175
+ name: REPLICA_CHANNEL,
176
+ confirm: false,
177
+ setup: async (channel) => {
178
+ await channel.assertQueue(queue, {
179
+ exclusive: true,
180
+ autoDelete: true,
181
+ durable: false,
182
+ });
183
+ for (const type of types) {
184
+ await channel.bindQueue(queue, EVENTS_EXCHANGE, type);
185
+ }
186
+ },
187
+ });
188
+ this.replica
189
+ .consume(queue, (msg) => void this.handleReplica(msg), { noAck: true })
190
+ .catch((error) => {
191
+ this.log.warn({ err: sanitizeError(error) }, 'messaging: consume реплики не удался');
192
+ });
193
+ }
194
+ /** Доставка после начала остановки не обрабатывается: без ack брокер вернёт её при закрытии канала. */
195
+ track(msg) {
196
+ if (msg === null) {
197
+ return;
198
+ }
199
+ if (this.stopping) {
200
+ this.log.debug({ id: String(msg.properties.messageId ?? '') }, 'messaging: остановка, доставка остаётся брокеру');
201
+ return;
202
+ }
203
+ const handling = this.handle(msg);
204
+ this.inFlight.add(handling);
205
+ void handling.finally(() => this.inFlight.delete(handling));
206
+ }
207
+ async handle(msg) {
208
+ const headers = msg.properties.headers;
209
+ // UC-6 шаг 1: номер доставки по x-death.
210
+ const delivery = retryCount(headers, this.queues.main) + 1;
211
+ // Шаг 2: разбор конверта, тип, признак tenant, data.
212
+ const parsed = parseEnvelope(msg.content);
213
+ if (!parsed.ok) {
214
+ await this.finish(UNKNOWN_TYPE, null, delivery, () => this.toDlq(msg, parsed.reason, null, UNKNOWN_TYPE, delivery, {
215
+ fields: parsed.fields,
216
+ }));
217
+ return;
218
+ }
219
+ const { envelope } = parsed;
220
+ const registered = this.handlers.get(envelope.type);
221
+ if (registered === undefined) {
222
+ await this.finish(UNKNOWN_TYPE, envelope.id, delivery, () => this.toDlq(msg, 'unsubscribed', envelope.id, envelope.type, delivery));
223
+ return;
224
+ }
225
+ const typed = this.typedEnvelope(registered.event, envelope);
226
+ if (!typed.ok) {
227
+ await this.finish(envelope.type, envelope.id, delivery, () => this.toDlq(msg, typed.reason, envelope.id, envelope.type, delivery, {
228
+ fields: typed.fields,
229
+ }));
230
+ return;
231
+ }
232
+ // Шаг 3: inbox и обработчик в одной транзакции (dedupe 'inbox') или без БД (dedupe 'none').
233
+ let outcome;
234
+ try {
235
+ outcome = { ok: true, duplicate: await this.invoke(registered, typed.envelope) };
236
+ }
237
+ catch (error) {
238
+ outcome = { ok: false, error };
239
+ }
240
+ // Канал мог смениться, пока шёл обработчик: по доставке прошлого канала итога нет (брокер вернёт её).
241
+ if (!this.isCurrent(msg)) {
242
+ this.log.debug({ id: envelope.id }, 'messaging: устаревшая доставка, итог пропущен');
243
+ return;
244
+ }
245
+ // Шаг 4: подтверждение, повтор или DLQ.
246
+ await this.finish(envelope.type, envelope.id, delivery, async () => {
247
+ if (outcome.ok) {
248
+ this.main?.ack(msg);
249
+ return outcome.duplicate ? 'duplicate' : 'handled';
250
+ }
251
+ return this.onHandlerError(msg, envelope, delivery, outcome.error);
252
+ });
253
+ }
254
+ /** true — повтор `id` (inbox занят), обработчик не вызван. */
255
+ async invoke(registered, envelope) {
256
+ const { db, inbox } = this.deps;
257
+ if (this.deps.dedupe === 'none' || db === undefined || inbox === undefined) {
258
+ await registered.handler(envelope, null);
259
+ return false;
260
+ }
261
+ const ctx = {
262
+ tenant: envelope.tenant,
263
+ traceId: envelope.traceId ?? envelope.id,
264
+ actor: { channel: 'system' },
265
+ };
266
+ return db.transaction(ctx, async (tx) => {
267
+ if (!(await claimInbox(tx, inbox, envelope.id, envelope.type))) {
268
+ return true;
269
+ }
270
+ await registered.handler(envelope, tx);
271
+ return false;
272
+ });
273
+ }
274
+ typedEnvelope(event, envelope) {
275
+ if (tenantViolation(event, envelope)) {
276
+ return { ok: false, reason: 'tenant', fields: [] };
277
+ }
278
+ const data = event.data.safeParse(envelope.data);
279
+ if (!data.success) {
280
+ const fields = [
281
+ ...new Set(data.error.issues.map((issue) => issue.path.join('.') || '(root)')),
282
+ ];
283
+ return { ok: false, reason: 'data', fields };
284
+ }
285
+ return { ok: true, envelope: { ...envelope, data: data.data } };
286
+ }
287
+ async onHandlerError(msg, envelope, delivery, error) {
288
+ const headers = msg.properties.headers;
289
+ if (error instanceof InvalidMessageError) {
290
+ return this.toDlq(msg, error.reason, envelope.id, envelope.type, delivery, {
291
+ invalid: true,
292
+ });
293
+ }
294
+ const exhausted = decideRedelivery(headers, this.queues.main) === 'dlq';
295
+ if (error instanceof ExpectedMessageError) {
296
+ if (exhausted) {
297
+ return this.toDlq(msg, error.code, envelope.id, envelope.type, delivery, {
298
+ report: false,
299
+ });
300
+ }
301
+ this.log.warn({ id: envelope.id, type: envelope.type, code: error.code, delivery }, 'messaging: ожидаемый отказ, повтор');
302
+ return this.toRetry(msg);
303
+ }
304
+ if (exhausted) {
305
+ return this.toDlq(msg, 'retries-exhausted', envelope.id, envelope.type, delivery, {
306
+ error,
307
+ });
308
+ }
309
+ this.log.warn({ id: envelope.id, type: envelope.type, delivery, err: sanitizeError(error) }, 'messaging: ошибка обработчика, повтор');
310
+ return this.toRetry(msg);
311
+ }
312
+ /** Итог доставки: метрика и debug-строка; null — доставка прошлого канала (итога нет). */
313
+ async finish(type, id, delivery, decide) {
314
+ const result = await decide();
315
+ if (result === null) {
316
+ return;
317
+ }
318
+ this.deps.metrics?.eventsConsumed.inc({ type, result });
319
+ this.log.debug({ id, type, delivery, result }, 'messaging: доставка');
320
+ }
321
+ /** Отказ без возврата: dead-letter → `.retry` → через TTL снова в `<svc>`. */
322
+ toRetry(msg) {
323
+ this.main?.nack(msg, false, false);
324
+ return 'retry';
325
+ }
326
+ /** Копия в `<svc>.dlq` с `x-dlq-reason` и ack исходного после подтверждения копии; сбой копии — повтор. */
327
+ async toDlq(msg, reason, id, type, delivery, options = {}) {
328
+ const channel = this.main;
329
+ if (channel === null || !channel.isCurrent(msg)) {
330
+ this.log.debug({ id }, 'messaging: устаревшая доставка, копия в DLQ при повторе');
331
+ return null;
332
+ }
333
+ this.reportDlq(reason, id, type, delivery, options);
334
+ try {
335
+ await channel.sendToQueue(this.queues.dlq, msg.content, {
336
+ persistent: true,
337
+ messageId: msg.properties.messageId,
338
+ type: msg.properties.type,
339
+ contentType: msg.properties.contentType,
340
+ headers: { ...msg.properties.headers, [DLQ_REASON_HEADER]: reason },
341
+ });
342
+ channel.ack(msg);
343
+ return 'dlq';
344
+ }
345
+ catch (error) {
346
+ this.log.warn({ id, err: sanitizeError(error) }, 'messaging: копия в DLQ не удалась');
347
+ channel.nack(msg, false, false);
348
+ return 'retry';
349
+ }
350
+ }
351
+ reportDlq(reason, id, type, delivery, options) {
352
+ const fields = options.fields ?? [];
353
+ if (options.report === false) {
354
+ this.log.warn({ id, type, reason, delivery }, 'messaging: сообщение в DLQ');
355
+ return;
356
+ }
357
+ this.log.error({ id, type, reason, fields, delivery }, 'messaging: сообщение в DLQ');
358
+ if (reason === 'retries-exhausted') {
359
+ captureError(options.error, {
360
+ tags: { code: 'MESSAGE_DEAD_LETTERED' },
361
+ extra: { id, type, reason },
362
+ });
363
+ return;
364
+ }
365
+ // InvalidMessageError — решение обработчика, не сбой: в GlitchTip не отправляется (§3.4 v1.15, П7).
366
+ if (options.invalid === true || !PARSE_REASONS.has(reason)) {
367
+ return;
368
+ }
369
+ captureThrottled(`messaging-dlq:${type}:${reason}`, new Error(`messaging: сообщение в DLQ (${reason})`));
370
+ }
371
+ isCurrent(msg) {
372
+ return this.main !== null && this.main.isCurrent(msg);
373
+ }
374
+ /** Реплика (noAck): разбор, признак tenant, data, обработчик; неудача — без повторов и DLQ (П24). */
375
+ async handleReplica(msg) {
376
+ if (msg === null || this.stopping) {
377
+ return;
378
+ }
379
+ const parsed = parseEnvelope(msg.content);
380
+ if (!parsed.ok) {
381
+ this.dropReplica(UNKNOWN_TYPE, parsed.reason);
382
+ return;
383
+ }
384
+ const registered = this.replicaHandlers.get(parsed.envelope.type);
385
+ if (registered === undefined) {
386
+ this.dropReplica(UNKNOWN_TYPE, 'unsubscribed');
387
+ return;
388
+ }
389
+ const { type } = registered.event;
390
+ const typed = this.typedEnvelope(registered.event, parsed.envelope);
391
+ if (!typed.ok) {
392
+ this.dropReplica(type, typed.reason);
393
+ return;
394
+ }
395
+ try {
396
+ await registered.handler(typed.envelope, null);
397
+ this.deps.metrics?.eventsConsumed.inc({ type, result: 'handled' });
398
+ }
399
+ catch (error) {
400
+ this.dropReplica(type, 'handler', typed.envelope.id, error);
401
+ }
402
+ }
403
+ /**
404
+ * Реплика без повтора и DLQ — сообщение не сохраняется (§3.4 v1.15, П24): `error`-лог, событие `captureThrottled`,
405
+ * метрика `result: 'dlq'`.
406
+ */
407
+ dropReplica(type, reason, id = null, error) {
408
+ this.log.error({ id, type, reason, ...(error === undefined ? {} : { err: sanitizeError(error) }) }, 'messaging: сообщение реплики не обработано');
409
+ captureThrottled(`messaging-replica:${type}:${reason}`, error ?? new Error(`messaging: сообщение реплики отброшено (${reason})`));
410
+ this.deps.metrics?.eventsConsumed.inc({ type, result: 'dlq' });
411
+ }
412
+ }