@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,233 @@
1
+ // initSentry, скраббер событий, captureError и captureThrottled (prd.md §3.2, AC-2.4, AC-2.5, AC-2.6, AC-2.12, NFR-4).
2
+ // Эталоны: O instrument.ts (NO_PERSONAL_DATA, условие включения, tracesSampleRate 0), O sentry-scrubber.ts (scrubValue,
3
+ // глубина 8 → [Truncated], заголовки, user → {id}, хвост params: и Key (…)=(…), запасной путь при исключении), троттлинг —
4
+ // O pool-timeout-reporter.ts (окно 60 с, performance.now()). По PRD: имена — sensitiveNames(redaction) без доменных ключей
5
+ // O; query удаляется целиком (и из request.url); путь — через maskPath; envSentry разбирается без исключения.
6
+ // envSentry — из fragments.js, не из config/index.js: индекс тянет @nestjs/*, а instrument.ts грузится до Nest (LE-5).
7
+ import * as Sentry from '@sentry/nestjs';
8
+ import { z } from 'zod';
9
+ import { envSentry } from '../config/fragments.js';
10
+ import { maskPathOf, sensitiveNames } from './redaction.js';
11
+ import { sanitizeError } from './sanitize-error.js';
12
+ /** Sentry 11: вместо sendDefaultPii — ничего из ПДн, заголовков, тел, query и данных БД не собирается. */
13
+ export const NO_PERSONAL_DATA = {
14
+ userInfo: false,
15
+ cookies: false,
16
+ httpHeaders: false,
17
+ httpBodies: [],
18
+ urlQueryParams: false,
19
+ databaseQueryData: false,
20
+ queues: false,
21
+ stackFrameVariables: false,
22
+ genAI: { inputs: false, outputs: false },
23
+ };
24
+ const FILTERED = '[Filtered]';
25
+ const TRUNCATED = '[Truncated]';
26
+ const MAX_DEPTH = 8;
27
+ const DROPPED_HEADERS = new Set(['authorization', 'cookie', 'set-cookie', 'x-forwarded-for']);
28
+ const DEFAULT_WINDOW_MS = 60_000;
29
+ // То же выражение, что DSN_REGEX @sentry/core utils/dsn.js: неверный DSN SDK печатает через console.error со значением.
30
+ const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)((?:\[[:.%\w]+\]|[\w.-]+))(?::(\d+))?\/(.+)/;
31
+ // Хвост сообщения DrizzleQueryError («\nparams: …») и detail pg «Key (sub)=(…)».
32
+ const PARAMS_TAIL = /\nparams:[\s\S]*$/;
33
+ const KEY_VALUE = /Key \([^)]*\)=\([^)]*\)/g;
34
+ const URL_PARTS = /^([a-z][a-z\d+.-]*:\/\/[^/]*)?(.*)$/i;
35
+ function scrubNames(redaction) {
36
+ const names = sensitiveNames(redaction).map((name) => name.toLowerCase());
37
+ return {
38
+ plain: new Set(names.filter((name) => !name.includes('.'))),
39
+ dotted: new Set(names.filter((name) => name.includes('.'))),
40
+ };
41
+ }
42
+ function isSensitive(names, key, parent) {
43
+ const lower = key.toLowerCase();
44
+ if (names.plain.has(lower)) {
45
+ return true;
46
+ }
47
+ return parent !== undefined && names.dotted.has(`${parent.toLowerCase()}.${lower}`);
48
+ }
49
+ function scrubValue(value, names, depth = 0, parent) {
50
+ if (typeof value !== 'object' || value === null) {
51
+ return value;
52
+ }
53
+ if (depth >= MAX_DEPTH) {
54
+ return TRUNCATED;
55
+ }
56
+ if (Array.isArray(value)) {
57
+ return value.map((item) => scrubValue(item, names, depth + 1, parent));
58
+ }
59
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
60
+ key,
61
+ isSensitive(names, key, parent) ? FILTERED : scrubValue(item, names, depth + 1, key),
62
+ ]));
63
+ }
64
+ /** Адрес без query и фрагмента, путь — через maskPath. */
65
+ function scrubUrl(url, maskPath) {
66
+ const base = url.split(/[?#]/, 1)[0] ?? '';
67
+ const [, origin = '', path = ''] = URL_PARTS.exec(base) ?? [];
68
+ return `${origin}${maskPath(path)}`;
69
+ }
70
+ function scrubExceptionText(value) {
71
+ return value.replace(PARAMS_TAIL, '').replace(KEY_VALUE, 'Key [Filtered]');
72
+ }
73
+ /**
74
+ * Текст исключений: ошибка, которую изменил sanitizeError (БД, ioredis, ветка сервиса), — его сообщение во всех values
75
+ * (linkedErrors добавляет цепочку cause отдельными values; текст ioredis повторяет ключи Redis); прочие — без хвоста
76
+ * params: и «Key (…)=(…)».
77
+ */
78
+ function scrubException(event, originalException) {
79
+ const values = event.exception?.values;
80
+ if (!values) {
81
+ return;
82
+ }
83
+ const sanitized = sanitizeError(originalException);
84
+ const safeMessage = sanitized instanceof Error && sanitized !== originalException ? sanitized.message : null;
85
+ for (const value of values) {
86
+ if (safeMessage !== null) {
87
+ value.value = scrubExceptionText(safeMessage);
88
+ }
89
+ else if (value.value !== undefined) {
90
+ value.value = scrubExceptionText(value.value);
91
+ }
92
+ }
93
+ }
94
+ function scrubRequest(request, names, maskPath) {
95
+ if (typeof request.url === 'string') {
96
+ request.url = scrubUrl(request.url, maskPath);
97
+ }
98
+ delete request.query_string;
99
+ delete request.cookies;
100
+ delete request.data;
101
+ if (request.headers) {
102
+ const headers = Object.fromEntries(Object.entries(request.headers).filter(([name]) => !DROPPED_HEADERS.has(name.toLowerCase())));
103
+ request.headers = scrubValue(headers, names);
104
+ }
105
+ }
106
+ /** Хлебная крошка без чувствительных данных; адрес — без query и через maskPath. Внутренняя функция. */
107
+ export function scrubBreadcrumb(crumb, redaction) {
108
+ if (!crumb.data) {
109
+ return crumb;
110
+ }
111
+ const data = scrubValue(crumb.data, scrubNames(redaction));
112
+ if (typeof data.url === 'string') {
113
+ data.url = scrubUrl(data.url, maskPathOf(redaction));
114
+ }
115
+ return { ...crumb, data };
116
+ }
117
+ /**
118
+ * Событие без query, `request.data`, cookie, ПДн пользователя (кроме `id`) и значений чувствительных имён в
119
+ * extra, contexts, tags и breadcrumbs (§3.2, AC-2.5). Внутренняя функция. При исключении отбрасываются
120
+ * request, extra, breadcrumbs и exception.
121
+ */
122
+ export function scrubEvent(event, originalException, redaction) {
123
+ try {
124
+ const names = scrubNames(redaction);
125
+ scrubException(event, originalException);
126
+ if (event.request) {
127
+ scrubRequest(event.request, names, maskPathOf(redaction));
128
+ }
129
+ if (event.user) {
130
+ event.user = event.user.id === undefined ? {} : { id: event.user.id };
131
+ }
132
+ if (event.extra) {
133
+ event.extra = scrubValue(event.extra, names);
134
+ }
135
+ if (event.contexts) {
136
+ event.contexts = scrubValue(event.contexts, names);
137
+ }
138
+ if (event.tags) {
139
+ event.tags = scrubValue(event.tags, names);
140
+ }
141
+ if (event.breadcrumbs) {
142
+ event.breadcrumbs = event.breadcrumbs.map((crumb) => scrubBreadcrumb(crumb, redaction));
143
+ }
144
+ return event;
145
+ }
146
+ catch {
147
+ delete event.request;
148
+ delete event.extra;
149
+ delete event.breadcrumbs;
150
+ delete event.exception;
151
+ return event;
152
+ }
153
+ }
154
+ /**
155
+ * DSN в форме, которую принимает SDK: иначе он сам печатает значение в console.error (NFR-4). Внутренняя функция: её же
156
+ * использует bootstrapService для SENTRY_NOT_PRELOADED.
157
+ */
158
+ export function isValidDsn(dsn) {
159
+ const match = DSN_REGEX.exec(dsn);
160
+ if (!match) {
161
+ return false;
162
+ }
163
+ const [, protocol, , , , , path = ''] = match;
164
+ const projectId = path.split('/').pop() ?? '';
165
+ return (protocol === 'http' || protocol === 'https') && /^\d+$/.test(projectId);
166
+ }
167
+ /**
168
+ * Включает GlitchTip (§3.2): вызывается в `instrument.ts` сервиса, подключённом через `node --import` до загрузки
169
+ * приложения. Включается только при непустом и верном `SENTRY_DSN` и `NODE_ENV = production` (не задан — production);
170
+ * неверное окружение — Sentry выключен, без исключения и лога. `dataCollection: NO_PERSONAL_DATA`,
171
+ * `tracesSampleRate: 0`, тег `service`. Возвращает, включён ли SDK.
172
+ */
173
+ export function initSentry(options) {
174
+ const parsed = z.object(envSentry).safeParse(process.env);
175
+ if (!parsed.success) {
176
+ return false;
177
+ }
178
+ const env = parsed.data;
179
+ if (env.SENTRY_DSN === null || env.NODE_ENV !== 'production' || !isValidDsn(env.SENTRY_DSN)) {
180
+ return false;
181
+ }
182
+ Sentry.init({
183
+ dsn: env.SENTRY_DSN,
184
+ environment: env.SENTRY_ENVIRONMENT ?? 'production',
185
+ ...(env.SENTRY_RELEASE === null ? {} : { release: env.SENTRY_RELEASE }),
186
+ dataCollection: NO_PERSONAL_DATA,
187
+ tracesSampleRate: 0,
188
+ initialScope: { tags: { service: options.service } },
189
+ beforeSend: (event, hint) => scrubEvent(event, hint.originalException, options.redaction),
190
+ beforeBreadcrumb: (crumb) => scrubBreadcrumb(crumb, options.redaction),
191
+ });
192
+ return true;
193
+ }
194
+ /** Ошибки, уже отправленные в GlitchTip. */
195
+ const sentErrors = new WeakSet();
196
+ /** Время последней отправки по ключу `captureThrottled` (performance.now()). */
197
+ const lastSentAt = new Map();
198
+ function wasSent(error) {
199
+ return typeof error === 'object' && error !== null && sentErrors.has(error);
200
+ }
201
+ /**
202
+ * Отправляет ошибку в GlitchTip через `sanitizeError` — ровно одно событие на ошибку (§3.2): объект ошибки
203
+ * помечается отправленным, повторный вызов ничего не шлёт.
204
+ */
205
+ export function captureError(error, context) {
206
+ if (wasSent(error)) {
207
+ return;
208
+ }
209
+ Sentry.captureException(sanitizeError(error), {
210
+ ...(context?.tags === undefined ? {} : { tags: { ...context.tags } }),
211
+ ...(context?.extra === undefined ? {} : { extra: { ...context.extra } }),
212
+ });
213
+ if (typeof error === 'object' && error !== null) {
214
+ sentErrors.add(error);
215
+ }
216
+ }
217
+ /**
218
+ * Одно событие за окно `windowMs` (умолч. 60 000) на ключ в процессе (§3.2, AC-2.6). Уже отправленная ошибка
219
+ * (например, границей через `captureError`) повторно не шлётся. Возвращает, отправлено ли событие.
220
+ */
221
+ export function captureThrottled(key, error, options) {
222
+ if (wasSent(error)) {
223
+ return false;
224
+ }
225
+ const now = performance.now();
226
+ const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
227
+ if (now - (lastSentAt.get(key) ?? -Infinity) < windowMs) {
228
+ return false;
229
+ }
230
+ lastSentAt.set(key, now);
231
+ captureError(error);
232
+ return true;
233
+ }
@@ -0,0 +1,34 @@
1
+ import type { Redis } from 'ioredis';
2
+ import { type RedisLog } from './client.js';
3
+ /** Обработчик сообщения канала: строка сообщения (JSON — забота сервиса). */
4
+ type ChannelHandler = (message: string) => void | Promise<void>;
5
+ /**
6
+ * Каналы Redis сервиса с префиксом `<svc>:` (§3.7): `publish('x')` публикует в `<svc>:x`, `subscribe('x')` слушает
7
+ * `<svc>:x` и отдаёт обработчику сообщение. Подписки требуют `KitRedisModule.forRoot({subscriber: true})`.
8
+ */
9
+ export declare class KitRedisChannels {
10
+ private readonly redis;
11
+ private readonly prefix;
12
+ private readonly log;
13
+ private readonly subscriber?;
14
+ private readonly channels;
15
+ constructor(redis: Redis, prefix: string, log: RedisLog, subscriber?: Redis | undefined);
16
+ /**
17
+ * Публикует сообщение в канал `<svc>:<channel>` основным соединением; число получателей. Ошибка Redis — исключение
18
+ * вызывающему.
19
+ */
20
+ publish(channel: string, message: string): Promise<number>;
21
+ /**
22
+ * Подписывает обработчик на канал `<svc>:<channel>`; одна подписка Redis на канал. Разрешается после подтверждения
23
+ * `SUBSCRIBE` (одновременные вызовы ждут одного подтверждения); отказ `SUBSCRIBE` отклоняет все ожидающие вызовы, и
24
+ * следующий `subscribe` шлёт `SUBSCRIBE` заново. Возвращает функцию отписки: уход последнего обработчика —
25
+ * `UNSUBSCRIBE`. Ошибка обработчика — `error` + GlitchTip, остальные обработчики получают сообщение. Без
26
+ * `subscriber: true` — `Error`.
27
+ */
28
+ subscribe(channel: string, handler: ChannelHandler): Promise<() => Promise<void>>;
29
+ /** Новая запись канала и SUBSCRIBE; отказ снимает запись, если её ещё не заменили. */
30
+ private open;
31
+ private release;
32
+ private dispatch;
33
+ }
34
+ export {};
@@ -0,0 +1,128 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ // KitRedisChannels (prd.md §3.7 «префикс к каналам ставит обёртка», AC-8.1, AC-8.6): keyPrefix ioredis не действует на
14
+ // каналы. Механика Set обработчиков — O RedisBus, без JSON и повторов SUBSCRIBE (повторная подписка после
15
+ // переподключения — autoResubscribe ioredis). KT-31: одно обещание SUBSCRIBE на канал — одновременные subscribe ждут его,
16
+ // отказ SUBSCRIBE снимает запись канала (повторный subscribe шлёт SUBSCRIBE заново).
17
+ import { Inject, Injectable, Optional } from '@nestjs/common';
18
+ import { KIT_REDIS_LOG, KIT_REDIS_PREFIX, KIT_REDIS_SUBSCRIBER } from '../internal/tokens.js';
19
+ import { sanitizeError } from '../observability/sanitize-error.js';
20
+ import { captureError } from '../observability/sentry.js';
21
+ import { KIT_REDIS } from './client.js';
22
+ /**
23
+ * Каналы Redis сервиса с префиксом `<svc>:` (§3.7): `publish('x')` публикует в `<svc>:x`, `subscribe('x')` слушает
24
+ * `<svc>:x` и отдаёт обработчику сообщение. Подписки требуют `KitRedisModule.forRoot({subscriber: true})`.
25
+ */
26
+ let KitRedisChannels = class KitRedisChannels {
27
+ redis;
28
+ prefix;
29
+ log;
30
+ subscriber;
31
+ channels = new Map();
32
+ constructor(redis, prefix, log, subscriber) {
33
+ this.redis = redis;
34
+ this.prefix = prefix;
35
+ this.log = log;
36
+ this.subscriber = subscriber;
37
+ this.subscriber?.on('message', (channel, message) => {
38
+ this.dispatch(channel, message);
39
+ });
40
+ }
41
+ /**
42
+ * Публикует сообщение в канал `<svc>:<channel>` основным соединением; число получателей. Ошибка Redis — исключение
43
+ * вызывающему.
44
+ */
45
+ publish(channel, message) {
46
+ return this.redis.publish(`${this.prefix}${channel}`, message);
47
+ }
48
+ /**
49
+ * Подписывает обработчик на канал `<svc>:<channel>`; одна подписка Redis на канал. Разрешается после подтверждения
50
+ * `SUBSCRIBE` (одновременные вызовы ждут одного подтверждения); отказ `SUBSCRIBE` отклоняет все ожидающие вызовы, и
51
+ * следующий `subscribe` шлёт `SUBSCRIBE` заново. Возвращает функцию отписки: уход последнего обработчика —
52
+ * `UNSUBSCRIBE`. Ошибка обработчика — `error` + GlitchTip, остальные обработчики получают сообщение. Без
53
+ * `subscriber: true` — `Error`.
54
+ */
55
+ async subscribe(channel, handler) {
56
+ const subscriber = this.subscriber;
57
+ if (subscriber === undefined) {
58
+ throw new Error('KitRedisChannels.subscribe: подписки требуют KitRedisModule.forRoot({subscriber: true})');
59
+ }
60
+ const full = `${this.prefix}${channel}`;
61
+ const state = this.channels.get(full) ?? this.open(subscriber, channel, full);
62
+ state.handlers.add(handler);
63
+ try {
64
+ await state.ready;
65
+ }
66
+ catch (error) {
67
+ state.handlers.delete(handler);
68
+ throw error;
69
+ }
70
+ let active = true;
71
+ return async () => {
72
+ if (!active) {
73
+ return;
74
+ }
75
+ active = false;
76
+ await this.release(subscriber, channel, full, state, handler);
77
+ };
78
+ }
79
+ /** Новая запись канала и SUBSCRIBE; отказ снимает запись, если её ещё не заменили. */
80
+ open(subscriber, channel, full) {
81
+ const state = { handlers: new Set(), ready: Promise.resolve() };
82
+ state.ready = subscriber.subscribe(full).then(() => {
83
+ this.log.debug({ channel }, 'redis: подписка на канал');
84
+ }, (error) => {
85
+ if (this.channels.get(full) === state) {
86
+ this.channels.delete(full);
87
+ }
88
+ this.log.debug({ channel, err: sanitizeError(error) }, 'redis: подписка на канал не удалась');
89
+ throw error;
90
+ });
91
+ this.channels.set(full, state);
92
+ return state;
93
+ }
94
+ async release(subscriber, channel, full, state, handler) {
95
+ state.handlers.delete(handler);
96
+ if (state.handlers.size > 0 || this.channels.get(full) !== state) {
97
+ return;
98
+ }
99
+ this.channels.delete(full);
100
+ await subscriber.unsubscribe(full);
101
+ this.log.debug({ channel }, 'redis: отписка от канала');
102
+ }
103
+ dispatch(full, message) {
104
+ const state = this.channels.get(full);
105
+ if (state === undefined || !full.startsWith(this.prefix)) {
106
+ return;
107
+ }
108
+ const channel = full.slice(this.prefix.length);
109
+ for (const handler of state.handlers) {
110
+ void Promise.resolve()
111
+ .then(() => handler(message))
112
+ .catch((error) => {
113
+ this.log.error({ channel, err: sanitizeError(error) }, 'redis: ошибка обработчика сообщения канала');
114
+ captureError(error);
115
+ });
116
+ }
117
+ }
118
+ };
119
+ KitRedisChannels = __decorate([
120
+ Injectable(),
121
+ __param(0, Inject(KIT_REDIS)),
122
+ __param(1, Inject(KIT_REDIS_PREFIX)),
123
+ __param(2, Inject(KIT_REDIS_LOG)),
124
+ __param(3, Optional()),
125
+ __param(3, Inject(KIT_REDIS_SUBSCRIBER)),
126
+ __metadata("design:paramtypes", [Function, String, Object, Function])
127
+ ], KitRedisChannels);
128
+ export { KitRedisChannels };
@@ -0,0 +1,42 @@
1
+ import type { Redis } from 'ioredis';
2
+ /** Токен основного соединения Redis: `Redis` ioredis с `keyPrefix <svc>:` (§3.7). */
3
+ export declare const KIT_REDIS: unique symbol;
4
+ /** Срок команды основного соединения, мс (§3.7, AC-8.2). */
5
+ export declare const MAIN_COMMAND_TIMEOUT_MS = 500;
6
+ /** Срок команды подписочного соединения, мс (§3.7, AC-8.6). */
7
+ export declare const SUBSCRIBER_COMMAND_TIMEOUT_MS = 5000;
8
+ /** Роль соединения в логах. */
9
+ export type RedisConnectionRole = 'main' | 'subscriber';
10
+ /** Логгер фоновых операций /redis: pino экземпляра (`service`, `replica` в `base`) или адаптер логгера Nest. */
11
+ export interface RedisLog {
12
+ /** Уровень debug. */
13
+ debug(fields: object, message: string): void;
14
+ /** Уровень info. */
15
+ info(fields: object, message: string): void;
16
+ /** Уровень warn. */
17
+ warn(fields: object, message: string): void;
18
+ /** Уровень error. */
19
+ error(fields: object, message: string): void;
20
+ }
21
+ /** Адаптер логгера Nest (приложение без KIT_LOGGER); копия nestLog /messaging — импорт оттуда запрещён. */
22
+ export declare function redisNestLog(context: string): RedisLog;
23
+ /** Параметры соединения. */
24
+ export interface RedisConnectionParams {
25
+ /** REDIS_URL (содержит пароль — в лог не пишется). */
26
+ readonly url: string;
27
+ /** Роль соединения. */
28
+ readonly connection: RedisConnectionRole;
29
+ /** Имя сервиса — имя клиента в `CLIENT LIST`. */
30
+ readonly service: string;
31
+ /** Префикс ключей; только у основного соединения. */
32
+ readonly keyPrefix?: string;
33
+ /** Логгер. */
34
+ readonly log: RedisLog;
35
+ }
36
+ /**
37
+ * Создаёт ленивое соединение (подключается при первой команде). Первая ошибка соединения после `ready` — `warn`, повторы
38
+ * до следующего `ready` — `debug` (переподключения не заливают лог). URL, ключи и аргументы команд в лог не попадают.
39
+ */
40
+ export declare function createRedisConnection(params: RedisConnectionParams): Promise<Redis>;
41
+ /** Закрывает соединение (D11): не подключено — `disconnect()`, иначе `quit()`; ошибка `quit` — `disconnect()`. */
42
+ export declare function closeRedisConnection(redis: Redis, connection: RedisConnectionRole, log: RedisLog): Promise<void>;
@@ -0,0 +1,85 @@
1
+ // Соединение ioredis /redis (prd.md §3.7, AC-8.2, AC-8.6, NFR-3). Эталон — O shared/redis/redis.provider.ts: lazyConnect,
2
+ // maxRetriesPerRequest 1, commandTimeout 500 / 5000 для подписки, закрытие quit() → disconnect(), ошибки соединения без
3
+ // URL. По PRD: keyPrefix <svc>: у основного соединения, enableReadyCheck: false (INFO запрещён ACL -@dangerous),
4
+ // ioredis — динамический import() при создании (без top-level await, П15), логгер — pino экземпляра или адаптер Nest.
5
+ import { Logger } from '@nestjs/common';
6
+ import { sanitizeError } from '../observability/sanitize-error.js';
7
+ /** Токен основного соединения Redis: `Redis` ioredis с `keyPrefix <svc>:` (§3.7). */
8
+ export const KIT_REDIS = Symbol('KIT_REDIS');
9
+ /** Срок команды основного соединения, мс (§3.7, AC-8.2). */
10
+ export const MAIN_COMMAND_TIMEOUT_MS = 500;
11
+ /** Срок команды подписочного соединения, мс (§3.7, AC-8.6). */
12
+ export const SUBSCRIBER_COMMAND_TIMEOUT_MS = 5000;
13
+ /** Адаптер логгера Nest (приложение без KIT_LOGGER); копия nestLog /messaging — импорт оттуда запрещён. */
14
+ export function redisNestLog(context) {
15
+ const logger = new Logger(context);
16
+ return {
17
+ debug: (fields, message) => logger.debug(fields, message),
18
+ info: (fields, message) => logger.log(fields, message),
19
+ warn: (fields, message) => logger.warn(fields, message),
20
+ error: (fields, message) => logger.error(fields, message),
21
+ };
22
+ }
23
+ let ioredisPromise;
24
+ /**
25
+ * Класс `Redis` ioredis, загружается один раз. Именованный `Redis`, а не `default`: ioredis — CommonJS, и при NodeNext
26
+ * ESM-`default` типизируется как весь `module.exports`.
27
+ */
28
+ function loadIoredis() {
29
+ ioredisPromise ??= import('ioredis').then((module) => module.Redis);
30
+ return ioredisPromise;
31
+ }
32
+ /** Опции ioredis соединения (D9). */
33
+ function optionsOf(params) {
34
+ const main = params.connection === 'main';
35
+ return {
36
+ lazyConnect: true,
37
+ maxRetriesPerRequest: 1,
38
+ // Проверка готовности шлёт INFO (@dangerous, запрещён ACL §3.7); при отказе ioredis пишет console.warn.
39
+ enableReadyCheck: false,
40
+ commandTimeout: main ? MAIN_COMMAND_TIMEOUT_MS : SUBSCRIBER_COMMAND_TIMEOUT_MS,
41
+ connectionName: main ? params.service : `${params.service}-subscriber`,
42
+ ...(main && params.keyPrefix !== undefined ? { keyPrefix: params.keyPrefix } : {}),
43
+ };
44
+ }
45
+ /**
46
+ * Создаёт ленивое соединение (подключается при первой команде). Первая ошибка соединения после `ready` — `warn`, повторы
47
+ * до следующего `ready` — `debug` (переподключения не заливают лог). URL, ключи и аргументы команд в лог не попадают.
48
+ */
49
+ export async function createRedisConnection(params) {
50
+ const RedisClient = await loadIoredis();
51
+ const redis = new RedisClient(params.url, optionsOf(params));
52
+ const { connection, log } = params;
53
+ let failing = false;
54
+ redis.on('ready', () => {
55
+ failing = false;
56
+ log.debug({ connection }, 'redis: соединение готово');
57
+ });
58
+ redis.on('error', (error) => {
59
+ const fields = { connection, err: sanitizeError(error) };
60
+ if (failing) {
61
+ log.debug(fields, 'redis: ошибка соединения (повтор)');
62
+ return;
63
+ }
64
+ failing = true;
65
+ log.warn(fields, 'redis: ошибка соединения');
66
+ });
67
+ log.debug({ connection }, 'redis: соединение создано');
68
+ return redis;
69
+ }
70
+ /** Закрывает соединение (D11): не подключено — `disconnect()`, иначе `quit()`; ошибка `quit` — `disconnect()`. */
71
+ export async function closeRedisConnection(redis, connection, log) {
72
+ if (redis.status === 'wait' || redis.status === 'end') {
73
+ redis.disconnect();
74
+ log.debug({ connection }, 'redis: соединение закрыто');
75
+ return;
76
+ }
77
+ try {
78
+ await redis.quit();
79
+ }
80
+ catch (error) {
81
+ log.debug({ connection, err: error instanceof Error ? error.name : 'unknown' }, 'redis: quit не удался, disconnect');
82
+ redis.disconnect();
83
+ }
84
+ log.debug({ connection }, 'redis: соединение закрыто');
85
+ }
@@ -0,0 +1,7 @@
1
+ import './peer.js';
2
+ export { KIT_REDIS } from './client.js';
3
+ export { KitRedisChannels } from './channels.js';
4
+ export { KitRedisModule } from './kit-redis.module.js';
5
+ export type { KitRedisModuleOptions } from './kit-redis.module.js';
6
+ export { RateLimiter, throwRateLimited } from './rate-limiter.js';
7
+ export type { RateLimitOptions, RateLimitResult } from './rate-limiter.js';
@@ -0,0 +1,7 @@
1
+ // Подпуть @verdeect/kit/redis (prd.md §3.7, D1): KitRedisModule, KIT_REDIS, KitRedisChannels, RateLimiter,
2
+ // throwRateLimited. Первый импорт — проверка peer ioredis (AC-1.2).
3
+ import './peer.js';
4
+ export { KIT_REDIS } from './client.js';
5
+ export { KitRedisChannels } from './channels.js';
6
+ export { KitRedisModule } from './kit-redis.module.js';
7
+ export { RateLimiter, throwRateLimited } from './rate-limiter.js';
@@ -0,0 +1,18 @@
1
+ import { type DynamicModule } from '@nestjs/common';
2
+ /** Опции `KitRedisModule.forRoot`. */
3
+ export interface KitRedisModuleOptions {
4
+ /** Имя сервиса (`^[a-z][a-z0-9-]{1,31}$`): префикс `<svc>:` ключей и каналов, имя клиента Redis. */
5
+ readonly service: string;
6
+ /** Второе соединение для подписок (`KitRedisChannels.subscribe`); без него подписки недоступны. */
7
+ readonly subscriber?: true;
8
+ }
9
+ /**
10
+ * Redis сервиса (§3.7): основное соединение `KIT_REDIS` с `keyPrefix <svc>:` (срок команды 500 мс), `KitRedisChannels`,
11
+ * `RateLimiter`, проба `/health` `redis` (некритичная), закрытие на шаге 4. `subscriber: true` — второе соединение для
12
+ * подписок (срок 5 с); без него второго соединения нет. `keyPrefix` не действует на шаблоны `KEYS`/`SCAN` — префикс в
13
+ * шаблон ставит сервис. `REDIS_URL` — из `KitConfigModule` (`envRedis`).
14
+ */
15
+ export declare class KitRedisModule {
16
+ /** Модуль Redis сервиса; глобальный. */
17
+ static forRoot(options: KitRedisModuleOptions): DynamicModule;
18
+ }