@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,317 @@
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
+ // IdempotencyInterceptor и хранилище (prd.md §3.3а v1.14, AC-4.14, UC-7). Эталон — O idempotency.interceptor.ts +
14
+ // idempotency.store.ts (KEY_FORMAT, canonicalJson, отпечаток, begin с FOR UPDATE, брошенная in_progress через 5 мин,
15
+ // mismatch до in_progress, release при ошибке, один повтор complete). По PRD: область user/agent → (tenant, sub),
16
+ // service → client_id или (client_id, поле tenantFromBody); Location в ответе; без принципала — 500; коды — DomainError
17
+ // словаря /http; сравнение области — IS NOT DISTINCT FROM (tenant nullable).
18
+ import { createHash, randomUUID } from 'node:crypto';
19
+ import { Inject, Injectable, Logger, } from '@nestjs/common';
20
+ import { HTTP_CODE_METADATA } from '@nestjs/common/constants.js';
21
+ import { Reflector } from '@nestjs/core';
22
+ import { principalOf } from '@verdeect/auth/nest';
23
+ import { and, eq, sql } from 'drizzle-orm';
24
+ import { from, lastValueFrom } from 'rxjs';
25
+ import { Db } from '../database/db.js';
26
+ import { DomainError } from '../http/errors.js';
27
+ import { IDEMPOTENT_METADATA } from '../internal/metadata.js';
28
+ import { sanitizeError } from '../observability/sanitize-error.js';
29
+ import { captureError } from '../observability/sentry.js';
30
+ /** Токен таблицы идемпотентности сервиса (провайдер `KitIdempotencyModule`). */
31
+ export const KIT_IDEMPOTENCY_TABLE = Symbol('KIT_IDEMPOTENCY_TABLE');
32
+ /** Печатные ASCII, ≤ 200 знаков (эталон O). */
33
+ export const KEY_FORMAT = /^[\x21-\x7E]{1,200}$/;
34
+ /** Брошенная `in_progress` (запрос упал без release) переиспользуется через 5 минут (эталон O). */
35
+ const ABANDONED_AFTER_MINUTES = 5;
36
+ /** Срок хранения ответа. */
37
+ const EXPIRES_AFTER_HOURS = 24;
38
+ /** Заголовок ответа повтора. */
39
+ const REPLAYED_HEADER = 'Idempotent-Replayed';
40
+ /** JSON с рекурсивно отсортированными ключами: одинаковое тело — одинаковый отпечаток (эталон O). */
41
+ export function canonicalJson(value) {
42
+ if (Array.isArray(value)) {
43
+ return `[${value.map(canonicalJson).join(',')}]`;
44
+ }
45
+ if (typeof value === 'object' && value !== null) {
46
+ const entries = Object.entries(value)
47
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
48
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`);
49
+ return `{${entries.join(',')}}`;
50
+ }
51
+ return JSON.stringify(value ?? null);
52
+ }
53
+ /** SHA-256 hex метода, пути без query и канонического тела. */
54
+ export function fingerprintOf(method, path, body) {
55
+ const bare = path.split('?')[0] ?? '';
56
+ return createHash('sha256')
57
+ .update(`${method} ${bare}\n${canonicalJson(body ?? null)}`)
58
+ .digest('hex');
59
+ }
60
+ /** Значение поля тела по пути через точку. */
61
+ function fieldOf(body, path) {
62
+ let current = body;
63
+ for (const segment of path.split('.')) {
64
+ if (typeof current !== 'object' || current === null) {
65
+ return undefined;
66
+ }
67
+ current = current[segment];
68
+ }
69
+ return current;
70
+ }
71
+ /**
72
+ * Область ключа (§3.3а): пользователь и агент — тенант токена (поле тела игнорируется); служба — `client_id` и тенант
73
+ * из поля `tenantFromBody`, если это непустая строка, иначе `null`.
74
+ */
75
+ export function scopeOf(principal, body, options) {
76
+ if (principal.kind === 'user') {
77
+ return { tenant: principal.tenant, subject: `user:${principal.sub}` };
78
+ }
79
+ if (principal.kind === 'agent') {
80
+ return { tenant: principal.tenant, subject: `agent:${principal.sub}` };
81
+ }
82
+ const value = options.tenantFromBody === undefined ? undefined : fieldOf(body, options.tenantFromBody);
83
+ return {
84
+ tenant: typeof value === 'string' && value !== '' ? value : null,
85
+ subject: `service:${principal.clientId}`,
86
+ };
87
+ }
88
+ /** Ключ из заголовка: нет — `null`; не строка или не по формату — 422 `VALIDATION_FAILED`. */
89
+ function readKey(request) {
90
+ const header = request.headers['idempotency-key'];
91
+ if (header === undefined) {
92
+ return null;
93
+ }
94
+ if (typeof header !== 'string' || !KEY_FORMAT.test(header)) {
95
+ throw new DomainError('VALIDATION_FAILED', {
96
+ issues: [{ path: 'Idempotency-Key', code: 'invalid' }],
97
+ });
98
+ }
99
+ return header;
100
+ }
101
+ /**
102
+ * Интерсептор `@Idempotent` (§3.3а): регистрирует `KitIdempotencyModule` через `APP_INTERCEPTOR`. Повтор — сохранённый
103
+ * ответ и `Idempotent-Replayed: true`; другое тело — 422 `IDEMPOTENCY_KEY_REUSED`; запрос ещё идёт — 409
104
+ * `IDEMPOTENCY_IN_PROGRESS`. Сохраняются только успешные ответы.
105
+ */
106
+ let IdempotencyInterceptor = class IdempotencyInterceptor {
107
+ reflector;
108
+ db;
109
+ table;
110
+ logger = new Logger('IdempotencyInterceptor');
111
+ constructor(reflector, db, table) {
112
+ this.reflector = reflector;
113
+ this.db = db;
114
+ this.table = table;
115
+ }
116
+ /** Оборачивает обработчик маршрута с `@Idempotent`. */
117
+ intercept(context, next) {
118
+ const options = this.reflector.get(IDEMPOTENT_METADATA, context.getHandler());
119
+ if (options === undefined) {
120
+ return next.handle();
121
+ }
122
+ const request = context.switchToHttp().getRequest();
123
+ // Принципал — до ключа: без guard'а 500 при любом ключе и без него (PRD v1.14).
124
+ const principal = principalOf(request);
125
+ if (principal === null) {
126
+ throw new Error('@Idempotent: нет проверенного принципала — нужен VerdeectAuthGuard на маршруте');
127
+ }
128
+ const key = readKey(request);
129
+ if (key === null) {
130
+ if (options.required === true) {
131
+ throw new DomainError('IDEMPOTENCY_KEY_REQUIRED');
132
+ }
133
+ return next.handle();
134
+ }
135
+ return from(this.run(context, next, request, principal, options, key));
136
+ }
137
+ async run(context, next, request, principal, options, key) {
138
+ const scope = scopeOf(principal, request.body, options);
139
+ const ctx = {
140
+ tenant: scope.tenant,
141
+ traceId: String(request.id),
142
+ actor: {
143
+ ...(principal.clientId === null ? {} : { clientId: principal.clientId }),
144
+ channel: 'http',
145
+ },
146
+ };
147
+ const fields = {
148
+ subjectKind: principal.kind,
149
+ keyPrefix: key.slice(0, 8),
150
+ traceId: ctx.traceId,
151
+ };
152
+ const path = request.originalUrl ?? request.url;
153
+ const begin = await this.begin(ctx, scope, key, fingerprintOf(request.method, path, request.body));
154
+ this.logger.debug({ ...fields, state: begin.state }, 'идемпотентность: begin');
155
+ if (begin.state === 'mismatch') {
156
+ this.logger.warn(fields, 'идемпотентность: ключ с другим телом');
157
+ throw new DomainError('IDEMPOTENCY_KEY_REUSED');
158
+ }
159
+ if (begin.state === 'in_progress') {
160
+ this.logger.warn(fields, 'идемпотентность: запрос с ключом ещё выполняется');
161
+ throw new DomainError('IDEMPOTENCY_IN_PROGRESS');
162
+ }
163
+ const reply = context.switchToHttp().getResponse();
164
+ if (begin.state === 'replay') {
165
+ void reply.status(begin.status).header(REPLAYED_HEADER, 'true');
166
+ if (begin.location !== null) {
167
+ void reply.header('Location', begin.location);
168
+ }
169
+ return begin.body;
170
+ }
171
+ let body;
172
+ try {
173
+ body = await lastValueFrom(next.handle(), { defaultValue: undefined });
174
+ }
175
+ catch (error) {
176
+ await this.release(ctx, scope, key, begin.attempt, fields);
177
+ throw error;
178
+ }
179
+ const status = this.reflector.get(HTTP_CODE_METADATA, context.getHandler()) ??
180
+ (request.method === 'POST' ? 201 : 200);
181
+ const header = reply.getHeader('location');
182
+ const location = typeof header === 'string' ? header : null;
183
+ await this.completeWithRetry(ctx, scope, key, begin.attempt, { status, body: body ?? null, location }, fields);
184
+ return body;
185
+ }
186
+ where(scope, key) {
187
+ const t = this.table;
188
+ return and(sql `${t.tenant} IS NOT DISTINCT FROM ${scope.tenant}`, eq(t.subject, scope.subject), eq(t.key, key));
189
+ }
190
+ begin(ctx, scope, key, fingerprint) {
191
+ const t = this.table;
192
+ // Метка попытки: ответ пишет только та попытка, что захватила строку (KT-22 п. 3).
193
+ const attempt = randomUUID();
194
+ return this.db.transaction(ctx, async (tx) => {
195
+ // ON CONFLICT DO NOTHING без ON CONSTRAINT работает, пока idempotency_key_scope_key (tenant, subject, key) —
196
+ // единственное уникальное ограничение таблицы; новое уникальное ограничение — переписать на
197
+ // ON CONFLICT ON CONSTRAINT idempotency_key_scope_key (KT-21 п. 3).
198
+ const inserted = await tx
199
+ .insert(t)
200
+ .values({ tenant: scope.tenant, subject: scope.subject, key, fingerprint, attempt })
201
+ .onConflictDoNothing()
202
+ .returning({ key: t.key });
203
+ if (inserted.length > 0) {
204
+ return { state: 'new', attempt };
205
+ }
206
+ const [row] = await tx
207
+ .select({
208
+ fingerprint: t.fingerprint,
209
+ state: t.state,
210
+ status: t.responseStatus,
211
+ body: t.responseBody,
212
+ location: t.responseLocation,
213
+ expired: sql `${t.createdAt} < now() - make_interval(hours => ${EXPIRES_AFTER_HOURS})`,
214
+ abandoned: sql `${t.state} = 'in_progress' AND ${t.createdAt} < now() - make_interval(mins => ${ABANDONED_AFTER_MINUTES})`,
215
+ })
216
+ .from(t)
217
+ .where(this.where(scope, key))
218
+ .for('update');
219
+ if (row === undefined) {
220
+ throw new Error('idempotency: строка области не найдена после конфликта');
221
+ }
222
+ if (row.expired || row.abandoned) {
223
+ if (row.abandoned && !row.expired) {
224
+ this.logger.warn({ keyPrefix: key.slice(0, 8), traceId: ctx.traceId }, 'идемпотентность: брошенная in_progress переиспользована');
225
+ }
226
+ await tx
227
+ .update(t)
228
+ .set({
229
+ fingerprint,
230
+ attempt,
231
+ state: 'in_progress',
232
+ responseStatus: null,
233
+ responseBody: null,
234
+ responseLocation: null,
235
+ createdAt: sql `now()`,
236
+ })
237
+ .where(this.where(scope, key));
238
+ return { state: 'new', attempt };
239
+ }
240
+ if (row.fingerprint !== fingerprint) {
241
+ return { state: 'mismatch' };
242
+ }
243
+ if (row.state === 'in_progress') {
244
+ return { state: 'in_progress' };
245
+ }
246
+ return {
247
+ state: 'replay',
248
+ status: row.status ?? 200,
249
+ body: row.body,
250
+ location: row.location,
251
+ };
252
+ });
253
+ }
254
+ /**
255
+ * Обработчик упал: строка своей попытки удаляется — повтор выполнит запрос заново; перехваченную строку другой
256
+ * попытки не трогает; сбой release не подменяет ответ.
257
+ */
258
+ async release(ctx, scope, key, attempt, fields) {
259
+ try {
260
+ await this.db.transaction(ctx, (tx) => tx
261
+ .delete(this.table)
262
+ .where(and(this.where(scope, key), eq(this.table.state, 'in_progress'), eq(this.table.attempt, attempt))));
263
+ }
264
+ catch (error) {
265
+ this.logger.error({ ...fields, err: sanitizeError(error) }, 'идемпотентность: release не удался');
266
+ captureError(error);
267
+ }
268
+ }
269
+ /**
270
+ * Один повтор `complete` сразу после сбоя (эталон O). После двух сбоев ответ клиенту не меняется: строка остаётся
271
+ * `in_progress` и через 5 минут переиспользуется. Ответ пишется только своей попытки: строку перехватил повторный
272
+ * запрос (обработчик дольше 5 минут) — ответ не сохраняется, `warn` без ключа и тела (KT-22 п. 3).
273
+ */
274
+ async completeWithRetry(ctx, scope, key, attempt, response, fields) {
275
+ const complete = () => this.db.transaction(ctx, (tx) => tx
276
+ .update(this.table)
277
+ .set({
278
+ state: 'done',
279
+ responseStatus: response.status,
280
+ responseBody: response.body,
281
+ responseLocation: response.location,
282
+ })
283
+ .where(and(this.where(scope, key), eq(this.table.attempt, attempt))));
284
+ // Перехват строки другой попыткой — warn без ключа (даже без префикса) и тела (KT-22 п. 3).
285
+ const taken = (rowCount) => {
286
+ if ((rowCount ?? 0) > 0) {
287
+ return false;
288
+ }
289
+ this.logger.warn({ subjectKind: fields.subjectKind, traceId: fields.traceId }, 'идемпотентность: строку перехватила другая попытка — ответ не сохранён');
290
+ return true;
291
+ };
292
+ try {
293
+ const result = await complete();
294
+ if (!taken(result.rowCount)) {
295
+ this.logger.debug({ ...fields, state: 'done' }, 'идемпотентность: complete');
296
+ }
297
+ return;
298
+ }
299
+ catch (error) {
300
+ this.logger.warn({ ...fields, err: sanitizeError(error) }, 'идемпотентность: complete не удался, повтор');
301
+ }
302
+ try {
303
+ taken((await complete()).rowCount);
304
+ }
305
+ catch (error) {
306
+ this.logger.error({ ...fields, err: sanitizeError(error) }, 'идемпотентность: complete не удался');
307
+ captureError(error);
308
+ }
309
+ }
310
+ };
311
+ IdempotencyInterceptor = __decorate([
312
+ Injectable(),
313
+ __param(2, Inject(KIT_IDEMPOTENCY_TABLE)),
314
+ __metadata("design:paramtypes", [Reflector,
315
+ Db, Object])
316
+ ], IdempotencyInterceptor);
317
+ export { IdempotencyInterceptor };
@@ -0,0 +1,7 @@
1
+ import { type KitIdempotentOptions } from '../internal/metadata.js';
2
+ /**
3
+ * Маршрут принимает `Idempotency-Key` (§3.3а): повтор с тем же ключом и телом — сохранённый ответ и
4
+ * `Idempotent-Replayed: true`. Только на маршрутах с `VerdeectAuthGuard`: без проверенного принципала — 500.
5
+ * @throws {TypeError} `tenantFromBody` — пустая строка или путь с пустым сегментом.
6
+ */
7
+ export declare function Idempotent(options?: KitIdempotentOptions): MethodDecorator;
@@ -0,0 +1,16 @@
1
+ // @Idempotent (prd.md §3.3а, AC-4.14). Эталон — O IDEMPOTENT/SetMetadata; ключ метаданных — internal/metadata.ts (его
2
+ // читает OpenAPI /http без импорта idempotency). Без pg.
3
+ import { SetMetadata } from '@nestjs/common';
4
+ import { IDEMPOTENT_METADATA } from '../internal/metadata.js';
5
+ /**
6
+ * Маршрут принимает `Idempotency-Key` (§3.3а): повтор с тем же ключом и телом — сохранённый ответ и
7
+ * `Idempotent-Replayed: true`. Только на маршрутах с `VerdeectAuthGuard`: без проверенного принципала — 500.
8
+ * @throws {TypeError} `tenantFromBody` — пустая строка или путь с пустым сегментом.
9
+ */
10
+ export function Idempotent(options = {}) {
11
+ const path = options.tenantFromBody;
12
+ if (path !== undefined && path.split('.').some((segment) => segment === '')) {
13
+ throw new TypeError('@Idempotent: tenantFromBody — путь поля через точку без пустых сегментов');
14
+ }
15
+ return SetMetadata(IDEMPOTENT_METADATA, options);
16
+ }
@@ -0,0 +1,7 @@
1
+ import './peer.js';
2
+ export { Idempotent } from './idempotent.decorator.js';
3
+ export { KitIdempotencyModule } from './kit-idempotency.module.js';
4
+ export type { KitIdempotencyModuleOptions } from './kit-idempotency.module.js';
5
+ export { createIdempotencyTable, idempotencyPurgeSql } from './table.js';
6
+ export type { KitIdempotencyTable } from './table.js';
7
+ export type { KitIdempotentOptions } from '../internal/metadata.js';
@@ -0,0 +1,6 @@
1
+ // Точка входа @verdeect/kit/idempotency (prd.md §3.3а, E4): явный список экспортов. Первый импорт — ./peer.js: без pg
2
+ // ошибка AC-1.2 называет /idempotency (requirePeer синхронно, без top-level await — П15).
3
+ import './peer.js';
4
+ export { Idempotent } from './idempotent.decorator.js';
5
+ export { KitIdempotencyModule } from './kit-idempotency.module.js';
6
+ export { createIdempotencyTable, idempotencyPurgeSql } from './table.js';
@@ -0,0 +1,47 @@
1
+ import { type BeforeApplicationShutdown, type DynamicModule, type OnApplicationBootstrap, type OnModuleInit } from '@nestjs/common';
2
+ import type { Logger as PinoLogger } from 'pino';
3
+ import { Db } from '../database/db.js';
4
+ import type { KitMetrics } from '../observability/metrics.js';
5
+ import { type KitIdempotencyTable } from './table.js';
6
+ /** Опции `KitIdempotencyModule.forRoot`. */
7
+ export interface KitIdempotencyModuleOptions {
8
+ /** Таблица `createIdempotencyTable(pgSchema(…))` схемы сервиса. */
9
+ readonly table: KitIdempotencyTable;
10
+ }
11
+ /** Очистка записей старше 24 ч одной репликой (UC-9); внутренний класс модуля. */
12
+ export declare class IdempotencyCleanup implements OnModuleInit, OnApplicationBootstrap, BeforeApplicationShutdown {
13
+ private readonly db;
14
+ private readonly schema;
15
+ private readonly metrics?;
16
+ private readonly log;
17
+ private stopped;
18
+ private interval;
19
+ private current;
20
+ private running;
21
+ constructor(db: Db, schema: string, metrics?: KitMetrics | undefined, logger?: PinoLogger);
22
+ /** До прослушивания порта: функция очистки есть, иначе отказ запуска. */
23
+ onModuleInit(): Promise<void>;
24
+ /** Первый проход сразу после старта, затем раз в час. */
25
+ onApplicationBootstrap(): void;
26
+ /** Текущий проход (включая ещё не начатый первый); нет — разрешённый промис. */
27
+ idle(): Promise<void>;
28
+ /** Проход очистки; идущий проход не накладывается (пропуск). Ошибка не пробрасывается. */
29
+ run(limits?: {
30
+ readonly batchSize: number;
31
+ readonly maxBatches: number;
32
+ }): Promise<void>;
33
+ /** Шаг 3 остановки: пачка дорабатывает, новая не начинается. */
34
+ beforeApplicationShutdown(): Promise<void>;
35
+ private pass;
36
+ }
37
+ /**
38
+ * Идемпотентность маршрутов `@Idempotent` (§3.3а): интерсептор на `Db` сервиса, проверка функции
39
+ * `idempotency_purge` при старте, очистка записей старше 24 ч раз в час одной репликой. Требует `KitDatabaseModule`.
40
+ */
41
+ export declare class KitIdempotencyModule {
42
+ /**
43
+ * Модуль с таблицей сервиса.
44
+ * @throws {TypeError} таблица без схемы или имя схемы вне формата имени сервиса.
45
+ */
46
+ static forRoot(options: KitIdempotencyModuleOptions): DynamicModule;
47
+ }
@@ -0,0 +1,173 @@
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
+ var KitIdempotencyModule_1;
14
+ // KitIdempotencyModule.forRoot({table}) (prd.md §3.3а v1.14, AC-4.14, AC-9.5, UC-9). Эталон — O idempotency.module.ts +
15
+ // idempotency-cleanup.job.ts (раз в час, пачка 1000, до 100 пачек, остановка); по PRD: очистка через runExclusive
16
+ // функцией idempotency_purge пачками; нет функции — отказ запуска; расписание — таймер (без @nestjs/schedule); пулы и
17
+ // шаги остановки — хуки Nest (KitLifecycle не используется).
18
+ import { Inject, Injectable, Logger, Module, Optional, } from '@nestjs/common';
19
+ import { APP_INTERCEPTOR } from '@nestjs/core';
20
+ import { sql } from 'drizzle-orm';
21
+ import { getTableConfig } from 'drizzle-orm/pg-core';
22
+ import { deleteInBatches } from '../database/batches.js';
23
+ import { Db } from '../database/db.js';
24
+ import { runExclusive } from '../database/run-exclusive.js';
25
+ import { resultOf } from '../internal/cleanup-result.js';
26
+ import { assertServiceName } from '../internal/service-name.js';
27
+ import { quoteIdentifier } from '../internal/sql-identifier.js';
28
+ import { KIT_LOGGER, KIT_METRICS } from '../internal/tokens.js';
29
+ import { sanitizeError } from '../observability/sanitize-error.js';
30
+ import { captureError } from '../observability/sentry.js';
31
+ import { IdempotencyInterceptor, KIT_IDEMPOTENCY_TABLE } from './idempotency.interceptor.js';
32
+ import { IDEMPOTENCY_TABLE, PURGE_FUNCTION } from './table.js';
33
+ /** Интервал очистки (эталон O — раз в час). */
34
+ const CLEANUP_INTERVAL_MS = 3_600_000;
35
+ /** Пределы прохода по умолчанию (эталон O). */
36
+ const CLEANUP_LIMITS = { batchSize: 1000, maxBatches: 100 };
37
+ /** Схема таблицы (внутренний токен). */
38
+ const KIT_IDEMPOTENCY_SCHEMA = Symbol('KIT_IDEMPOTENCY_SCHEMA');
39
+ function nestLog() {
40
+ const logger = new Logger('IdempotencyCleanup');
41
+ return {
42
+ debug: (fields, message) => logger.debug(fields, message),
43
+ info: (fields, message) => logger.log(fields, message),
44
+ error: (fields, message) => logger.error(fields, message),
45
+ };
46
+ }
47
+ /** Очистка записей старше 24 ч одной репликой (UC-9); внутренний класс модуля. */
48
+ let IdempotencyCleanup = class IdempotencyCleanup {
49
+ db;
50
+ schema;
51
+ metrics;
52
+ log;
53
+ stopped = false;
54
+ interval;
55
+ current;
56
+ running = false;
57
+ constructor(db, schema, metrics, logger) {
58
+ this.db = db;
59
+ this.schema = schema;
60
+ this.metrics = metrics;
61
+ this.log = logger ?? nestLog();
62
+ }
63
+ /** До прослушивания порта: функция очистки есть, иначе отказ запуска. */
64
+ async onModuleInit() {
65
+ const signature = `${quoteIdentifier(this.schema)}.${quoteIdentifier(PURGE_FUNCTION)}(integer)`;
66
+ const { rows } = await this.db.readGlobal((reader) => reader.execute(sql `select to_regprocedure(${signature}) is not null as ok`));
67
+ if (rows[0]?.ok !== true) {
68
+ throw new Error(`KitIdempotencyModule: нет функции ${this.schema}.${PURGE_FUNCTION} — добавьте idempotencyPurgeSql(${this.schema}) в SQL-миграцию сервиса`);
69
+ }
70
+ }
71
+ /** Первый проход сразу после старта, затем раз в час. */
72
+ onApplicationBootstrap() {
73
+ this.current = new Promise((resolve) => {
74
+ setTimeout(resolve, 0).unref();
75
+ }).then(() => this.run());
76
+ this.interval = setInterval(() => void this.run(), CLEANUP_INTERVAL_MS);
77
+ this.interval.unref();
78
+ }
79
+ /** Текущий проход (включая ещё не начатый первый); нет — разрешённый промис. */
80
+ idle() {
81
+ return this.current ?? Promise.resolve();
82
+ }
83
+ /** Проход очистки; идущий проход не накладывается (пропуск). Ошибка не пробрасывается. */
84
+ run(limits = CLEANUP_LIMITS) {
85
+ if (this.stopped) {
86
+ return Promise.resolve();
87
+ }
88
+ if (this.running && this.current !== undefined) {
89
+ return this.current;
90
+ }
91
+ this.running = true;
92
+ const pass = this.pass(limits).finally(() => {
93
+ this.running = false;
94
+ });
95
+ this.current = pass;
96
+ return pass;
97
+ }
98
+ /** Шаг 3 остановки: пачка дорабатывает, новая не начинается. */
99
+ async beforeApplicationShutdown() {
100
+ this.stopped = true;
101
+ clearInterval(this.interval);
102
+ await this.current;
103
+ }
104
+ async pass(limits) {
105
+ const table = IDEMPOTENCY_TABLE;
106
+ const fn = sql.raw(`${quoteIdentifier(this.schema)}.${quoteIdentifier(PURGE_FUNCTION)}`);
107
+ try {
108
+ const outcome = await runExclusive(this.db, `verdeect-kit:idempotency-cleanup:${this.schema}`,
109
+ // Пачки — на соединении блокировки: очистка не берёт второе соединение пула (KT-22 п. 4).
110
+ (connection) => deleteInBatches({
111
+ db: this.db,
112
+ sql: (batch) => sql `select ${fn}(${batch}::int) as deleted`,
113
+ ...limits,
114
+ shouldContinue: () => !this.stopped,
115
+ connection,
116
+ }));
117
+ if (!outcome.ran) {
118
+ this.log.debug({ table }, 'очистка идемпотентности: другая реплика');
119
+ return;
120
+ }
121
+ const { deleted, batches, complete } = outcome.result;
122
+ const result = resultOf(complete, batches, limits.maxBatches);
123
+ this.metrics?.cleanupRuns.inc({ table, result });
124
+ this.log.info({ table, deleted, batches, result }, 'очистка идемпотентности: проход');
125
+ }
126
+ catch (error) {
127
+ this.metrics?.cleanupRuns.inc({ table, result: 'error' });
128
+ this.log.error({ table, err: sanitizeError(error) }, 'очистка идемпотентности: ошибка');
129
+ captureError(error);
130
+ }
131
+ }
132
+ };
133
+ IdempotencyCleanup = __decorate([
134
+ Injectable(),
135
+ __param(1, Inject(KIT_IDEMPOTENCY_SCHEMA)),
136
+ __param(2, Optional()),
137
+ __param(2, Inject(KIT_METRICS)),
138
+ __param(3, Optional()),
139
+ __param(3, Inject(KIT_LOGGER)),
140
+ __metadata("design:paramtypes", [Db, String, Object, Object])
141
+ ], IdempotencyCleanup);
142
+ export { IdempotencyCleanup };
143
+ /**
144
+ * Идемпотентность маршрутов `@Idempotent` (§3.3а): интерсептор на `Db` сервиса, проверка функции
145
+ * `idempotency_purge` при старте, очистка записей старше 24 ч раз в час одной репликой. Требует `KitDatabaseModule`.
146
+ */
147
+ let KitIdempotencyModule = KitIdempotencyModule_1 = class KitIdempotencyModule {
148
+ /**
149
+ * Модуль с таблицей сервиса.
150
+ * @throws {TypeError} таблица без схемы или имя схемы вне формата имени сервиса.
151
+ */
152
+ static forRoot(options) {
153
+ const schema = getTableConfig(options.table).schema;
154
+ if (schema === undefined) {
155
+ throw new TypeError('KitIdempotencyModule: таблица без схемы — createIdempotencyTable(pgSchema(…))');
156
+ }
157
+ assertServiceName(schema, 'KitIdempotencyModule');
158
+ return {
159
+ module: KitIdempotencyModule_1,
160
+ global: true,
161
+ providers: [
162
+ { provide: KIT_IDEMPOTENCY_TABLE, useValue: options.table },
163
+ { provide: KIT_IDEMPOTENCY_SCHEMA, useValue: schema },
164
+ { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
165
+ IdempotencyCleanup,
166
+ ],
167
+ };
168
+ }
169
+ };
170
+ KitIdempotencyModule = KitIdempotencyModule_1 = __decorate([
171
+ Module({})
172
+ ], KitIdempotencyModule);
173
+ export { KitIdempotencyModule };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // Первым импортом index.ts: без pg — текст AC-1.2 с /idempotency, раньше загрузки /database (prd.md §3.3а, NFR-3).
2
+ // Проверка синхронная, без top-level await: схему сервиса грузит drizzle-kit через require (ARCHITECTURE.md, П15).
3
+ import { requirePeer } from '../internal/peer.js';
4
+ requirePeer('pg', 'idempotency');