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

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 +132 -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,67 @@
1
+ import type { Db } from '../database/db.js';
2
+ import type { KitMetrics } from '../observability/metrics.js';
3
+ import type { KitEvent } from './define-event.js';
4
+ import type { OutboxStore } from './outbox-store.js';
5
+ import { type MessagingLog, type RabbitConnection } from './rabbit-connection.js';
6
+ /** Часы relay (внутренние): подмена в тесте AC-5.16 — без реальных пауз. */
7
+ export interface RelayTimers {
8
+ /** Текущее время, мс. */
9
+ now(): number;
10
+ /** Пауза `ms` (`Infinity` — до прерывания); прерывание `signal` — resolve. */
11
+ sleep(ms: number, signal: AbortSignal, kind: 'pause' | 'confirm' | 'stats'): Promise<void>;
12
+ }
13
+ /** Зависимости relay (внутренние). */
14
+ export interface OutboxRelayDeps {
15
+ /** `Db` сервиса. */
16
+ readonly db: Db;
17
+ /** Запросы outbox. */
18
+ readonly store: OutboxStore;
19
+ /** Соединение. */
20
+ readonly connection: Pick<RabbitConnection, 'isConnected' | 'isChannelReady' | 'createChannel' | 'onChannelReady' | 'shutdownDeadline'>;
21
+ /** События модуля по type. */
22
+ readonly events: ReadonlyMap<string, KitEvent>;
23
+ /** Метрики экземпляра. */
24
+ readonly metrics?: KitMetrics;
25
+ /** Логгер. */
26
+ readonly log?: MessagingLog;
27
+ /** Часы. */
28
+ readonly timers?: RelayTimers;
29
+ }
30
+ /** Публикатор outbox: фоновый цикл, хуки Nest вызывает модуль (внутренний класс). */
31
+ export declare class OutboxRelay {
32
+ private readonly deps;
33
+ private readonly log;
34
+ private readonly timers;
35
+ private channel;
36
+ private unsubscribe;
37
+ private loop;
38
+ private stats;
39
+ private running;
40
+ private backoffMs;
41
+ private wakeRequested;
42
+ private sleepAbort;
43
+ private readonly statsAbort;
44
+ private requestStop;
45
+ private readonly stopRequested;
46
+ private statsFailing;
47
+ private readonly unknownTypes;
48
+ constructor(deps: OutboxRelayDeps);
49
+ /** Запуск цикла после старта приложения. */
50
+ onApplicationBootstrap(): void;
51
+ /** Будит цикл (после COMMIT `OutboxStore.add`, готовности канала, остановки). */
52
+ wake(): void;
53
+ /** Шаг 3 остановки: пачка дорабатывает в общем сроке, неподтверждённая публикация бросается. */
54
+ beforeApplicationShutdown(): Promise<void>;
55
+ private run;
56
+ private iterate;
57
+ private drain;
58
+ /** null — подтверждено; STOPPED — остановка прервала ожидание; иначе текст ошибки. */
59
+ private publish;
60
+ private subjectKind;
61
+ private reportFailure;
62
+ private nextBackoff;
63
+ /** Пробуждение во время drain не теряется: флаг проверяется до сна. */
64
+ private pause;
65
+ /** Метрики outbox раз в RELAY_POLL_MS, независимо от канала (П10). */
66
+ private statsLoop;
67
+ }
@@ -0,0 +1,260 @@
1
+ // OutboxRelay (prd.md §3.4 relay, UC-5, AC-5.3–AC-5.5, AC-5.11, AC-5.16, AC-9.5). Эталон — O outbox-relay.ts; из P —
2
+ // гонка publish с остановкой (STOPPED) и beforeApplicationShutdown против shutdownDeadline; по PRD — опрос 1000 мс
3
+ // (O — 200), метрики verdeect_outbox_* раз в RELAY_POLL_MS независимо от канала (П10), строка неизвестного типа —
4
+ // subject.kind 'unknown' (П23). Пачка ≤ 100 по seq под FOR UPDATE SKIP LOCKED, публикация последовательно с
5
+ // подтверждением; первая неудача прерывает пачку — порядок сохраняется. Без соединения или канала БД не опрашивается.
6
+ // Остановка во время неподтверждённой публикации оставляет строку без attempts + 1: повтор после рестарта —
7
+ // «не больше одного раза по смыслу» через inbox получателя (UC-6).
8
+ import { setTimeout as sleepFor } from 'node:timers/promises';
9
+ import { ulid } from '../internal/ids.js';
10
+ import { sanitizeError } from '../observability/sanitize-error.js';
11
+ import { captureError, captureThrottled } from '../observability/sentry.js';
12
+ import { buildEnvelope } from './envelope.js';
13
+ import { nestLog, } from './rabbit-connection.js';
14
+ import { EVENTS_EXCHANGE, PUBLISH_CONFIRM_TIMEOUT_MS, RELAY_ALERT_ATTEMPTS, RELAY_BACKOFF_MAX_MS, RELAY_BACKOFF_MIN_MS, RELAY_BATCH_DEADLINE_MS, RELAY_BATCH_SIZE, RELAY_POLL_MS, } from './topology.js';
15
+ const CHANNEL_NAME = 'outbox-relay';
16
+ const CONFIRM_DEADLINE_ERROR = 'confirm deadline';
17
+ const STOPPED = Symbol('stopped');
18
+ const realTimers = {
19
+ now: () => Date.now(),
20
+ sleep: async (ms, signal) => {
21
+ if (signal.aborted) {
22
+ return;
23
+ }
24
+ // Infinity — ждать прерывания: setTimeout не принимает бесконечность.
25
+ const wait = Number.isFinite(ms) ? ms : 2_147_483_647;
26
+ await sleepFor(wait, undefined, { signal, ref: false }).catch(() => undefined);
27
+ },
28
+ };
29
+ /** Публикатор outbox: фоновый цикл, хуки Nest вызывает модуль (внутренний класс). */
30
+ export class OutboxRelay {
31
+ deps;
32
+ log;
33
+ timers;
34
+ channel = null;
35
+ unsubscribe = null;
36
+ loop = null;
37
+ stats = null;
38
+ running = false;
39
+ backoffMs = 0;
40
+ wakeRequested = false;
41
+ sleepAbort = new AbortController();
42
+ statsAbort = new AbortController();
43
+ requestStop = () => undefined;
44
+ stopRequested = new Promise((resolve) => {
45
+ this.requestStop = () => resolve(STOPPED);
46
+ });
47
+ statsFailing = false;
48
+ unknownTypes = new Set();
49
+ constructor(deps) {
50
+ this.deps = deps;
51
+ this.log = deps.log ?? nestLog('OutboxRelay');
52
+ this.timers = deps.timers ?? realTimers;
53
+ }
54
+ /** Запуск цикла после старта приложения. */
55
+ onApplicationBootstrap() {
56
+ if (this.running) {
57
+ return;
58
+ }
59
+ this.running = true;
60
+ this.channel = this.deps.connection.createChannel({
61
+ name: CHANNEL_NAME,
62
+ confirm: true,
63
+ publishTimeout: PUBLISH_CONFIRM_TIMEOUT_MS,
64
+ });
65
+ // Канал настроен (после подключения или повтора): пауза сбрасывается, разгрузка — сразу.
66
+ this.unsubscribe = this.deps.connection.onChannelReady(CHANNEL_NAME, () => {
67
+ this.backoffMs = 0;
68
+ this.wake();
69
+ });
70
+ this.loop = this.run();
71
+ this.stats = this.statsLoop();
72
+ this.log.debug({}, 'outbox: relay запущен');
73
+ }
74
+ /** Будит цикл (после COMMIT `OutboxStore.add`, готовности канала, остановки). */
75
+ wake() {
76
+ this.wakeRequested = true;
77
+ this.sleepAbort.abort();
78
+ }
79
+ /** Шаг 3 остановки: пачка дорабатывает в общем сроке, неподтверждённая публикация бросается. */
80
+ async beforeApplicationShutdown() {
81
+ if (!this.running) {
82
+ return;
83
+ }
84
+ this.running = false;
85
+ this.requestStop();
86
+ this.wake();
87
+ this.statsAbort.abort();
88
+ const loop = this.loop ?? Promise.resolve();
89
+ const stopped = await Promise.race([
90
+ loop.then(() => true),
91
+ this.deps.connection.shutdownDeadline(),
92
+ ]);
93
+ if (!stopped) {
94
+ this.log.warn({}, 'outbox: срок остановки relay вышел, идём дальше');
95
+ }
96
+ await this.stats;
97
+ this.unsubscribe?.();
98
+ this.unsubscribe = null;
99
+ await this.channel?.close();
100
+ this.channel = null;
101
+ this.log.debug({}, 'outbox: relay остановлен');
102
+ }
103
+ async run() {
104
+ while (this.running) {
105
+ const pause = await this.iterate();
106
+ if (!this.running) {
107
+ return;
108
+ }
109
+ await this.pause(pause);
110
+ }
111
+ }
112
+ async iterate() {
113
+ const { connection } = this.deps;
114
+ if (!connection.isConnected() || !connection.isChannelReady(CHANNEL_NAME)) {
115
+ // Без соединения или канала БД не опрашивается: строки копятся, lag растёт; ждём готовности канала.
116
+ return Infinity;
117
+ }
118
+ try {
119
+ const result = await this.drain();
120
+ if (result.failed) {
121
+ return this.nextBackoff();
122
+ }
123
+ this.backoffMs = 0;
124
+ return result.claimed === RELAY_BATCH_SIZE ? 0 : RELAY_POLL_MS;
125
+ }
126
+ catch (error) {
127
+ this.log.error({ err: sanitizeError(error) }, 'outbox: ошибка БД relay');
128
+ captureThrottled('outbox-relay:db', error);
129
+ return this.nextBackoff();
130
+ }
131
+ }
132
+ async drain() {
133
+ const ctx = { tenant: null, traceId: ulid(), actor: { channel: 'system' } };
134
+ return this.deps.db.transaction(ctx, async (tx) => {
135
+ const rows = await this.deps.store.claimBatch(tx, RELAY_BATCH_SIZE);
136
+ const deadline = this.timers.now() + RELAY_BATCH_DEADLINE_MS;
137
+ const published = [];
138
+ let failed = false;
139
+ for (const row of rows) {
140
+ if (!this.running) {
141
+ break;
142
+ }
143
+ const remaining = deadline - this.timers.now();
144
+ if (remaining <= 0) {
145
+ this.log.warn({ published: published.length, left: rows.length - published.length }, 'outbox: срок пачки вышел');
146
+ break;
147
+ }
148
+ const outcome = await this.publish(row, remaining);
149
+ if (outcome === STOPPED) {
150
+ break;
151
+ }
152
+ if (outcome === null) {
153
+ published.push(row.id);
154
+ continue;
155
+ }
156
+ failed = true;
157
+ const attempts = await this.deps.store.markFailed(tx, row.id, outcome);
158
+ this.reportFailure(row, attempts, outcome);
159
+ break;
160
+ }
161
+ await this.deps.store.markPublished(tx, published);
162
+ if (published.length > 0) {
163
+ this.log.debug({ published: published.length, claimed: rows.length }, 'outbox: пачка опубликована');
164
+ }
165
+ return { claimed: rows.length, failed };
166
+ });
167
+ }
168
+ /** null — подтверждено; STOPPED — остановка прервала ожидание; иначе текст ошибки. */
169
+ async publish(row, timeoutMs) {
170
+ const channel = this.channel;
171
+ if (channel === null) {
172
+ return 'channel closed';
173
+ }
174
+ const envelope = buildEnvelope({ ...row, subjectKind: this.subjectKind(row.type) });
175
+ const confirmAbort = new AbortController();
176
+ const confirmed = channel
177
+ .publish(EVENTS_EXCHANGE, row.type, Buffer.from(JSON.stringify(envelope)), {
178
+ persistent: true,
179
+ messageId: row.id,
180
+ type: row.type,
181
+ contentType: 'application/json',
182
+ // AMQP timestamp — секунды.
183
+ timestamp: Math.floor(row.occurredAt.getTime() / 1000),
184
+ })
185
+ .then(() => null, (error) => (error instanceof Error ? error.message : String(error)));
186
+ const deadline = this.timers
187
+ .sleep(timeoutMs, confirmAbort.signal, 'confirm')
188
+ .then(() => (confirmAbort.signal.aborted ? null : CONFIRM_DEADLINE_ERROR));
189
+ try {
190
+ // Опоздавшее подтверждение даст дубль при повторе: доставка at-least-once.
191
+ return await Promise.race([
192
+ confirmed,
193
+ deadline.then((value) => value ?? new Promise(() => undefined)),
194
+ this.stopRequested,
195
+ ]);
196
+ }
197
+ finally {
198
+ confirmAbort.abort();
199
+ }
200
+ }
201
+ subjectKind(type) {
202
+ const event = this.deps.events.get(type);
203
+ if (event !== undefined) {
204
+ return event.subject;
205
+ }
206
+ if (!this.unknownTypes.has(type)) {
207
+ this.unknownTypes.add(type);
208
+ this.log.warn({ type }, 'outbox: тип события вне outbox.events — subject.kind unknown');
209
+ }
210
+ return 'unknown';
211
+ }
212
+ reportFailure(row, attempts, error) {
213
+ this.log.warn({ id: row.id, type: row.type, attempts, error }, 'outbox: публикация не удалась');
214
+ if (attempts !== RELAY_ALERT_ATTEMPTS) {
215
+ return;
216
+ }
217
+ // Ровно раз на строку: attempts растёт монотонно. Без data (AC-5.16).
218
+ this.log.error({ id: row.id, type: row.type, attempts }, 'outbox: строка не опубликована 5 раз');
219
+ captureError(new Error('outbox: строка не опубликована 5 раз'), {
220
+ tags: { code: 'OUTBOX_PUBLISH_FAILED' },
221
+ extra: { id: row.id, type: row.type, attempts },
222
+ });
223
+ }
224
+ nextBackoff() {
225
+ this.backoffMs =
226
+ this.backoffMs === 0
227
+ ? RELAY_BACKOFF_MIN_MS
228
+ : Math.min(this.backoffMs * 2, RELAY_BACKOFF_MAX_MS);
229
+ return this.backoffMs;
230
+ }
231
+ /** Пробуждение во время drain не теряется: флаг проверяется до сна. */
232
+ async pause(ms) {
233
+ if (ms === 0 || this.wakeRequested) {
234
+ this.wakeRequested = false;
235
+ return;
236
+ }
237
+ this.sleepAbort = new AbortController();
238
+ await this.timers.sleep(ms, this.sleepAbort.signal, 'pause');
239
+ this.wakeRequested = false;
240
+ }
241
+ /** Метрики outbox раз в RELAY_POLL_MS, независимо от канала (П10). */
242
+ async statsLoop() {
243
+ const ctx = { tenant: null, traceId: ulid(), actor: { channel: 'system' } };
244
+ while (!this.statsAbort.signal.aborted) {
245
+ try {
246
+ const { pending, lag } = await this.deps.db.transaction(ctx, (tx) => this.deps.store.stats(tx));
247
+ this.deps.metrics?.outboxPending.set(pending);
248
+ this.deps.metrics?.outboxLagSeconds.set(lag);
249
+ this.statsFailing = false;
250
+ }
251
+ catch (error) {
252
+ if (!this.statsFailing) {
253
+ this.statsFailing = true;
254
+ this.log.warn({ err: sanitizeError(error) }, 'outbox: метрики не обновлены');
255
+ }
256
+ }
257
+ await this.timers.sleep(RELAY_POLL_MS, this.statsAbort.signal, 'stats');
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,75 @@
1
+ import type { z } from 'zod';
2
+ import type { Db, DbTx } from '../database/db.js';
3
+ import type { KitEvent } from './define-event.js';
4
+ import { type MessagingLog } from './rabbit-connection.js';
5
+ import type { KitOutboxTable } from './tables.js';
6
+ /** Строка, взятая relay. */
7
+ export interface OutboxClaimedRow {
8
+ /** id события. */
9
+ readonly id: string;
10
+ /** Тип. */
11
+ readonly type: string;
12
+ /** Версия. */
13
+ readonly version: number;
14
+ /** Тенант. */
15
+ readonly tenant: string | null;
16
+ /** id субъекта. */
17
+ readonly subjectId: string;
18
+ /** Актор. */
19
+ readonly actor: Record<string, unknown>;
20
+ /** traceId. */
21
+ readonly traceId: string;
22
+ /** Данные. */
23
+ readonly data: Record<string, unknown>;
24
+ /** Время события. */
25
+ readonly occurredAt: Date;
26
+ /** Неудачных публикаций. */
27
+ readonly attempts: number;
28
+ }
29
+ /** Зависимости `OutboxStore` (внутренние). */
30
+ export interface OutboxStoreDeps {
31
+ /** `Db` сервиса. */
32
+ readonly db: Db;
33
+ /** Таблица outbox. */
34
+ readonly table: KitOutboxTable;
35
+ /** События `outbox.events` модуля по type. */
36
+ readonly events: ReadonlyMap<string, KitEvent>;
37
+ /** Пробуждение relay после COMMIT. */
38
+ readonly wake: () => void;
39
+ /** Логгер. */
40
+ readonly log?: MessagingLog;
41
+ }
42
+ /**
43
+ * Запись событий в outbox в транзакции сервиса (§3.4): `add(tx, Event, {subjectId, data})`. Строка видна relay после
44
+ * `COMMIT` вместе с изменением домена; откат транзакции — ни изменения, ни события.
45
+ */
46
+ export declare class OutboxStore {
47
+ private readonly deps;
48
+ private readonly log;
49
+ /** @internal Создаёт KitMessagingModule. */
50
+ constructor(deps: OutboxStoreDeps);
51
+ /**
52
+ * Добавляет событие в outbox транзакции `tx` (`Db.transaction`): `tenant`, `actor`, `traceId` — из `DbContext`,
53
+ * `id` — `evt_` + ULID; после `COMMIT` relay публикует его без ожидания опроса. Любая ошибка — исключение до записи:
54
+ * транзакция сервиса откатывается.
55
+ * @returns id события.
56
+ * @throws {TypeError} событие не из `outbox.events`, `tx` не из `Db.transaction`, признак `tenant` нарушен, пустой
57
+ * `subjectId`, `data` не по схеме (в тексте — пути полей без значений).
58
+ * @throws {AfterCommitInReadOnlyError} `tx` из `Db.read`.
59
+ */
60
+ add<E extends KitEvent>(tx: DbTx, event: E, input: {
61
+ readonly subjectId: string;
62
+ readonly data: z.input<E['data']>;
63
+ }): Promise<string>;
64
+ /** @internal Пачка неопубликованных по `seq` под `FOR UPDATE SKIP LOCKED` (relay). */
65
+ claimBatch(tx: DbTx, limit: number): Promise<OutboxClaimedRow[]>;
66
+ /** @internal Отметка опубликованных. */
67
+ markPublished(tx: DbTx, ids: readonly string[]): Promise<void>;
68
+ /** @internal Неудача публикации: `attempts + 1`, `last_error` ≤ 500; возвращает новое `attempts`. */
69
+ markFailed(tx: DbTx, id: string, error: string): Promise<number>;
70
+ /** @internal Число неопубликованных и возраст старейшей, с (метрики). */
71
+ stats(tx: DbTx): Promise<{
72
+ readonly pending: number;
73
+ readonly lag: number;
74
+ }>;
75
+ }
@@ -0,0 +1,126 @@
1
+ // OutboxStore (prd.md §3.4, §6, UC-5, AC-5.1, AC-5.2, AC-5.15). Эталон — O outbox-store.ts; по PRD — id = newId('evt') в
2
+ // коде, tenant/actor/traceId из DbContext (txContextOf), проверка data и признака tenant до записи, afterCommit →
3
+ // relay.wake(); SQL-триггеров нет (P0). Запросы relay и очистки — построителем Drizzle по объекту таблицы (AC-9.4).
4
+ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
5
+ import { newId } from '../internal/ids.js';
6
+ import { txContextOf } from '../internal/tx-context.js';
7
+ import { nestLog } from './rabbit-connection.js';
8
+ /** Предел `last_error`. */
9
+ const LAST_ERROR_MAX = 500;
10
+ /**
11
+ * Запись событий в outbox в транзакции сервиса (§3.4): `add(tx, Event, {subjectId, data})`. Строка видна relay после
12
+ * `COMMIT` вместе с изменением домена; откат транзакции — ни изменения, ни события.
13
+ */
14
+ export class OutboxStore {
15
+ deps;
16
+ log;
17
+ /** @internal Создаёт KitMessagingModule. */
18
+ constructor(deps) {
19
+ this.deps = deps;
20
+ this.log = deps.log ?? nestLog('OutboxStore');
21
+ }
22
+ /**
23
+ * Добавляет событие в outbox транзакции `tx` (`Db.transaction`): `tenant`, `actor`, `traceId` — из `DbContext`,
24
+ * `id` — `evt_` + ULID; после `COMMIT` relay публикует его без ожидания опроса. Любая ошибка — исключение до записи:
25
+ * транзакция сервиса откатывается.
26
+ * @returns id события.
27
+ * @throws {TypeError} событие не из `outbox.events`, `tx` не из `Db.transaction`, признак `tenant` нарушен, пустой
28
+ * `subjectId`, `data` не по схеме (в тексте — пути полей без значений).
29
+ * @throws {AfterCommitInReadOnlyError} `tx` из `Db.read`.
30
+ */
31
+ async add(tx, event, input) {
32
+ const { type } = event;
33
+ if (this.deps.events.get(type) !== event) {
34
+ throw new TypeError(`OutboxStore.add: событие ${type} не объявлено в KitMessagingModule.forRoot({outbox: {events}})`);
35
+ }
36
+ const ctx = txContextOf(tx);
37
+ if (ctx === undefined) {
38
+ throw new TypeError('OutboxStore.add: tx — не транзакция Db.transaction');
39
+ }
40
+ const tenant = ctx.tenant ?? '';
41
+ if (event.tenant === 'required' && tenant === '') {
42
+ throw new TypeError(`OutboxStore.add: событие ${type} требует компанию (tenant: 'required'), в DbContext тенанта нет`);
43
+ }
44
+ if (event.tenant === 'none' && tenant !== '') {
45
+ throw new TypeError(`OutboxStore.add: событие ${type} без компании (tenant: 'none'), а в DbContext тенант задан`);
46
+ }
47
+ if (typeof input.subjectId !== 'string' || input.subjectId === '') {
48
+ throw new TypeError(`OutboxStore.add: событие ${type} — пустой subjectId`);
49
+ }
50
+ const parsed = event.data.safeParse(input.data);
51
+ if (!parsed.success) {
52
+ const paths = [
53
+ ...new Set(parsed.error.issues.map((issue) => issue.path.join('.') || '(root)')),
54
+ ];
55
+ throw new TypeError(`OutboxStore.add: data события ${type} не по схеме: ${paths.join(', ')}`);
56
+ }
57
+ // До INSERT: read-only транзакция отклоняется здесь (AfterCommitInReadOnlyError), а не ошибкой 25006.
58
+ this.deps.db.afterCommit(tx, () => {
59
+ this.deps.wake();
60
+ });
61
+ const id = newId('evt');
62
+ await tx.insert(this.deps.table).values({
63
+ id,
64
+ tenant: event.tenant === 'none' ? null : tenant,
65
+ type,
66
+ version: event.version,
67
+ subjectId: input.subjectId,
68
+ actor: { ...ctx.actor },
69
+ traceId: ctx.traceId,
70
+ data: parsed.data,
71
+ });
72
+ this.log.debug({ type, id }, 'outbox: строка добавлена');
73
+ return id;
74
+ }
75
+ /** @internal Пачка неопубликованных по `seq` под `FOR UPDATE SKIP LOCKED` (relay). */
76
+ async claimBatch(tx, limit) {
77
+ const t = this.deps.table;
78
+ return tx
79
+ .select({
80
+ id: t.id,
81
+ type: t.type,
82
+ version: t.version,
83
+ tenant: t.tenant,
84
+ subjectId: t.subjectId,
85
+ actor: t.actor,
86
+ traceId: t.traceId,
87
+ data: t.data,
88
+ occurredAt: t.occurredAt,
89
+ attempts: t.attempts,
90
+ })
91
+ .from(t)
92
+ .where(isNull(t.publishedAt))
93
+ .orderBy(t.seq)
94
+ .limit(limit)
95
+ .for('update', { skipLocked: true });
96
+ }
97
+ /** @internal Отметка опубликованных. */
98
+ async markPublished(tx, ids) {
99
+ if (ids.length === 0) {
100
+ return;
101
+ }
102
+ const t = this.deps.table;
103
+ await tx
104
+ .update(t)
105
+ .set({ publishedAt: sql `now()` })
106
+ .where(inArray(t.id, [...ids]));
107
+ }
108
+ /** @internal Неудача публикации: `attempts + 1`, `last_error` ≤ 500; возвращает новое `attempts`. */
109
+ async markFailed(tx, id, error) {
110
+ const t = this.deps.table;
111
+ const [row] = await tx
112
+ .update(t)
113
+ .set({ attempts: sql `${t.attempts} + 1`, lastError: error.slice(0, LAST_ERROR_MAX) })
114
+ .where(and(eq(t.id, id), isNull(t.publishedAt)))
115
+ .returning({ attempts: t.attempts });
116
+ return row?.attempts ?? 0;
117
+ }
118
+ /** @internal Число неопубликованных и возраст старейшей, с (метрики). */
119
+ async stats(tx) {
120
+ const t = this.deps.table;
121
+ const { rows } = await tx.execute(sql `select count(*)::int as pending,
122
+ coalesce(extract(epoch from now() - min(${t.occurredAt}))::float8, 0) as lag
123
+ from ${t} where ${t.publishedAt} is null`);
124
+ return { pending: rows[0]?.pending ?? 0, lag: Math.max(0, rows[0]?.lag ?? 0) };
125
+ }
126
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ // Проверка опциональных peer /messaging при загрузке подпути (prd.md NFR-3, AC-1.2; П15 E4): без top-level await.
2
+ // Первый импорт index.ts — текст AC-1.2 называет /messaging. Сами пакеты грузит rabbit-connection.ts при подключении.
3
+ import { requirePeer } from '../internal/peer.js';
4
+ requirePeer('amqplib', 'messaging');
5
+ requirePeer('amqp-connection-manager', 'messaging');