@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.
- package/CHANGELOG.md +132 -0
- package/README.md +804 -2
- package/alerts/verdeect-kit.rules.yml +133 -0
- package/dist/cli/commands/crypto.d.ts +9 -0
- package/dist/cli/commands/crypto.js +87 -0
- package/dist/cli/commands/db.d.ts +11 -0
- package/dist/cli/commands/db.js +151 -0
- package/dist/cli/commands/openapi.d.ts +9 -0
- package/dist/cli/commands/openapi.js +144 -0
- package/dist/cli/commands/rabbitmq.d.ts +11 -0
- package/dist/cli/commands/rabbitmq.js +124 -0
- package/dist/cli/commands/redis.d.ts +11 -0
- package/dist/cli/commands/redis.js +119 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +31 -0
- package/dist/config/cli-schemas.d.ts +29 -0
- package/dist/config/cli-schemas.js +15 -0
- package/dist/config/define-env.d.ts +21 -0
- package/dist/config/define-env.js +43 -0
- package/dist/config/fragments.d.ts +105 -0
- package/dist/config/fragments.js +86 -0
- package/dist/config/helpers.d.ts +26 -0
- package/dist/config/helpers.js +62 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +7 -0
- package/dist/config/kit-config.module.d.ts +20 -0
- package/dist/config/kit-config.module.js +23 -0
- package/dist/config/parse-env.d.ts +14 -0
- package/dist/config/parse-env.js +28 -0
- package/dist/crypto/cipher.d.ts +61 -0
- package/dist/crypto/cipher.js +136 -0
- package/dist/crypto/index.d.ts +2 -0
- package/dist/crypto/index.js +2 -0
- package/dist/database/batches.d.ts +42 -0
- package/dist/database/batches.js +49 -0
- package/dist/database/db.d.ts +93 -0
- package/dist/database/db.js +234 -0
- package/dist/database/if-newer.d.ts +9 -0
- package/dist/database/if-newer.js +15 -0
- package/dist/database/index.d.ts +17 -0
- package/dist/database/index.js +13 -0
- package/dist/database/kit-database.module.d.ts +47 -0
- package/dist/database/kit-database.module.js +181 -0
- package/dist/database/migrations.d.ts +42 -0
- package/dist/database/migrations.js +142 -0
- package/dist/database/pg.d.ts +20 -0
- package/dist/database/pg.js +19 -0
- package/dist/database/prepare-database.d.ts +39 -0
- package/dist/database/prepare-database.js +75 -0
- package/dist/database/run-exclusive.d.ts +19 -0
- package/dist/database/run-exclusive.js +62 -0
- package/dist/database/tenant.d.ts +34 -0
- package/dist/database/tenant.js +28 -0
- package/dist/http/api/api.decorator.d.ts +18 -0
- package/dist/http/api/api.decorator.js +9 -0
- package/dist/http/api/openapi-document.d.ts +40 -0
- package/dist/http/api/openapi-document.js +452 -0
- package/dist/http/bootstrap-service.d.ts +33 -0
- package/dist/http/bootstrap-service.js +107 -0
- package/dist/http/create-http-app.d.ts +38 -0
- package/dist/http/create-http-app.js +137 -0
- package/dist/http/errors.d.ts +118 -0
- package/dist/http/errors.js +94 -0
- package/dist/http/index.d.ts +12 -0
- package/dist/http/index.js +8 -0
- package/dist/http/kit-exception.filter.d.ts +18 -0
- package/dist/http/kit-exception.filter.js +151 -0
- package/dist/http/kit-http-core.module.d.ts +19 -0
- package/dist/http/kit-http-core.module.js +122 -0
- package/dist/http/pagination.d.ts +30 -0
- package/dist/http/pagination.js +73 -0
- package/dist/http/zod-validation.pipe.d.ts +15 -0
- package/dist/http/zod-validation.pipe.js +47 -0
- package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
- package/dist/idempotency/idempotency.interceptor.js +317 -0
- package/dist/idempotency/idempotent.decorator.d.ts +7 -0
- package/dist/idempotency/idempotent.decorator.js +16 -0
- package/dist/idempotency/index.d.ts +7 -0
- package/dist/idempotency/index.js +6 -0
- package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
- package/dist/idempotency/kit-idempotency.module.js +173 -0
- package/dist/idempotency/peer.d.ts +1 -0
- package/dist/idempotency/peer.js +4 -0
- package/dist/idempotency/table.d.ts +203 -0
- package/dist/idempotency/table.js +86 -0
- package/dist/internal/cleanup-result.d.ts +7 -0
- package/dist/internal/cleanup-result.js +12 -0
- package/dist/internal/ids.d.ts +13 -0
- package/dist/internal/ids.js +41 -0
- package/dist/internal/metadata.d.ts +12 -0
- package/dist/internal/metadata.js +3 -0
- package/dist/internal/outbox-policy.d.ts +8 -0
- package/dist/internal/outbox-policy.js +10 -0
- package/dist/internal/peer.d.ts +16 -0
- package/dist/internal/peer.js +56 -0
- package/dist/internal/pg-errors.d.ts +21 -0
- package/dist/internal/pg-errors.js +115 -0
- package/dist/internal/preparsed-env.d.ts +4 -0
- package/dist/internal/preparsed-env.js +14 -0
- package/dist/internal/rabbitmq-definitions.d.ts +85 -0
- package/dist/internal/rabbitmq-definitions.js +73 -0
- package/dist/internal/redis-acl.d.ts +21 -0
- package/dist/internal/redis-acl.js +30 -0
- package/dist/internal/service-name.d.ts +4 -0
- package/dist/internal/service-name.js +10 -0
- package/dist/internal/sql-identifier.d.ts +5 -0
- package/dist/internal/sql-identifier.js +12 -0
- package/dist/internal/tokens.d.ts +10 -0
- package/dist/internal/tokens.js +12 -0
- package/dist/internal/tx-context.d.ts +5 -0
- package/dist/internal/tx-context.js +9 -0
- package/dist/messaging/cleanup.d.ts +49 -0
- package/dist/messaging/cleanup.js +129 -0
- package/dist/messaging/config.d.ts +43 -0
- package/dist/messaging/config.js +8 -0
- package/dist/messaging/define-event.d.ts +29 -0
- package/dist/messaging/define-event.js +38 -0
- package/dist/messaging/envelope.d.ts +66 -0
- package/dist/messaging/envelope.js +49 -0
- package/dist/messaging/errors.d.ts +45 -0
- package/dist/messaging/errors.js +63 -0
- package/dist/messaging/inbox.d.ts +4 -0
- package/dist/messaging/inbox.js +9 -0
- package/dist/messaging/index.d.ts +13 -0
- package/dist/messaging/index.js +10 -0
- package/dist/messaging/kit-messaging.module.d.ts +39 -0
- package/dist/messaging/kit-messaging.module.js +309 -0
- package/dist/messaging/outbox-relay.d.ts +67 -0
- package/dist/messaging/outbox-relay.js +260 -0
- package/dist/messaging/outbox-store.d.ts +75 -0
- package/dist/messaging/outbox-store.js +126 -0
- package/dist/messaging/peer.d.ts +1 -0
- package/dist/messaging/peer.js +5 -0
- package/dist/messaging/rabbit-connection.d.ts +144 -0
- package/dist/messaging/rabbit-connection.js +472 -0
- package/dist/messaging/redelivery.d.ts +6 -0
- package/dist/messaging/redelivery.js +17 -0
- package/dist/messaging/subscriber.d.ts +102 -0
- package/dist/messaging/subscriber.js +412 -0
- package/dist/messaging/tables.d.ts +315 -0
- package/dist/messaging/tables.js +72 -0
- package/dist/messaging/topology.d.ts +56 -0
- package/dist/messaging/topology.js +56 -0
- package/dist/observability/fail-startup.d.ts +14 -0
- package/dist/observability/fail-startup.js +51 -0
- package/dist/observability/health-registry.d.ts +34 -0
- package/dist/observability/health-registry.js +81 -0
- package/dist/observability/index.d.ts +16 -0
- package/dist/observability/index.js +10 -0
- package/dist/observability/lifecycle.d.ts +52 -0
- package/dist/observability/lifecycle.js +151 -0
- package/dist/observability/logger-options.d.ts +54 -0
- package/dist/observability/logger-options.js +91 -0
- package/dist/observability/metrics-server.d.ts +33 -0
- package/dist/observability/metrics-server.js +108 -0
- package/dist/observability/metrics.d.ts +30 -0
- package/dist/observability/metrics.js +53 -0
- package/dist/observability/redaction.d.ts +23 -0
- package/dist/observability/redaction.js +65 -0
- package/dist/observability/sanitize-error.d.ts +47 -0
- package/dist/observability/sanitize-error.js +194 -0
- package/dist/observability/sentry.d.ts +64 -0
- package/dist/observability/sentry.js +233 -0
- package/dist/redis/channels.d.ts +34 -0
- package/dist/redis/channels.js +128 -0
- package/dist/redis/client.d.ts +42 -0
- package/dist/redis/client.js +85 -0
- package/dist/redis/index.d.ts +7 -0
- package/dist/redis/index.js +7 -0
- package/dist/redis/kit-redis.module.d.ts +18 -0
- package/dist/redis/kit-redis.module.js +131 -0
- package/dist/redis/peer.d.ts +1 -0
- package/dist/redis/peer.js +4 -0
- package/dist/redis/rate-limiter.d.ts +49 -0
- package/dist/redis/rate-limiter.js +139 -0
- package/dist/testing/count-queries.d.ts +30 -0
- package/dist/testing/count-queries.js +58 -0
- package/dist/testing/create-test-app.d.ts +26 -0
- package/dist/testing/create-test-app.js +32 -0
- package/dist/testing/expect-matches-api.d.ts +8 -0
- package/dist/testing/expect-matches-api.js +49 -0
- package/dist/testing/expect-problem.d.ts +24 -0
- package/dist/testing/expect-problem.js +23 -0
- package/dist/testing/images.d.ts +10 -0
- package/dist/testing/images.js +14 -0
- package/dist/testing/index.d.ts +23 -0
- package/dist/testing/index.js +17 -0
- package/dist/testing/launch-command.d.ts +52 -0
- package/dist/testing/launch-command.js +510 -0
- package/dist/testing/outbox-coverage.d.ts +26 -0
- package/dist/testing/outbox-coverage.js +98 -0
- package/dist/testing/rabbitmq-admin.d.ts +81 -0
- package/dist/testing/rabbitmq-admin.js +146 -0
- package/dist/testing/redis-worker.d.ts +10 -0
- package/dist/testing/redis-worker.js +15 -0
- package/dist/testing/repo-conventions.d.ts +10 -0
- package/dist/testing/repo-conventions.js +524 -0
- package/dist/testing/repo-files.d.ts +113 -0
- package/dist/testing/repo-files.js +401 -0
- package/dist/testing/scratch-database.d.ts +19 -0
- package/dist/testing/scratch-database.js +75 -0
- package/dist/testing/start-test-stack.d.ts +85 -0
- package/dist/testing/start-test-stack.js +342 -0
- package/dist/testing/tcp-proxy.d.ts +40 -0
- package/dist/testing/tcp-proxy.js +164 -0
- package/dist/testing/tenant-isolation.d.ts +31 -0
- package/dist/testing/tenant-isolation.js +205 -0
- package/dist/testing/test-events.d.ts +37 -0
- package/dist/testing/test-events.js +150 -0
- package/dist/testing/timeout-scale.d.ts +4 -0
- package/dist/testing/timeout-scale.js +17 -0
- package/dist/testing/timing-guard.d.ts +22 -0
- package/dist/testing/timing-guard.js +54 -0
- package/dist/testing/worker-scope.d.ts +31 -0
- package/dist/testing/worker-scope.js +68 -0
- package/package.json +177 -4
package/README.md
CHANGED
|
@@ -1,3 +1,805 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @verdeect/kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Общий инфраструктурный пакет NestJS-сервисов Вердикта 2.0: схема окружения, тестовый контур, наблюдаемость, HTTP, БД с RLS, идемпотентность, сообщения, шифр и Redis. Один пакет с подпутями и одной версией заменяет копии `src/shared/**` в сервисах. Требования — [requirements/0000-kit/prd.md](requirements/0000-kit/prd.md).
|
|
4
|
+
|
|
5
|
+
## Совместимость
|
|
6
|
+
|
|
7
|
+
| `@verdeect/kit` | `@verdeect/events` | `@verdeect/auth` | `@verdeect/platform-client` |
|
|
8
|
+
| ----------------------- | ------------------ | ---------------- | --------------------------- |
|
|
9
|
+
| `1.0.0-rc.N` (после E8) | `1.0.0-rc.N` | `^1.0.1` | `1.1.x` |
|
|
10
|
+
|
|
11
|
+
Инфраструктура: Redis ≥ 7.0 (лимитер `/redis` использует `PEXPIRE … NX`), PostgreSQL 17, RabbitMQ 4. Тесты — образы `TEST_IMAGES` (Redis 7.4.11).
|
|
12
|
+
|
|
13
|
+
Промежуточных версий по этапам нет (G14): E1–E8 копятся в `[Unreleased]`, после E8 выходит `1.0.0-rc.1` (метка npm `next`), дальше `1.0.0-rc.N`. `1.0.0` (метка `latest`) — после перехода Identity. `@verdeect/events` выходит тем же порядком: `1.0.0-rc.N` после `@verdeect/kit 1.0.0-rc.1`, `1.0.0` — вместе с `@verdeect/kit 1.0.0` (prd.md §3.9). С `1.0.0` поддерживается только последний мажор (NFR-5): исправления выходят патчем последнего мажора, без бэкпортов.
|
|
14
|
+
|
|
15
|
+
## Установка
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @verdeect/kit@1.0.0-rc.N # после E8; до 1.0.0 — точный пин rc
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Версия пакета в сервисе — точная. Обязательные peer-зависимости (NFR-3) — точными версиями в диапазоне пакета:
|
|
22
|
+
|
|
23
|
+
- `@nestjs/common`, `@nestjs/core`, `@nestjs/config`, `@nestjs/platform-fastify`;
|
|
24
|
+
- `fastify`, `reflect-metadata`, `rxjs`, `zod`, `drizzle-orm`;
|
|
25
|
+
- `nestjs-pino`, `pino`, `@prometheus-io/client`, `@sentry/nestjs`, `@verdeect/auth`.
|
|
26
|
+
|
|
27
|
+
Опциональные peer нужны только подпути, который их использует. Без них подпуть не импортируется и называет библиотеку: «`@verdeect/kit/<подпуть>` требует `<библиотека>` — добавьте в зависимости сервиса» (AC-1.2).
|
|
28
|
+
|
|
29
|
+
| Подпуть | Опциональные peer |
|
|
30
|
+
| --------------------------- | --------------------------------------------------------------------------------------- |
|
|
31
|
+
| `/config` | — |
|
|
32
|
+
| `/http` | — |
|
|
33
|
+
| `/database`, `/idempotency` | `pg` |
|
|
34
|
+
| `/testing` | `testcontainers`, `vitest`; `@nestjs/testing` — только для `createTestApp` (при вызове) |
|
|
35
|
+
| `/observability`, `/crypto` | — |
|
|
36
|
+
| `/messaging` | `amqplib`, `amqp-connection-manager`; `pg` — только при outbox или inbox |
|
|
37
|
+
| `/redis` | `ioredis` |
|
|
38
|
+
|
|
39
|
+
`/database` и `/idempotency` проверяют `pg` синхронно при импорте (текст AC-1.2) и загружают `pg` и драйвер Drizzle лениво при первом вызове. Top-level await в этих подпутях нет, поэтому схему сервиса с `tenantColumn` и `createIdempotencyTable` загружает `drizzle-kit` (он грузит схему через `require`).
|
|
40
|
+
|
|
41
|
+
## Подпути
|
|
42
|
+
|
|
43
|
+
| Подпуть | Этап | Статус |
|
|
44
|
+
| ---------------- | ---- | ----------------------- |
|
|
45
|
+
| `/config` | E1 | готов, в `[Unreleased]` |
|
|
46
|
+
| `/testing` | E1 | готов, в `[Unreleased]` |
|
|
47
|
+
| `/observability` | E2 | готов, в `[Unreleased]` |
|
|
48
|
+
| `/http` | E3 | готов, в `[Unreleased]` |
|
|
49
|
+
| `/database` | E4 | готов, в `[Unreleased]` |
|
|
50
|
+
| `/idempotency` | E4 | готов, в `[Unreleased]` |
|
|
51
|
+
| `/messaging` | E5 | готов, в `[Unreleased]` |
|
|
52
|
+
| `/crypto` | E7 | готов, в `[Unreleased]` |
|
|
53
|
+
| `/redis` | E8 | готов, в `[Unreleased]` |
|
|
54
|
+
|
|
55
|
+
Все подпути выходят вместе в `1.0.0-rc.1` после E8 (G14).
|
|
56
|
+
|
|
57
|
+
Корневой точки входа `.` нет: каждый импорт называет подпуть.
|
|
58
|
+
|
|
59
|
+
## Конфигурация окружения
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { ConfigService } from '@nestjs/config';
|
|
63
|
+
import {
|
|
64
|
+
booleanFlag,
|
|
65
|
+
defineEnv,
|
|
66
|
+
envBase,
|
|
67
|
+
envDatabase,
|
|
68
|
+
envServiceUrls,
|
|
69
|
+
KitConfigModule,
|
|
70
|
+
parseEnv,
|
|
71
|
+
} from '@verdeect/kit/config';
|
|
72
|
+
|
|
73
|
+
export const envSchema = defineEnv({
|
|
74
|
+
...envBase,
|
|
75
|
+
...envDatabase,
|
|
76
|
+
...envServiceUrls(['identity']),
|
|
77
|
+
FEATURE_X: booleanFlag(false),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const env = parseEnv(envSchema, process.env); // InvalidEnvironmentError при ошибке
|
|
81
|
+
|
|
82
|
+
@Module({ imports: [await KitConfigModule.forRoot({ schema: envSchema })] })
|
|
83
|
+
export class AppModule {}
|
|
84
|
+
// В провайдерах: config.get('PORT') через ConfigService.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Ключи пакета (prd.md §3.1):
|
|
88
|
+
|
|
89
|
+
| Фрагмент | Ключ | Умолчание |
|
|
90
|
+
| ---------------- | ---------------------------------------------------- | ------------------------------------------------------ |
|
|
91
|
+
| `envBase` | `NODE_ENV` | `production` (`development`, `test`) |
|
|
92
|
+
| `envBase` | `LOG_LEVEL` | `info` (`fatal` … `trace`, `silent`) |
|
|
93
|
+
| `envBase` | `PORT` | 3000 |
|
|
94
|
+
| `envBase` | `METRICS_PORT` | 9100; не равен `PORT`, кроме 0 |
|
|
95
|
+
| `envBase` | `REPLICA_ID` | `hostname()` |
|
|
96
|
+
| `envBase` | `SHUTDOWN_TIMEOUT_MS` | 10000 |
|
|
97
|
+
| `envBase` | `SHUTDOWN_DRAIN_MS` | 0; меньше `SHUTDOWN_TIMEOUT_MS` |
|
|
98
|
+
| `envDatabase` | `DATABASE_URL` | обязателен |
|
|
99
|
+
| `envDatabase` | `DB_POOL_MAX` | 10 |
|
|
100
|
+
| `envDatabase` | `DB_POOL_ACQUIRE_TIMEOUT_MS` | 5000 (минимум 1) |
|
|
101
|
+
| `envMessaging` | `RABBITMQ_URL` | обязателен |
|
|
102
|
+
| `envMessaging` | `RABBITMQ_RETRY_SECONDS` | 30 (целое ≥ 1) |
|
|
103
|
+
| `envRedis` | `REDIS_URL` | обязателен |
|
|
104
|
+
| `envSentry` | `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE` | пусто → `null` |
|
|
105
|
+
| `envCrypto` | `ENCRYPTION_KEYS` | JSON `[{version, secret}]`, секрет base64url ≥ 32 байт |
|
|
106
|
+
| `envServiceUrls` | `SERVICE_URLS` | JSON `{имя: http(s)-URL}`, названные имена обязательны |
|
|
107
|
+
|
|
108
|
+
Правила:
|
|
109
|
+
|
|
110
|
+
- путь ключа в ошибке — полный, через точку (`SERVICE_URLS.identity`, `ENCRYPTION_KEYS.0.secret`); ошибка без пути — `(root)`;
|
|
111
|
+
- значений окружения в ошибке нет никогда — только пути ключей (NFR-4);
|
|
112
|
+
- переименования ключей идут без псевдонимов: старое имя не читается (C0, P0);
|
|
113
|
+
- ключ пакета, переопределённый несовместимым типом, — ошибка компиляции (AC-1.6);
|
|
114
|
+
- `KitConfigModule` (через `@nestjs/config`) дописывает в `process.env` умолчания разобранных ключей. В тестах с несколькими контекстами Nest окружение сохраняется и восстанавливается целиком, а не через `vi.unstubAllEnvs()`.
|
|
115
|
+
|
|
116
|
+
## Наблюдаемость
|
|
117
|
+
|
|
118
|
+
`@verdeect/kit/observability` (prd.md §3.2) — логи, GlitchTip, `/health` и `/metrics`, остановка. Nest-модуля нет: классы собирает `bootstrapService` (`/http`) или сам сервис.
|
|
119
|
+
|
|
120
|
+
**Ранний `instrument.ts`.** Интеграции `@sentry/nestjs` требуют инициализации до загрузки приложения, поэтому `initSentry` вызывается в отдельном файле, который подключается через `node --import`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// src/instrument.ts
|
|
124
|
+
import { initSentry } from '@verdeect/kit/observability';
|
|
125
|
+
import { redaction } from './redaction.js';
|
|
126
|
+
|
|
127
|
+
initSentry({ service: 'files', redaction });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
node --import ./dist/instrument.js dist/main.js
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
GlitchTip включается только при непустом верном `SENTRY_DSN` и `NODE_ENV = production`; неверное значение `SENTRY_*` выключает его без исключения.
|
|
135
|
+
|
|
136
|
+
**Общий `KitRedaction`.** Один объект на сервис — `src/redaction.ts`, только с импортом типов из `@verdeect/kit/observability` (правило `redaction-imports`). Тот же объект передаётся и в `createLoggerOptions`, и в `initSentry`: имена и маска пути в логах и GlitchTip совпадают.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// src/redaction.ts
|
|
140
|
+
import type { KitRedaction } from '@verdeect/kit/observability';
|
|
141
|
+
|
|
142
|
+
export const redaction: KitRedaction = {
|
|
143
|
+
sensitiveKeys: ['inn'],
|
|
144
|
+
maskPath: (path) => path.replace(/^\/share\/[^/]+/, '/share/***'),
|
|
145
|
+
};
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Логгер.** `createLoggerOptions` возвращает `Params` `nestjs-pino` с кортежем `[options, stream]`. Логгер вне Nest — `pino(...params.pinoHttp)`. На Fastify генератор trace-id передаётся и адаптеру: `new FastifyAdapter({genReqId: params.pinoHttp[0].genReqId})` — так делает `createHttpApp`.
|
|
149
|
+
|
|
150
|
+
**Один журнал доступа на процесс.** `nestjs-pino` 5.3 создаёт один pino-http на процесс (первая конфигурация выигрывает), сбросить его пакет не даёт. Сервис — одно приложение на процесс, и это не мешает: опции журнала доступа (`trustTraceparent` для строки доступа, `quietRoutes`, `redaction`) берутся из первого созданного приложения. Логгер ядра каждого приложения (`KitLifecycle`, `HealthRegistry`, сервер метрик) и trace-id адаптера — свои. В тестах сервиса `createTestApp` с другими опциями логгера (`trustTraceparent`, `quietRoutes`, `redaction`) кладите в отдельный файл Vitest: каждый файл идёт в своём процессе.
|
|
151
|
+
|
|
152
|
+
**`/health` и `/metrics`.** `MetricsServer` слушает `METRICS_PORT` (`node:http`, вне Nest; `0` — порт выбирает ОС). Пробы регистрируют владельцы зависимостей: `health.register(name, probe, {critical})`. Ошибка прослушивания (`EADDRINUSE`) — отказ `listen()`, её передают в `failStartup`.
|
|
153
|
+
|
|
154
|
+
**Остановка `KitLifecycle`.** Участники регистрируются на шаг: `lifecycle.register(participant, {step})`.
|
|
155
|
+
|
|
156
|
+
| Шаг | Что |
|
|
157
|
+
| --- | ------------------------------------------------------------------------- |
|
|
158
|
+
| 1 | `/health` → 503, пауза `SHUTDOWN_DRAIN_MS` (выполняет сам `KitLifecycle`) |
|
|
159
|
+
| 2 | HTTP не принимает новые соединения, начатые дорабатывают; потоки SSE |
|
|
160
|
+
| 3 | хуки модулей Nest, подписчики, relay, очистка |
|
|
161
|
+
| 4 | пулы БД, RabbitMQ, Redis |
|
|
162
|
+
| 5 | сервер метрик |
|
|
163
|
+
|
|
164
|
+
Участники шага работают параллельно, следующий шаг начинается после предыдущего. Два режима:
|
|
165
|
+
|
|
166
|
+
- **сигнал ОС** (`lifecycle.listen()`, `SIGTERM`, `SIGINT`) — сторож срока `SHUTDOWN_TIMEOUT_MS` от сигнала (пауза входит). Срок вышел — `error`-лог `SHUTDOWN_TIMEOUT`, событие GlitchTip, `exit(1)`. Шаги в срок — `exit` не вызывается, процесс без открытых дескрипторов выходит сам с кодом 0; если не вышел к сроку (утёк таймер или сокет) — `SHUTDOWN_NOT_EXITED` и `exit(1)`;
|
|
167
|
+
- **программный `close()`** (тесты сервиса, CLI) — те же шаги 1–5 без сторожа и без `exit`; ошибка участника — отказ `close()` после всех шагов (несколько — `AggregateError`). Безопасен в тестах сервиса.
|
|
168
|
+
|
|
169
|
+
**Метрики.** `kitMetrics` построен на `@prometheus-io/client` (обязательный peer `^0.16.1`, PRD v1.12): свой `Registry` на экземпляр приложения, метка `service`, метрики процесса Node.
|
|
170
|
+
|
|
171
|
+
**Алерты.** `alertRulesTemplate` — путь к `alerts/verdeect-kit.rules.yml` в установленном пакете: правила vmalert для метрик пакета с порогом по умолчанию. Свой порог — recording-правило `verdeect_kit_threshold:<метрика>` с меткой `service` в файле правил сервиса:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
- record: verdeect_kit_threshold:outbox_lag_seconds
|
|
175
|
+
expr: vector(30)
|
|
176
|
+
labels:
|
|
177
|
+
service: platform
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Пороги: `outbox_lag_seconds` (5 с за 1m), `outbox_pending` (1000 за 5m), `events_consumed` (DLQ за 15m > 0), `db_pool_wait_timeouts` (за 5m > 0), `rate_limit_unavailable` (за 5m > 0), `cleanup_runs` (`error` и `partial` за 2 ч > 0).
|
|
181
|
+
|
|
182
|
+
## HTTP
|
|
183
|
+
|
|
184
|
+
`@verdeect/kit/http` (prd.md §3.5) — точка входа сервиса, словарь ошибок, проверка ввода, keyset-пагинация и OpenAPI.
|
|
185
|
+
|
|
186
|
+
**`main.ts`.** `bootstrapService` разбирает окружение **до** создания приложения: при неверном окружении — `failStartup`, порт не открывается. Затем логгер, `createHttpApp`, сервер метрик (`METRICS_PORT`, шаг 5), `KitLifecycle.listen()` и `PORT` на `0.0.0.0`. `instrument.ts` подключается через `node --import`; если `SENTRY_DSN` задан, `NODE_ENV = production`, а Sentry не подключён — `warn` `SENTRY_NOT_PRELOADED`, запуск продолжается.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// src/main.ts
|
|
190
|
+
import 'reflect-metadata';
|
|
191
|
+
import { bootstrapService } from '@verdeect/kit/http';
|
|
192
|
+
import { AppModule } from './app.module.js';
|
|
193
|
+
import { envSchema } from './env.js';
|
|
194
|
+
import { redaction } from './redaction.js';
|
|
195
|
+
|
|
196
|
+
await bootstrapService({ module: AppModule, service: 'files', env: envSchema, redaction });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// src/app.module.ts — KitConfigModule обязателен, схема — тот же объект, что в main.ts
|
|
201
|
+
@Module({ imports: [KitConfigModule.forRoot({ schema: envSchema }), ItemsModule] })
|
|
202
|
+
export class AppModule {}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Без `KitConfigModule.forRoot({schema})` сборка падает: «модуль сервиса не импортирует KitConfigModule.forRoot({schema})». С той же схемой `KitConfigModule` берёт окружение, разобранное `bootstrapService`, и не разбирает его повторно.
|
|
206
|
+
|
|
207
|
+
Опции по роли сервиса: BFF — `logger: {trustTraceparent: false}` (trace-id всегда новый); P, O — `logger: {quietRoutes: ['/internal/…']}` (успешный ответ — `debug`); O — `redaction`; `http: {bodyLimit}` — лимит тела (по умолчанию — лимит Fastify 1 MiB, больше — 413). Журнал доступа — один на процесс, см. «Один журнал доступа на процесс» в разделе «Наблюдаемость».
|
|
208
|
+
|
|
209
|
+
`createHttpApp` даёт: заголовки `X-Content-Type-Options: nosniff` и `Referrer-Policy: no-referrer` на каждом ответе (HSTS, CSP и `no-store` для HTML — сервис и Traefik), trace-id, `GET /ok` (`{status: 'ok'}`, `no-store`, вне журнала доступа и OpenAPI), `KitExceptionFilter`, 404 `ROUTE_NOT_FOUND`. Модули сервиса внедряют `KitLifecycle` и `HealthRegistry`.
|
|
210
|
+
|
|
211
|
+
**Остановка.** `app.close()` = `lifecycle.close()`: шаги 1–5 без сторожа и `exit`. Шаг 2 — участник `http` (`server.close()` Fastify, keep-alive закрываются) и участники сервиса параллельно; шаг 3 — участник `nest` (исходный `app.close()` Nest: `onModuleDestroy` → `beforeApplicationShutdown` → адаптер → `onApplicationShutdown`). Правило для модулей: `beforeApplicationShutdown` — шаг 3, `onApplicationShutdown` — шаг 4. `enableShutdownHooks()` не вызывайте: сигналы ловит `KitLifecycle.listen()`.
|
|
212
|
+
|
|
213
|
+
Поток SSE (это «`onStopAccepting`» §3.2) закрывает участник шага 2 сервиса вызовом `reply.raw.end()` или `destroy()`; иначе остановка ждёт до срока:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
@Injectable()
|
|
217
|
+
export class EventsStream implements OnModuleInit {
|
|
218
|
+
private readonly streams = new Set<ServerResponse>();
|
|
219
|
+
|
|
220
|
+
constructor(private readonly lifecycle: KitLifecycle) {}
|
|
221
|
+
|
|
222
|
+
onModuleInit(): void {
|
|
223
|
+
this.lifecycle.register(
|
|
224
|
+
{ name: 'sse', stop: () => this.streams.forEach((raw) => raw.end()) },
|
|
225
|
+
{ step: 2 },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Ошибки.** Словарь — `defineErrors({...kitErrors, ITEM_NOT_FOUND: {status: 404, message: 'Запись не найдена'}})` один раз на процесс; повторный вызов объединяет словари, тот же код с другим статусом — ошибка при определении, код пакета с другим статусом — ошибка компиляции. `throw new DomainError('ITEM_NOT_FOUND', {id})` → `{error: {code, message, details}}`, статус словаря, `warn` без GlitchTip. Доменные 4xx — только `DomainError`: `HttpException` без тела с другим статусом, чем 400/401/403/404/413/415/429, — 500 `INTERNAL_ERROR` (ошибка разработчика). Тело `{error}` guard'ов `@verdeect/auth` и `@verdeect/platform-client` проходит без изменений. Срок пула БД (в том числе в `cause`) — 503 `SERVICE_UNAVAILABLE` + `Retry-After: 1` без метрики и события в фильтре: срок пулов `KitDatabaseModule` учитывает сам пул — `verdeect_db_pool_wait_timeouts_total{pool="main"|"read"}` и одно событие `captureThrottled`; срок собственного пула сервиса — 503 без метрики.
|
|
232
|
+
|
|
233
|
+
| Код | Статус | Код | Статус |
|
|
234
|
+
| ------------------------ | ------ | -------------------------- | ------ |
|
|
235
|
+
| `UNAUTHENTICATED` | 401 | `RATE_LIMITED` | 429 |
|
|
236
|
+
| `FORBIDDEN` | 403 | `INTERNAL_ERROR` | 500 |
|
|
237
|
+
| `VALIDATION_FAILED` | 422 | `SERVICE_UNAVAILABLE` | 503 |
|
|
238
|
+
| `BAD_REQUEST` | 400 | `SERVICE_STOPPING` | 503 |
|
|
239
|
+
| `PAYLOAD_TOO_LARGE` | 413 | `PLATFORM_UNAVAILABLE` | 503 |
|
|
240
|
+
| `UNSUPPORTED_MEDIA_TYPE` | 415 | `IDEMPOTENCY_KEY_REUSED` | 422 |
|
|
241
|
+
| `ROUTE_NOT_FOUND` | 404 | `IDEMPOTENCY_IN_PROGRESS` | 409 |
|
|
242
|
+
| `VERSION_CONFLICT` | 409 | `IDEMPOTENCY_KEY_REQUIRED` | 400 |
|
|
243
|
+
|
|
244
|
+
**Ввод.** `@Body(new ZodValidationPipe(schema))` — ошибка → 422 `VALIDATION_FAILED`, `details.issues: [{path, code}]` (путь через точку, без значений). Неразобранный JSON — 400 `BAD_REQUEST`, неподдерживаемый `Content-Type` — 415, тело больше лимита — 413.
|
|
245
|
+
|
|
246
|
+
**Пагинация.** `PageQuerySchema` (`cursor`, `limit` 1–200, умолчание 100), чтение `limit + 1` строк, `pageOf(rows, limit, keyOf)` → `{items, nextCursor}`; условие — `afterCursor([t.createdAt, t.id], decodeCursor(query.cursor, 2), 'desc')`. Последний ключ сортировки — `primaryKey()` или `unique()`, все ключи сортировки — `NOT NULL` (`null` в курсоре — 422); неверный курсор — 422 `path: cursor`.
|
|
247
|
+
|
|
248
|
+
**OpenAPI.** `@Api({summary, response?, envelope?, errors?, extra?})` на маршруте; `verdeect-kit openapi --out contracts/openapi.json --module dist/app.module.js#AppModule` строит OpenAPI 3.1 по метаданным без запуска приложения (окружение не нужно), `info` — из `package.json`. Тело, query и path — из `ZodValidationPipe`, ответ — `response`, ошибки — `errors` + стандартные 400/422/429 (401/403 — у защищённых; 409 `TENANT_REQUIRED` — у маршрутов с `@RequireTenant()`, тело guard'а `{error: {code: 'TENANT_REQUIRED', message: 'Не выбрана компания', details: {}}}`). `security` — по `@UseGuards(VerdeectAuthGuard)` и `Require*` из `@verdeect/auth/nest`; защиту через `APP_GUARD` генерация не видит, поэтому `@UseGuards` ставится на контроллер, а маршрут без guard'а документируется без `security`. Наследование — как у Nest при регистрации маршрутов: методы собираются по цепочке прототипов, `@Api` базового (в том числе абстрактного, без `@Controller`) контроллера попадает в документ с путём наследника, метод наследника с тем же именем заменяет метод предка — один маршрут. Метод наследника переопределяет и `@Api`: без своего `@Api` он в документ не попадает. Расхождений с регистрацией маршрутов Nest нет. Что генерация не выражает (SSE, сырой прокси) — `extra` или `contracts/openapi.extra.json` рядом с `--out`. Снимок `contracts/openapi.json` коммитится; тест сравнивает его с документом из кода байт в байт, поэтому добавьте его в `.prettierignore` сервиса — иначе форматирование Prettier разойдётся с выводом CLI:
|
|
249
|
+
|
|
250
|
+
```gitignore
|
|
251
|
+
# .prettierignore
|
|
252
|
+
contracts/openapi.json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Результат — `{"written": "<путь>", "operations": N}` в stdout, выход 0. Ошибка — `{"error": {"code", "message"}}` в stdout, выход 1:
|
|
256
|
+
|
|
257
|
+
| Код | Когда |
|
|
258
|
+
| ---------------------- | ------------------------------------------------------------------------------------ |
|
|
259
|
+
| `USAGE` | нет `--out` или неизвестная опция |
|
|
260
|
+
| `MODULE_NOT_FOUND` | нет файла `--module` или в нём нет экспорта (`#<экспорт>`, по умолчанию `AppModule`) |
|
|
261
|
+
| `INVALID_PACKAGE_JSON` | нет `package.json` в cwd, он не разбирается или без `name` / `version` |
|
|
262
|
+
| `INVALID_EXTRA` | `contracts/openapi.extra.json` — не объект JSON |
|
|
263
|
+
| `INVALID_ENVIRONMENT` | неверные `LOG_LEVEL` или `REPLICA_ID` (в `keys` — имена, без значений) |
|
|
264
|
+
| `OPENAPI_FAILED` | прочий сбой сборки документа (например, код из `errors` не зарегистрирован) |
|
|
265
|
+
| `UNKNOWN_COMMAND` | команда не `openapi` и не `db` |
|
|
266
|
+
|
|
267
|
+
## База данных
|
|
268
|
+
|
|
269
|
+
`KitDatabaseModule.forRoot({schema})` — глобальный модуль БД (окружение — `envDatabase` из `KitConfigModule`): основной пул и пул чтения (`default_transaction_read_only`, `statement_timeout` 5 с), `Db`, токены `DRIZZLE` и `DRIZZLE_READ` (Drizzle со схемой сервиса для запросов вне `Db`), критичные пробы `/health` `database` и `database-read`; пулы закрываются на шаге 4 остановки. Соединений на реплику — `2 × DB_POOL_MAX`.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const ctx: DbContext = { tenant: principal.tenant, traceId: request.id, actor: { channel: 'http' } };
|
|
273
|
+
await db.transaction(ctx, async (tx) => {
|
|
274
|
+
await tx.insert(file).values({ id: newId('fil'), tenant: ctx.tenant, name });
|
|
275
|
+
db.afterCommit(tx, () => cache.invalidate(name)); // только то, что можно потерять
|
|
276
|
+
});
|
|
277
|
+
const rows = await db.read(ctx, (tx) => tx.select().from(file)); // read-only, пул чтения
|
|
278
|
+
const flags = await db.readGlobal((reader) => reader.select().from(setting)); // без компании
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- `Db.transaction(ctx, fn)` — ровно один `set_config('app.tenant', …, true)` на транзакцию; значение не переживает транзакцию. `Db.read(ctx, fn)` — read-only транзакция на пуле чтения; без тенанта — `TenantRequiredError`. Чтение без компании — только `Db.readGlobal(fn)` (без транзакции и `set_config`).
|
|
282
|
+
- `Db.afterCommit(tx, task)` — задачи после `COMMIT` по порядку; при откате не выполняются; ошибка задачи — `error`-лог и GlitchTip, не пробрасывается; в `read`/`readGlobal` — `AfterCommitInReadOnlyError`; с транзакцией не этого `Db` (другой `Db`, `DRIZZLE`, вложенная `tx.transaction()`) — `AfterCommitOutsideTransactionError` (задача не теряется молча).
|
|
283
|
+
- `Db.setLocal(tx, name, value)` — своя переменная транзакции `app.*` (`^app\.[a-z_]{1,48}$`), имя и значение — параметрами. Служебная `app.tenant` запрещена (`TypeError`): тенант задаётся только `DbContext` в `transaction`/`read`.
|
|
284
|
+
- Строгий RLS (P0): колонка `tenantColumn()` и политика `tenantPolicy(t.tenant)` — `tenant = current_setting('app.tenant', true)`, SQL генерирует `drizzle-kit` сервиса. `FORCE ROW LEVEL SECURITY` и права роли приложения — SQL-миграцией сервиса (`drizzle-kit generate --custom`), как в фикстуре пакета:
|
|
285
|
+
|
|
286
|
+
```sql
|
|
287
|
+
ALTER TABLE "files"."file" FORCE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
288
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON "files"."file" TO "files_app";
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- Первая строка первой миграции `drizzle-kit` — `CREATE SCHEMA "<svc>";`: замените её на `CREATE SCHEMA IF NOT EXISTS "<svc>";` — схему заранее создаёт `prepareDatabase` (обязательный шаг после первой генерации).
|
|
292
|
+
- `prepareDatabase({service, adminUrl, ownerPassword, appPassword})` — идемпотентно: роли `<svc>_owner` (миграции), `<svc>_app` (приложение, без `CREATE` на базе и схеме), `<svc>_directory` (`NOLOGIN`, `BYPASSRLS`, владелец функций `SECURITY DEFINER`), схема `<svc>`; повтор — `created: []`. `runMigrations({url, folder, schema})` — миграции под `<svc>_owner`, журнал — `<svc>.__migrations` (схемы `drizzle` нет), повтор — `{applied: 0}`.
|
|
293
|
+
- `deleteInBatches({db, sql, batchSize, maxBatches, shouldContinue?, connection?})` — транзакция на пачку до неполной пачки или `maxBatches`; `sql(batchSize)` возвращает одну строку с целой колонкой `deleted` (`WITH d AS (DELETE … LIMIT n RETURNING 1) SELECT count(*)::int AS deleted FROM d`); строки компаний системный контекст не видит — их удаляет функция `SECURITY DEFINER`. Остановка `Db` (шаг 3) ждёт текущую пачку, новая не начинается. `connection` — соединение блокировки `runExclusive`: пачки идут на нём, второе соединение пула не берётся.
|
|
294
|
+
- `runExclusive(db, key, (connection) => …)` — `pg_try_advisory_lock` без ожидания: `fn` в одной реплике, занято — `{ran: false}`. Блокировка держит соединение основного пула и передаётся в `fn`: работа на нём (`deleteInBatches({…, connection})`) не берёт второе соединение — очистка работает и при `DB_POOL_MAX = 1`; транзакции `Db` внутри `fn` берут другие соединения пула.
|
|
295
|
+
- `occurredAtColumn(name)` и `ifNewer(column, occurredAt)` — обновление только событием новее сохранённого (`rowCount` 0 — нормальный итог).
|
|
296
|
+
- `newId(prefix)` и `ulid()` — id `<prefix>_<ulid>` (реестр префиксов — ниже). `isUniqueViolation(err, constraint?)`, `isConnectionError(err)`, `isPoolTimeout(err)` — распознавание ошибок pg по форме с обходом `cause`.
|
|
297
|
+
- Срок пула: `verdeect_db_pool_wait_timeouts_total{pool="main"|"read"}` и `captureThrottled` пишет пул `/database` при ошибке получения соединения — и для `Db`, и для прямых `DRIZZLE`/`DRIZZLE_READ`; фильтр `/http` отвечает 503.
|
|
298
|
+
|
|
299
|
+
CLI `verdeect-kit db` (результат — одна строка JSON в stdout, логи — stderr; ошибка — `{"error": {"code", …}}` и выход 1, без адресов, паролей и текста ошибки pg):
|
|
300
|
+
|
|
301
|
+
| Команда | Окружение | Результат |
|
|
302
|
+
| ------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
|
|
303
|
+
| `db prepare --service <svc>` | `DATABASE_ADMIN_URL`, `DB_OWNER_PASSWORD`, `DB_APP_PASSWORD` | `{database, roles, created}` |
|
|
304
|
+
| `db migrate --folder <путь> --schema <svc>` | `DATABASE_MIGRATOR_URL` | `{schema, applied}` |
|
|
305
|
+
| `db move-journal --from drizzle --to <svc>` | `DATABASE_MIGRATOR_URL` | `{from, to, moved}` |
|
|
306
|
+
|
|
307
|
+
Коды ошибок: `USAGE` (нет подкоманды или опции, имя вне формата, нет журнала `drizzle-kit` в папке), `INVALID_ENVIRONMENT` (`keys` — имена), `DATABASE_UNAVAILABLE`, `DB_COMMAND_FAILED` (`sqlState`).
|
|
308
|
+
|
|
309
|
+
## Идемпотентность
|
|
310
|
+
|
|
311
|
+
`KitIdempotencyModule.forRoot({table})` (нужен `KitDatabaseModule`) и `@Idempotent({required?, tenantFromBody?})` на маршруте — повтор с тем же `Idempotency-Key` и телом возвращает сохранённые статус, тело и `Location` с `Idempotent-Replayed: true`.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
// schema.ts
|
|
315
|
+
export const idempotency = createIdempotencyTable(files);
|
|
316
|
+
|
|
317
|
+
// controller
|
|
318
|
+
@Post()
|
|
319
|
+
@UseGuards(VerdeectAuthGuard)
|
|
320
|
+
@Idempotent({ required: true })
|
|
321
|
+
create(@Body(new ZodValidationPipe(CreateFile)) body: CreateFileDto) { … }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- Таблица `createIdempotencyTable(pgSchema('<svc>'))` — `idempotency_key` (с меткой попытки `attempt`: ответ пишет только попытка, захватившая строку; если обработчик шёл дольше 5 минут и строку перехватил повтор, его ответ не сохраняется — `warn`) под политикой `tenant IS NOT DISTINCT FROM nullif(current_setting('app.tenant', true), '')`. В SQL-миграцию сервиса — `FORCE`, права `<svc>_app` и текст `idempotencyPurgeSql(<схема>)` без изменений (функция очистки `SECURITY DEFINER`, владелец `<svc>_directory`); пример — `test/consumer/minimal/drizzle/0001_force_grants_purge.sql`. Нет функции `<svc>.idempotency_purge` — отказ запуска с текстом про `idempotencyPurgeSql`.
|
|
325
|
+
- `@Idempotent` — **только на маршрутах с `VerdeectAuthGuard`**: без проверенного принципала — 500 `INTERNAL_ERROR` при любом ключе.
|
|
326
|
+
- Область ключа: пользователь и агент — компания токена и `sub` (поле тела не учитывается); служба — `client_id` и, при `tenantFromBody: 'tenant'`, компания из поля тела (нет поля — без компании).
|
|
327
|
+
- Ответы: нет ключа при `required` — 400 `IDEMPOTENCY_KEY_REQUIRED`; ключ не по формату `^[\x21-\x7E]{1,200}$` — 422 `VALIDATION_FAILED` (`path: Idempotency-Key`); другое тело — 422 `IDEMPOTENCY_KEY_REUSED`; запрос ещё идёт — 409 `IDEMPOTENCY_IN_PROGRESS`. Сохраняются только успешные ответы; брошенная `in_progress` переиспользуется через 5 минут, запись — 24 часа. Заголовки и коды попадают в OpenAPI по метаданным `@Idempotent`.
|
|
328
|
+
- Очистка — при старте и раз в час, одной репликой (`runExclusive`), пачками функцией `idempotency_purge`. Метрика `verdeect_cleanup_runs_total{table, result}`: `ok`, `partial` (упёрлась в предел пачек — очистка не успевает), `interrupted` (остановлена при выкате; алерт не срабатывает), `error`; алерт `VerdeectCleanupFailing` (`error` и `partial`) (порог `verdeect_kit_threshold:cleanup_runs`). Число удалённых строк — в `info`-логе прохода «очистка идемпотентности: проход» (`service`, `replica`, `table`, `deleted`).
|
|
329
|
+
|
|
330
|
+
## Сообщения
|
|
331
|
+
|
|
332
|
+
`/messaging` требует `amqplib` и `amqp-connection-manager` (проверка при импорте подпути); `pg` — только при outbox или `dedupe: 'inbox'` (проверка в `forRoot`, до прослушивания порта).
|
|
333
|
+
|
|
334
|
+
**Имя сервиса** — имя экземпляра (`^[a-z][a-z0-9-]{1,31}$`, тот же текст отказа, что у `bootstrapService`): от него очереди `<svc>`, `<svc>.retry`, `<svc>.dlq`, `<svc>.replica.<REPLICA_ID>` и пользователь RabbitMQ. BFF — два экземпляра `bff-app` и `bff-admin`, у каждого свои очереди и пользователь.
|
|
335
|
+
|
|
336
|
+
**События** — `defineEvent({type, version, subject, tenant?, data})`: `type` — каталог `01` §7.2 (`<service>.<entity>.<action>`), `tenant: 'required'` по умолчанию, `'none'` — только для событий без компании; `data` — `z.object` без `.strict()` (новые поля без мажора). Каталог событий — `@verdeect/events`.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
export const CompanyCreated = defineEvent({
|
|
340
|
+
type: 'platform.company.created',
|
|
341
|
+
version: 1,
|
|
342
|
+
subject: 'company',
|
|
343
|
+
data: z.object({ companyId: z.string(), name: z.string().min(1) }),
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Таблицы** — в схеме Drizzle сервиса `createOutboxTable(schema, {tenantNullable?})` и `createInboxTable(schema)`; SQL генерирует `drizzle-kit`, ручная миграция добавляет `FORCE` и права роли приложения:
|
|
348
|
+
|
|
349
|
+
```sql
|
|
350
|
+
ALTER TABLE "platform"."outbox" FORCE ROW LEVEL SECURITY;
|
|
351
|
+
--> statement-breakpoint
|
|
352
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON "platform"."outbox" TO "platform_app";
|
|
353
|
+
--> statement-breakpoint
|
|
354
|
+
GRANT SELECT, INSERT, DELETE ON "platform"."inbox" TO "platform_app";
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Outbox — единственное исключение строгой модели: пустой тенант видит все строки (relay, очистка); `assertStrictTenantIsolation` проверяет, что политика именно `createOutboxTable`. Inbox — без RLS.
|
|
358
|
+
|
|
359
|
+
**Модуль**:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
// сервис с outbox и inbox
|
|
363
|
+
KitMessagingModule.forRoot({
|
|
364
|
+
service: 'platform',
|
|
365
|
+
outbox: { table: schema.outbox, events: [CompanyCreated] },
|
|
366
|
+
subscriber: { inbox: schema.inbox },
|
|
367
|
+
});
|
|
368
|
+
// BFF: подписчик без БД
|
|
369
|
+
KitMessagingModule.forRoot({ service: 'bff-app', subscriber: { dedupe: 'none' } });
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
`outbox.events` — все события, которые сервис публикует. `tenantNullable` берётся из таблицы: событие `'none'` при NOT NULL `tenant` — отказ запуска `MESSAGING_TENANT_NOT_NULLABLE`. `subscriber` не задан — подписчика нет; `dedupe` по умолчанию `'inbox'` (требует `inbox`). Окружение — `envBase` и `envMessaging`; outbox и inbox — вместе с `KitDatabaseModule`.
|
|
373
|
+
|
|
374
|
+
**Запись** — `OutboxStore.add(tx, Event, {subjectId, data})` в `Db.transaction`: `tenant`, `actor`, `traceId` — из `DbContext`, id — `evt_` + ULID. `data` не по схеме, нарушение признака `tenant`, событие вне `outbox.events` или транзакция `Db.read` — исключение до записи, транзакция откатывается. Публикация мимо outbox невозможна, SQL-триггеров нет.
|
|
375
|
+
|
|
376
|
+
**Relay** — после `COMMIT` сразу (без ожидания опроса), иначе опрос раз в 1 с; пачка ≤ 100 по `seq` (`FOR UPDATE SKIP LOCKED` — реплики не делят строку), публикация с подтверждением брокера, срок пачки 5 с; первая неудача прерывает пачку (порядок сохраняется), паузы 1 с → 2 → 4 … 5 мин; строка, не опубликованная 5 раз, — `error` и событие GlitchTip с `id` и `type` (без `data`). Без соединения outbox не опрашивается, `verdeect_outbox_lag_seconds` растёт. Метрики — `verdeect_outbox_pending`, `verdeect_outbox_lag_seconds`.
|
|
377
|
+
|
|
378
|
+
**Подписчик** — `KitSubscriber` внедряется в провайдер сервиса, обработчики регистрируются в `onModuleInit`:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
@Injectable()
|
|
382
|
+
export class CompanyEvents implements OnModuleInit {
|
|
383
|
+
constructor(private readonly subscriber: KitSubscriber) {}
|
|
384
|
+
|
|
385
|
+
onModuleInit(): void {
|
|
386
|
+
this.subscriber.on(CompanyCreated, async (event, tx) => {
|
|
387
|
+
// tx — транзакция Db с DbContext события; inbox уже занят в ней
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Топологию объявляет подписчик: `<svc>` (DLX → `<svc>.retry`), `<svc>.retry` (TTL `RABBITMQ_RETRY_SECONDS` → `<svc>`), `<svc>.dlq`; привязки — по типам `on`. `prefetch` 10. inbox занимается в транзакции обработчика: повтор `id` → `duplicate` без вызова. `ExpectedMessageError` — `warn` и повтор через `.retry`, до 5 раз по `x-death`, затем DLQ без события GlitchTip; прочие ошибки — `warn` и повтор, после 5 — DLQ и ровно одно событие GlitchTip; `InvalidMessageError` — сразу DLQ без события GlitchTip; ошибки разбора (`not-json`, `envelope`, `unsubscribed`, `tenant`, `data`) — `captureThrottled`. Порядок по `occurredAt` — `ifNewer` из `/database`. `dedupe: 'none'` — без БД, `tx = null`. `onReplica(Event, handler)` — очередь реплики `<svc>.replica.<REPLICA_ID>` (exclusive, auto-delete) без inbox, повторов и DLQ: неразобранное сообщение или ошибка обработчика — `error`-лог, событие `captureThrottled`, `verdeect_events_consumed_total{result="dlq"}`; у очередей реплики (`<svc>.replica.*`) сообщение не сохраняется — смотрите лог и GlitchTip. Метрика — `verdeect_events_consumed_total{type, result}` (`handled`, `duplicate`, `retry`, `dlq`).
|
|
394
|
+
|
|
395
|
+
| `x-dlq-reason` | Когда |
|
|
396
|
+
| ----------------------------- | -------------------------------------- |
|
|
397
|
+
| `not-json` | тело не JSON |
|
|
398
|
+
| `envelope` | конверт не по `01` §7.1 |
|
|
399
|
+
| `unsubscribed` | тип без обработчика |
|
|
400
|
+
| `tenant` | признак `tenant` события нарушен |
|
|
401
|
+
| `data` | `data` не по схеме события |
|
|
402
|
+
| причина `InvalidMessageError` | обработчик отклонил сообщение навсегда |
|
|
403
|
+
| код `ExpectedMessageError` | ожидаемый отказ после 5 повторов |
|
|
404
|
+
| `retries-exhausted` | непредвиденная ошибка после 5 повторов |
|
|
405
|
+
|
|
406
|
+
Другой TTL уже объявленной `<svc>.retry` — отказ запуска «очередь `<svc>.retry` объявлена с другим TTL — пересоздайте очередь» без цикла переподключений (порядок смены — «Развёртывание»). Exchange `verdeect.events` сервис только проверяет пассивно; нет exchange — `/health` `rabbitmq` down.
|
|
407
|
+
|
|
408
|
+
**Очистка** — опубликованный outbox и inbox старше 7 дней, при старте и раз в час, одной репликой (`runExclusive`), пачками по 1000, до 100 за проход, на соединении блокировки (работает при `DB_POOL_MAX = 1`). Метрика `verdeect_cleanup_runs_total{table: outbox|inbox, result}`, число строк — `info`-лог «очистка сообщений: проход».
|
|
409
|
+
|
|
410
|
+
**Остановка**: relay, подписчик (снять потребителей → дождаться доставок → закрыть канал) и очистка — шаг 3, соединение RabbitMQ — шаг 4, в общем сроке `SHUTDOWN_TIMEOUT_MS`. Проба `/health` `rabbitmq` — некритичная.
|
|
411
|
+
|
|
412
|
+
## Шифр
|
|
413
|
+
|
|
414
|
+
`@verdeect/kit/crypto` шифрует секреты в БД (секреты клиентов, ключи агентов, сессии): AES-256-GCM, ключ каждой версии — HKDF-SHA256 от декодированных байт секрета, соль пустая, `info = verdeect-<svc>:<purpose>`. Один секрет `ENCRYPTION_KEYS` даёт разные ключи для разных сервисов и назначений. Шифр — чистые функции без Nest-модуля и без `pg`.
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { createCipher, DecryptError } from '@verdeect/kit/crypto';
|
|
418
|
+
|
|
419
|
+
const cipher = createCipher({ service: 'identity', keys: env.ENCRYPTION_KEYS, purpose: 'client-secret' });
|
|
420
|
+
const value = cipher.encrypt(secret, { aad: `client-secret:${client.id}` });
|
|
421
|
+
const plain = cipher.decrypt(value, { aad: `client-secret:${client.id}` });
|
|
422
|
+
if (cipher.needsReencrypt(value)) {
|
|
423
|
+
// перешифровать в задаче под runExclusive
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
- Значение — `v<N>:<iv>:<ct>:<tag>` в base64url без `=`: `N` — версия ключа, IV 12 байт, тег 16 байт. Шифрует первый ключ `keys`, расшифровывает ключ версии значения — без перебора.
|
|
428
|
+
- `aad = <purpose>:<id записи>` обязателен: значение одной записи не расшифровывается в другой. Пустой `aad` — `TypeError`.
|
|
429
|
+
- `DecryptError.reason`: `'format'` — значение вне формата (в том числе неканоничный base64url: лишний символ или изменённые хвостовые биты), `'unknown_version'` — версии нет в `keys`, `'auth'` — другой `aad`, ключ или изменённое значение. Текст ошибки без значения, `aad`, открытого текста и ключей.
|
|
430
|
+
- Старые форматы сервисов (`v1:` base64, строковая версия, перебор секретов) не читаются: значения создаются заново при переходе (§10).
|
|
431
|
+
- Секрет — `verdeect-kit crypto generate-secret | jq -r .secret` (`--bytes` — целое ≥ 32, по умолчанию 32) или `generateSecret()`.
|
|
432
|
+
|
|
433
|
+
## Redis
|
|
434
|
+
|
|
435
|
+
`@verdeect/kit/redis` — Redis сервиса: соединение с префиксом `<svc>:`, каналы, лимитер частоты и 429. Ключ окружения — `REDIS_URL` (`envRedis`); нужен `ioredis` в зависимостях сервиса.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { KitRedisModule, RateLimiter, throwRateLimited } from '@verdeect/kit/redis';
|
|
439
|
+
|
|
440
|
+
@Module({
|
|
441
|
+
imports: [
|
|
442
|
+
KitConfigModule.forRoot({ schema: defineEnv({ ...envBase, ...envRedis }) }),
|
|
443
|
+
KitRedisModule.forRoot({ service: 'identity', subscriber: true }),
|
|
444
|
+
],
|
|
445
|
+
})
|
|
446
|
+
export class AppModule {}
|
|
447
|
+
|
|
448
|
+
// в обработчике
|
|
449
|
+
const result = await limiter.hit(`login:${ip}`, { limit: 30, windowMs: 60_000 });
|
|
450
|
+
if (!result.allowed) {
|
|
451
|
+
throwRateLimited(result); // 429 RATE_LIMITED + Retry-After
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
- `KIT_REDIS` — `Redis` ioredis основного соединения с `keyPrefix <svc>:`: ключ `k` в Redis — `<svc>:k`. Префикс не ставится шаблонам `KEYS`/`SCAN` и не снимается в их ответах — сервис пишет префикс в шаблон сам. Срок команды — 500 мс; соединение ленивое, закрывается на шаге 4 остановки.
|
|
456
|
+
- `KitRedisChannels` — `publish('x', message)` → канал `<svc>:x`, `subscribe('x', handler)` → функция отписки; сообщение — строка. Подписки требуют `subscriber: true` — второе соединение (срок команды 5 с); без флага второго соединения нет, `subscribe` — `Error`.
|
|
457
|
+
- `RateLimiter.hit(key, {limit, windowMs})` считает попадание, `peek` — только читает. Окно фиксированное от первого попадания (`INCR`, `PEXPIRE … NX`, `PTTL` одним `MULTI`), ключ — `<svc>:rl:<key>`, итог — `{allowed, remaining, retryAfterMs}`.
|
|
458
|
+
- Redis недоступен — лимитер пропускает запрос (fail-open: `allowed: true`, `remaining = limit`), `verdeect_rate_limit_unavailable_total` +1 на каждую проверку, `warn` не чаще раза в 60 с, алерт `VerdeectRateLimitUnavailable`.
|
|
459
|
+
- `throwRateLimited(result)` — `DomainError('RATE_LIMITED', {retryAfterSec})`: `KitExceptionFilter` отвечает 429 и `Retry-After` в секундах вверх (не меньше 1).
|
|
460
|
+
- Проба `/health` `redis` (`PING`) некритичная: Redis недоступен — `degraded`, 200.
|
|
461
|
+
|
|
462
|
+
## Тесты сервиса
|
|
463
|
+
|
|
464
|
+
Подпуть `/testing` разрешается только с условием `development` (NFR-4): в production-сборке сервиса его нет. Vitest включает это условие сам; для `node` — `node --conditions=development`.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
// test/integration/global-setup.ts
|
|
468
|
+
import { startTestStack } from '@verdeect/kit/testing';
|
|
469
|
+
|
|
470
|
+
export default startTestStack({
|
|
471
|
+
service: 'files',
|
|
472
|
+
postgres: true,
|
|
473
|
+
migrations: 'drizzle',
|
|
474
|
+
mailpit: true,
|
|
475
|
+
});
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Контейнеры `TEST_IMAGES` стартуют раз на прогон, адреса доходят до тестов через `inject`:
|
|
479
|
+
|
|
480
|
+
| Ключ `inject` | Флаг | Значение |
|
|
481
|
+
| -------------------------- | -------------- | --------------------------------------------------------- |
|
|
482
|
+
| `kitPostgresUrl` | `postgres` | `postgres://postgres:<пароль>@<host>:<port>/postgres` |
|
|
483
|
+
| `kitDatabaseUrl` | `postgres` | роль `<svc>_app` к БД `<svc>` — `DATABASE_URL` тестов |
|
|
484
|
+
| `kitDatabaseOwnerUrl` | `postgres` | роль `<svc>_owner` к БД `<svc>` — `DATABASE_MIGRATOR_URL` |
|
|
485
|
+
| `kitDatabaseAdminUrl` | `postgres` | суперпользователь к БД `<svc>` |
|
|
486
|
+
| `kitDatabaseService` | `postgres` | имя сервиса: схема и роли |
|
|
487
|
+
| `kitDatabaseTemplate` | `postgres` | шаблонная БД `<svc>_template` |
|
|
488
|
+
| `kitSmtpUrl` | `mailpit` | `smtp://<host>:<port>` |
|
|
489
|
+
| `kitMailpitUrl` | `mailpit` | `http://<host>:<port>` — HTTP API mailpit |
|
|
490
|
+
| `kitS3Endpoint` | `minio` | `http://<host>:<port>` |
|
|
491
|
+
| `kitS3AccessKey` | `minio` | пользователь MinIO |
|
|
492
|
+
| `kitS3SecretKey` | `minio` | пароль MinIO, случайный на прогон |
|
|
493
|
+
| `kitRabbitmqUrl` | `rabbitmq` | `amqp://<администратор>@<host>:<port>` — vhost `/` |
|
|
494
|
+
| `kitRabbitmqManagementUrl` | `rabbitmq` | `http://<host>:<port>` — management API |
|
|
495
|
+
| `kitRabbitmqWorkers` | `rabbitmq` | vhost воркеров `{vhost, url, adminUrl}` |
|
|
496
|
+
| `kitRedisUrl` | `redis` | пользователь сервиса из aclfile — `REDIS_URL` тестов |
|
|
497
|
+
| `kitRedisAdminUrl` | `redis` | администратор теста: `CLIENT LIST`, `ACL LOG`, очистка |
|
|
498
|
+
| `kitRedisService` | `redis` | имя сервиса — пользователь Redis и префикс воркера |
|
|
499
|
+
| `kitOutboxCoverageDir` | `outboxEvents` | каталог покрытия outbox прогона |
|
|
500
|
+
|
|
501
|
+
При `postgres: true` `startTestStack` готовит БД как в production: `prepareDatabase` на шаблоне `<svc>_template`, миграции папки `migrations` (путь от корня проекта; без `postgres` — `TypeError`), затем копия `<svc>`. При `rabbitmq: true` — RabbitMQ с `definitions.json` сервиса (тот же `buildDefinitions`, что CLI) и vhost с пользователем по правам сервиса на каждый воркер Vitest (`<svc>-test-<run>-w<N>`); teardown удаляет их, остатки прошлых прогонов удаляются при старте. При `redis: true` — Redis с aclfile (тот же генератор, что CLI `redis acl`): пользователь сервиса с правами `~<svc>:* &<svc>:*` — `kitRedisUrl`, администратор теста — `kitRedisAdminUrl`; пользователей на воркер нет — воркеры разделены префиксом `workerScope().redisPrefix`.
|
|
502
|
+
|
|
503
|
+
- `createScratchDatabase()` — пустая мигрированная БД из шаблона за доли секунды (`{name, url, ownerUrl, adminUrl, drop()}`); тест сам вызывает `drop()`.
|
|
504
|
+
- `workerScope().rabbitmq` — `{vhost, url}` vhost воркера: `url` — значение `RABBITMQ_URL` тестов.
|
|
505
|
+
- `workerScope().redisPrefix` — префикс Redis воркера `<svc>:w<VITEST_POOL_ID>:`; приложение теста получает его через `createTestApp({…, overrides: [redisWorkerOverride()]})`, `REDIS_URL` — `inject('kitRedisUrl')`.
|
|
506
|
+
- `workerScope().tenant` — слаг компании воркера (`w<VITEST_POOL_ID>-<8 hex>`, формат claim `tenant`): то же значение в `DbContext.tenant` и в токене; `tenantId` и строку компании создаёт фабрика сервиса.
|
|
507
|
+
- `countQueries(pool, fn, {onQuery?})` — `{result, count, queries}`: все запросы пула за время `fn` (транзакция `Db` с одним запросом — 4: `begin`, `set_config`, запрос, `commit`); `onQuery(text)` — синхронный сигнал на каждый запрос (например, «первая пачка началась») вместо паузы.
|
|
508
|
+
- `assertStrictTenantIsolation(pool, {schema, except?})` — один тест сервиса под ролью приложения после вставки данных фабриками: каждая таблица с колонкой `tenant` — RLS, `FORCE`, строгая политика, 0 строк под пустым тенантом; `idempotency_key` и `outbox` (политика `createOutboxTable`) — исключения строгой модели; функция `idempotency_purge` — `SECURITY DEFINER`, владелец `<svc>_directory`, `search_path`, без `EXECUTE` у `PUBLIC`.
|
|
509
|
+
- `assertAppRoleIsNotOwner(db)` — роль соединения не суперпользователь, без `BYPASSRLS`, не владеет схемами и таблицами, без `CREATE` на базе и схемах.
|
|
510
|
+
- `expectOutbox(tx, Event, {subjectId?, data?})` — строка события в текущей транзакции use-case (строки других транзакций не считаются), `data` по схеме события; успешная проверка пишет покрытие `<type>.v<version>`. `startTestStack({outboxEvents})` + `KIT_OUTBOX_COVERAGE=1` (в `make test-integration` и CI) — teardown падает со списком событий без успешного `expectOutbox` (`assertOutboxCoverage`); без переменной проверка не выполняется.
|
|
511
|
+
- `publishTestEvent(Event, data, opts?)` — конверт `01` §7.1 в `verdeect.events` vhost воркера (тенант воркера для `'required'`, `null` для `'none'`; `opts.tenant` может нарушать признак); `waitForInbox(id, {timeoutMs})` — ожидание строки `<svc>.inbox` без `sleep` в тесте (подписчик с `dedupe: 'inbox'`).
|
|
512
|
+
|
|
513
|
+
`TEST_IMAGES`: `postgres:17.11-alpine3.24`, `redis:7.4.11-alpine`, `rabbitmq:4.3.6-management-alpine`, `axllent/mailpit:v1.31.6`, `pgsty/minio:RELEASE.2026-08-04T00-00-00Z`. Мажорная смена образа — мажор пакета (G14).
|
|
514
|
+
|
|
515
|
+
`assertRepoConventions({ root })` — один тест репозитория сервиса. Новое правило после `1.0.0` — мажор (G14); между `rc` — допустимо.
|
|
516
|
+
|
|
517
|
+
| Правило | Что проверяется | Кого |
|
|
518
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
519
|
+
| `test-images` | образы `postgres`, `redis`, `rabbitmq`, `axllent/mailpit`, `pgsty/minio` в `compose*.yml` и CI (`.gitlab-ci.yml`, `.github/workflows/*`) — ровно с тегом `TEST_IMAGES` | все сервисы |
|
|
520
|
+
| `replica-id` | `REPLICA_ID` не задан в `environment:` и `env_file:` | все сервисы |
|
|
521
|
+
| `stop-grace-period` | `stop_grace_period` > `SHUTDOWN_TIMEOUT_MS` | приложения |
|
|
522
|
+
| `env-keys` | нет старых ключей: `IDENTITY_PORT`, `BFF_PORT` → `PORT`; `DATABASE_MIGRATION_URL` → `DATABASE_MIGRATOR_URL`; `SESSION_ENC_KEYS`, `SIGNING_KEY_ENCRYPTION_KEYS`, `CLIENT_SECRET_KEY`, `IDENTITY_SECRETS` → `ENCRYPTION_KEYS` | приложения |
|
|
523
|
+
| `peer-versions` | peer пакета в `package.json` сервиса — точная версия в peer-диапазоне `@verdeect/kit` | манифесты с `@verdeect/kit` |
|
|
524
|
+
| `instrument-import` | команда запуска приложения содержит `--import` со значением, где есть `instrument`, в аргументах `node` (`NODE_OPTIONS` не засчитывается) | `command:`/`entrypoint:` приложений, Dockerfile `CMD`/`ENTRYPOINT`, `scripts.start` |
|
|
525
|
+
| `command-unrecognized` | команда запуска — одна из распознаваемых форм; иначе «команда не распознана: <форма>; нужна форма `node --import ./dist/instrument.js dist/main.js`» | `command:`/`entrypoint:` приложений и заданий, стадии Dockerfile, на которые они ссылаются, `scripts.start` |
|
|
526
|
+
| `redaction-imports` | `src/redaction.ts` импортирует только типы из `@verdeect/kit/observability` | `src/redaction.ts`, `apps/*/src/redaction.ts` |
|
|
527
|
+
|
|
528
|
+
- **Приложение** — слитый сервис `compose*.yml` (база ⊕ переопределение), у которого в `environment:` или в файлах `env_file:` есть хотя бы один ключ `envBase`. Подделка вида `*.main.ts` / `*.main.js` (например, `fake-services.main.ts`) и инфраструктурные сервисы (без ключей `envBase`) этими правилами не проверяются. **Одноразовое задание** — сервис с командой (`command:`/`entrypoint:`, своей или унаследованной) без запуска приложения и без формы «команда не распознана» (например, `pnpm db:migrate`, `node dist/cli.js migrate`): правила `stop-grace-period` и `instrument-import` к нему не применяются, `test-images`, `replica-id`, `env-keys` и `command-unrecognized` — применяются; сервис без команды запускается командой Dockerfile и считается приложением.
|
|
529
|
+
- **Запуск приложения** — команда `node`, у которой первый аргумент после опций — файл входа `main`, `main.js`, `main.mjs`, `main.cjs` или `main.ts`. Разбор — по правилам shell (`shell-quote`): `sh`/`bash`/`ash`/`dash` с опциями до `-c` раскрываются: короткие флаги и кластеры (`-ec`, `-lc`, `-e -c`), `o`/`O` с именем опции (`-o pipefail`, `-eo pipefail`, `-O extglob`, `+O …`), длинные `--<имя>` (`--login`, `--norc`; `--rcfile <f>`, `--init-file <f>` — со значением); команды через `&&`, `||`, `;`, `&`, `|`, перевод строки и в подоболочке `( … )` проверяются по отдельности; комментарий `#` в начале слова снимается до конца строки, `\` — продолжение строки. Снимаются обёртки: `exec`, `tini` (опции `-s`, `-g`, `-v`, `-w`, `-l`, `-p <сигнал>`, `-e <код>`, `--`), `dumb-init` (`-c`/`--single-child`, `-v`/`--verbose`, `-r`/`--rewrite <s:r>`, `--`), `env [VAR=…] [-флаги]`, ведущие `VAR=…`, `pnpm|npm|yarn exec|dlx`, `npx`. `${VAR:-x}` и `${VAR-x}` подставляются умолчанием. **У приложения обязателен распознанный запуск**: у стадии Dockerfile, которой запускается приложение без своего `command:`/`entrypoint:`, и у `scripts.start` итоговая команда (по правилам Docker и Compose) без запуска приложения — «не найден запуск приложения: <голова>…».
|
|
530
|
+
- **Команда не распознана** (`command-unrecognized`): раннеры (`pnpm`/`npm`/`yarn start*` и `run start*`, `node --run start*`, `nest start`, `tsx`, `ts-node`, `pm2`, `pm2-runtime`, `nodemon`), `gosu`, `su-exec`, опция `tini`/`dumb-init` вне списка, вызов оболочки с `-c` без выделенного скрипта, `$(…)` и `` `…` ``, `eval`, переменная без умолчания на месте программы или обёртки, среди опций `node` до файла входа или в самом файле входа (аргументы после файла входа не проверяются: `dist/main.js --port $PORT` — без нарушения), `main.*` в аргументах любой команды, кроме файла входа `node` (`node --max-old-space-size 512 dist/main.js`, `./docker-entrypoint.sh node dist/main.js`).
|
|
531
|
+
- **Dockerfile**: действуют последние `CMD` и `ENTRYPOINT` стадии; `FROM <стадия>` наследует их, `ENTRYPOINT` дочерней стадии сбрасывает унаследованный `CMD`; `CMD` внутри `HEALTHCHECK` — не `CMD` стадии; `\` с пробелами или табуляцией перед переводом строки — продолжение. Exec-форма `ENTRYPOINT` получает `CMD` аргументами (exec-форма как есть, shell-форма как `/bin/sh -c "…"`); у `ENTRYPOINT ["sh", "-c", "<скрипт>"]` запуск ищется в скрипте, `CMD` — только `$0…`; shell-форма `ENTRYPOINT` игнорирует `CMD`; невалидный JSON (`CMD ['node', …]`) — shell-форма, как в Docker.
|
|
532
|
+
- **Compose**: базовый файл — первый найденный из `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`. Переопределение проверяется как слитый сервис: `environment:`/`env_file:` сливаются с базой, `command:`, `entrypoint:`, `build:`, `stop_grace_period` без своего значения берутся из базы; `!reset` возвращает умолчание, `!override` заменяет значение базы целиком. `entrypoint:` задан — `CMD` образа отбрасывается, `command:` — его аргументы; только `command:` — аргументы `ENTRYPOINT` стадии `build:` (`target` или последняя стадия), если Dockerfile найден.
|
|
533
|
+
- `stop_grace_period` — после подстановки `${VAR:-default}`; переменная без умолчания — «значение не определено»; нет нигде — 10 с Docker. `SHUTDOWN_TIMEOUT_MS` — целое или `${NAME:-N}`; нет нигде — 10000; иное — «значение не определено или не целое число мс» (без значения в тексте). В `env_file` у значения без кавычек ` #…` — комментарий, как в Compose.
|
|
534
|
+
- `image:` в compose разбирается как значение: подстановка `${VAR:-default}`, без префикса `docker.io/library/` или `docker.io/`, дайджест `@sha256:…` отдельно, без тега — `latest`. Неразрешённая переменная в теге образа из `TEST_IMAGES` — нарушение «тег не определён»; образы вне `TEST_IMAGES` и с неопределяемым репозиторием не проверяются. В тексте CI префикс `docker.io/library/` и `docker.io/` снимается так же, тег учитывается, только если в нём есть точка или буква (`postgres:5432` — адрес, а не образ).
|
|
535
|
+
|
|
536
|
+
HTTP-тесты: `createTestApp({module, service, overrides?})` — тот же `createHttpApp` на `Test.createTestingModule`, запросы — `app.inject(...)`, закрытие — `app.close()`. Нужен `@nestjs/testing` в зависимостях сервиса (иначе — текст AC-1.2 при вызове). `expectProblem(res, {status, code, issues?})` — ответ-ошибка; `expectMatchesApi(res, [ItemsController, 'list'])` — статус и тело по `@Api` маршрута: успешный статус — схема `response`, описанный статус ошибки (стандартные, `errors`, 401/403/409 guard'а) — `{error: {code, message, details}}`, прочий статус — падение (Ajv 2020, тот же построитель, что у CLI; схема компилируется один раз на маршрут и статус).
|
|
537
|
+
|
|
538
|
+
Ещё в `/testing`: `tcpProxy(target)` — обрывы (`break`, `restore`, `blackhole`, `freeze`, `blackholeOn`) и задержки (`delayOn`) TCP; `timingGuard` — `globalSetup` замеров времени (нагрузка, ядра, отказ при запущенном e2e-контуре `verdeect-*-e2e`); `TEST_TIMEOUT_SCALE` — множитель тайм-аутов конфигов Vitest.
|
|
539
|
+
|
|
540
|
+
## Развёртывание
|
|
541
|
+
|
|
542
|
+
- Порт метрик `METRICS_PORT` не публикуется наружу: его читает только сборщик метрик внутри сети.
|
|
543
|
+
- Балансировщик проверяет готовность по `/health` на `METRICS_PORT`: с начала остановки — 503, реплика выводится из балансировки за паузу `SHUTDOWN_DRAIN_MS`.
|
|
544
|
+
- Правила алертов: подключите файл `alertRulesTemplate` (`node_modules/@verdeect/kit/alerts/verdeect-kit.rules.yml`) в vmalert рядом с правилами сервиса; свои пороги — `verdeect_kit_threshold:<метрика>`.
|
|
545
|
+
- `REPLICA_ID` не задавать ни в compose, ни в `env_file`: умолчание — `hostname()` контейнера.
|
|
546
|
+
- `stop_grace_period: 15s` при `SHUTDOWN_TIMEOUT_MS` 10000: срок Docker должен быть больше срока остановки сервиса.
|
|
547
|
+
- Пул соединений PostgreSQL: сервис открывает до `2 × DB_POOL_MAX × реплики` соединений (основной пул и пул чтения) — `max_connections` считается по этой формуле.
|
|
548
|
+
- БД при каждом выкате: `verdeect-kit db prepare --service <svc>` → `verdeect-kit db migrate --folder drizzle --schema <svc>` (отдельные шаги до запуска приложения). Переход сервиса с журнала `drizzle.__drizzle_migrations` — один раз `verdeect-kit db move-journal --from drizzle --to <svc>`.
|
|
549
|
+
- RabbitMQ: `verdeect-kit rabbitmq definitions --services <svc,…> [--out definitions.json]` — `definitions.json` (vhost, пользователи сервисов, права, exchange `verdeect.events` topic durable); хеш пароля — из `RABBITMQ_PASSWORD_HASH_<SVC>` (`bff-app` → `RABBITMQ_PASSWORD_HASH_BFF_APP`, значение — `rabbitmqctl hash_password <пароль>`). Брокер грузит файл при старте (`load_definitions`). Права: configure `^<svc>(\..*)?$`, write — `verdeect.events`, `amq.default` и свои очереди, read — `verdeect.events` и свои очереди. Exchange объявляет файл, сервисы проверяют его пассивно.
|
|
550
|
+
- Redis ≥ 7.0. ACL: `verdeect-kit redis acl --services <svc,…> --out users.acl` — aclfile с `user default off` первой строкой и пользователем на сервис (`~<svc>:* &<svc>:* +@all -@dangerous`); хеш пароля — из `REDIS_PASSWORD_HASH_<SVC>` (`bff-app` → `REDIS_PASSWORD_HASH_BFF_APP`, значение — sha256 hex: `printf %s "$PASS" | sha256sum`). Redis запускается с `--aclfile users.acl`; проверка здоровья в compose — с пользователем: `redis-cli --user <svc> --pass … ping`. `REDIS_URL` сервиса — `redis://<svc>:<пароль>@<host>:6379`.
|
|
551
|
+
- DLQ — разбор в 7 дней: сообщения `<svc>.dlq` разбирает владелец сервиса не позже 7 дней (алерт `VerdeectEventsDlq`); причина — заголовок `x-dlq-reason`; повторная отправка — перекладкой в `<svc>` после исправления.
|
|
552
|
+
- Смена ключа шифра `ENCRYPTION_KEYS` (UC-10): 1) новый секрет — `verdeect-kit crypto generate-secret | jq -r .secret` (единственный рекомендуемый способ); 2) новая версия **первой** в `ENCRYPTION_KEYS`, старая остаётся; 3) перезапуск всех реплик; 4) задача сервиса под `runExclusive` перешифровывает значения с `needsReencrypt(value) === true`; 5) старая версия убирается из `ENCRYPTION_KEYS`, когда значений под ней не осталось.
|
|
553
|
+
- Смена TTL `.retry` (`RABBITMQ_RETRY_SECONDS`): 1) остановить все реплики сервиса; 2) дождаться опустошения `<svc>.retry` (или переложить сообщения в `<svc>`); 3) удалить очередь `<svc>.retry`; 4) задать новый `RABBITMQ_RETRY_SECONDS` и запустить реплики — очередь объявится с новым TTL. Иначе — отказ запуска «очередь `<svc>.retry` объявлена с другим TTL — пересоздайте очередь».
|
|
554
|
+
|
|
555
|
+
## Реестр префиксов id
|
|
556
|
+
|
|
557
|
+
Формат префикса `^[a-z]{2,5}$`, id — `<prefix>_<ulid>`; id создаёт `newId(prefix)` из `/database`. Новый префикс добавляется строкой в этот реестр в плане сервиса.
|
|
558
|
+
|
|
559
|
+
| Префикс | Сущность | Сервис |
|
|
560
|
+
| ------- | -------------------------- | ------------ |
|
|
561
|
+
| `evt` | событие outbox | все (kit) |
|
|
562
|
+
| `tst` | субъект `publishTestEvent` | kit (тесты) |
|
|
563
|
+
| `trn` | ход (turn) | orchestrator |
|
|
564
|
+
| `msg` | сообщение хода | orchestrator |
|
|
565
|
+
| `lse` | аренда (lease) рантайма | orchestrator |
|
|
566
|
+
| `jup` | загрузка журнала | orchestrator |
|
|
567
|
+
| `chm` | сообщение канала | orchestrator |
|
|
568
|
+
| `chr` | запрос канала | orchestrator |
|
|
569
|
+
|
|
570
|
+
## API
|
|
571
|
+
|
|
572
|
+
### @verdeect/kit/config
|
|
573
|
+
|
|
574
|
+
- `envBase` — базовые ключи каждого сервиса.
|
|
575
|
+
- `envDatabase` — ключи БД.
|
|
576
|
+
- `envMessaging` — ключи RabbitMQ.
|
|
577
|
+
- `envRedis` — ключ Redis.
|
|
578
|
+
- `envSentry` — ключи GlitchTip.
|
|
579
|
+
- `envCrypto` — ключи шифра `ENCRYPTION_KEYS`.
|
|
580
|
+
- `envServiceUrls` — `envServiceUrls(required)`: `SERVICE_URLS` с обязательными именами.
|
|
581
|
+
- `defineEnv` — схема окружения сервиса из фрагментов и своих ключей, с `withBaseChecks`.
|
|
582
|
+
- `withBaseChecks` — проверки между ключами пакета (`PORT` ≠ `METRICS_PORT`, `SHUTDOWN_DRAIN_MS` < `SHUTDOWN_TIMEOUT_MS`).
|
|
583
|
+
- `parseEnv` — разбор окружения схемой; ошибка — `InvalidEnvironmentError`.
|
|
584
|
+
- `InvalidEnvironmentError` — `{code: 'INVALID_ENVIRONMENT', keys}`, без значений.
|
|
585
|
+
- `KitConfigModule` — `forRoot({schema})`: глобальный `ConfigModule`, окружение разбирается при каждой сборке контекста (при `bootstrapService` — без повторного разбора), `process.env` не меняется.
|
|
586
|
+
- `migrateEnvSchema` — окружение CLI `db migrate`: `DATABASE_MIGRATOR_URL`, `LOG_LEVEL`.
|
|
587
|
+
- `prepareEnvSchema` — окружение CLI `db prepare`: `DATABASE_ADMIN_URL`, `DB_OWNER_PASSWORD`, `DB_APP_PASSWORD`, `LOG_LEVEL`.
|
|
588
|
+
- `port` — порт 0–65535.
|
|
589
|
+
- `positiveInt` — целое > 0.
|
|
590
|
+
- `optionalText` — пусто → `null`, иначе текст без крайних пробелов.
|
|
591
|
+
- `booleanFlag` — только `true`/`false`.
|
|
592
|
+
- `jsonValue` — JSON-значение со своей схемой.
|
|
593
|
+
- `httpUrl` — URL `http`/`https`.
|
|
594
|
+
- `uniqueBy` — проверка уникальности поля элементов массива.
|
|
595
|
+
- `KitBaseEnv` — тип разобранных `envBase`.
|
|
596
|
+
- `KitDatabaseEnv` — тип разобранных `envDatabase`.
|
|
597
|
+
- `KitMessagingEnv` — тип разобранных `envMessaging`.
|
|
598
|
+
- `KitRedisEnv` — тип разобранных `envRedis`.
|
|
599
|
+
- `KitCryptoEnv` — тип разобранных `envCrypto`.
|
|
600
|
+
|
|
601
|
+
### @verdeect/kit/observability
|
|
602
|
+
|
|
603
|
+
- `SENSITIVE_KEYS` — базовый список чувствительных имён (§3.2); каждое скрывается на уровнях 0–2, `err.code` виден.
|
|
604
|
+
- `KitRedaction` — `{sensitiveKeys?, maskPath?}`: имена добавляются к `SENSITIVE_KEYS`, `maskPath` — маска пути в логе доступа и GlitchTip.
|
|
605
|
+
- `createLoggerOptions` — `createLoggerOptions({service, entry, env, trustTraceparent?, quietRoutes?, extraBase?, redaction?})`: опции `nestjs-pino` (HTTP — stdout, CLI — stderr).
|
|
606
|
+
- `KitLoggerOptions` — тип опций `createLoggerOptions`.
|
|
607
|
+
- `KitLoggerParams` — тип результата: `Params` с кортежем `pinoHttp: [options, stream]`.
|
|
608
|
+
- `sanitizeError` — ошибка без SQL, параметров и значений: ветки pg/Drizzle, ioredis и зарегистрированные; цепочка `cause` до 10.
|
|
609
|
+
- `registerErrorSanitizer` — `registerErrorSanitizer(match, sanitize)`: своя ветка сервиса, глобально на процесс.
|
|
610
|
+
- `initSentry` — `initSentry({service, redaction?})` в `instrument.ts`; возвращает, включён ли GlitchTip.
|
|
611
|
+
- `KitSentryOptions` — тип опций `initSentry`.
|
|
612
|
+
- `captureError` — `captureError(err, {tags?, extra?})`: ровно одно событие на ошибку.
|
|
613
|
+
- `captureThrottled` — `captureThrottled(key, err, {windowMs = 60_000})`: одно событие за окно на ключ; возвращает, отправлено ли.
|
|
614
|
+
- `HealthRegistry` — пробы `/health`: `register(name, probe, {critical})`, `markStopping()`, `evaluate()`; срок пробы 2 с.
|
|
615
|
+
- `HealthProbe` — тип пробы `() => Promise<boolean>`.
|
|
616
|
+
- `HealthReport` — `{status: 'ok' | 'degraded' | 'unavailable', checks}`.
|
|
617
|
+
- `MetricsServer` — `/health` и `/metrics` на `METRICS_PORT`: `listen()`, `address()`, `close()`.
|
|
618
|
+
- `kitMetrics` — `kitMetrics({service})`: свой реестр экземпляра с меткой `service`, метрики процесса Node и `verdeect_*`.
|
|
619
|
+
- `KitMetrics` — тип реестра и метрик пакета.
|
|
620
|
+
- `alertRulesTemplate` — абсолютный путь к `alerts/verdeect-kit.rules.yml`.
|
|
621
|
+
- `failStartup` — `failStartup(raw, logger)`: `fatal`-лог, событие GlitchTip с ожиданием до 2 с, `exit(1)`.
|
|
622
|
+
- `StartupFailure` — интерфейс ошибки запуска сервиса `{startupCode, startupDetails()}`.
|
|
623
|
+
- `KitLifecycle` — остановка по шагам 1–5: `register`, `listen()` (сигнал ОС, сторож срока), `close()` (без сторожа).
|
|
624
|
+
- `KitShutdownParticipant` — участник `{name, stop()}`.
|
|
625
|
+
- `KitShutdownStep` — шаг участника `2 | 3 | 4 | 5`.
|
|
626
|
+
|
|
627
|
+
### @verdeect/kit/http
|
|
628
|
+
|
|
629
|
+
- `bootstrapService` — `bootstrapService({module, service, env, logger?, http?, redaction?})`: точка входа `main.ts`.
|
|
630
|
+
- `KitBootstrapOptions` — тип опций `bootstrapService`.
|
|
631
|
+
- `createHttpApp` — `createHttpApp({module, service, bodyLimit?, securityHeaders?})`: Fastify-приложение Nest сервиса.
|
|
632
|
+
- `KitHttpAppOptions` — тип опций `createHttpApp`.
|
|
633
|
+
- `kitErrors` — общие коды пакета `{status, message}`.
|
|
634
|
+
- `defineErrors` — `defineErrors({...kitErrors, …})`: словарь сервиса на процесс.
|
|
635
|
+
- `KitErrorDictionary` — тип словаря `код → {status, message}`.
|
|
636
|
+
- `KitErrorEntry` — тип записи словаря.
|
|
637
|
+
- `DomainError` — `new DomainError(code, details?, {cause}?)`: ошибка домена со статусом из словаря, `toBody()`.
|
|
638
|
+
- `KitExceptionFilter` — фильтр ошибок (регистрирует `createHttpApp`; BFF собирает свой на `DomainError`).
|
|
639
|
+
- `ZodValidationPipe` — `new ZodValidationPipe(schema)`: 422 `VALIDATION_FAILED`.
|
|
640
|
+
- `PageQuerySchema` — `{cursor?, limit}` (умолчание 100, максимум 200).
|
|
641
|
+
- `Page` — тип ответа `{items, nextCursor}`.
|
|
642
|
+
- `pageOf` — `pageOf(rows, limit, keyOf)`: страница из `limit + 1` строк.
|
|
643
|
+
- `encodeCursor` — курсор base64url от JSON ключей.
|
|
644
|
+
- `decodeCursor` — `decodeCursor(cursor, arity)`: ключи или `null`; неверный — 422 `path: cursor`.
|
|
645
|
+
- `afterCursor` — `afterCursor(cols, cursor, dir)`: условие Drizzle «после курсора».
|
|
646
|
+
- `Api` — `@Api({summary, response?, envelope?, errors?, extra?})`: маршрут в OpenAPI.
|
|
647
|
+
- `KitApiOptions` — тип опций `@Api`.
|
|
648
|
+
|
|
649
|
+
CLI: `verdeect-kit openapi --out <файл> [--module <файл>#<экспорт>]` — результат `{written, operations}` в stdout; нет файла, экспорта или `version` в `package.json` — JSON `{error: {code}}` и выход 1.
|
|
650
|
+
|
|
651
|
+
### @verdeect/kit/database
|
|
652
|
+
|
|
653
|
+
- `KitDatabaseModule` — `forRoot({schema})`: пулы, `Db`, `DRIZZLE`, `DRIZZLE_READ`, пробы `database`/`database-read`, закрытие пулов на шаге 4.
|
|
654
|
+
- `KitDatabaseModuleOptions` — тип опций `KitDatabaseModule.forRoot`.
|
|
655
|
+
- `DRIZZLE` — токен Drizzle основного пула со схемой сервиса.
|
|
656
|
+
- `DRIZZLE_READ` — токен Drizzle пула чтения со схемой сервиса.
|
|
657
|
+
- `Db` — `transaction(ctx, fn)`, `read(ctx, fn)`, `readGlobal(fn)`, `afterCommit(tx, task)`, `setLocal(tx, name, value)`.
|
|
658
|
+
- `DbContext` — `{tenant, traceId, actor: {clientId?, accountId?, memberId?, onBehalfOf?, channel}}`.
|
|
659
|
+
- `DbTx` — тип транзакции `Db.transaction`/`Db.read`.
|
|
660
|
+
- `DbReader` — тип дескриптора `Db.readGlobal`.
|
|
661
|
+
- `AfterCommitTask` — тип задачи `() => void | Promise<void>`.
|
|
662
|
+
- `TenantRequiredError` — `Db.read` без тенанта.
|
|
663
|
+
- `AfterCommitInReadOnlyError` — `afterCommit` в `read`/`readGlobal`.
|
|
664
|
+
- `AfterCommitOutsideTransactionError` — `afterCommit` с транзакцией не этого `Db` или вложенной.
|
|
665
|
+
- `tenantColumn` — `tenantColumn(name = 'tenant')`: колонка тенанта `text not null`.
|
|
666
|
+
- `tenantPolicy` — `tenantPolicy(column)`: строгая политика `tenant_isolation`.
|
|
667
|
+
- `prepareDatabase` — `prepareDatabase({service, adminUrl, ownerPassword, appPassword}, logger?)`: роли и схема сервиса.
|
|
668
|
+
- `KitPrepareDatabaseOptions` — тип опций `prepareDatabase`.
|
|
669
|
+
- `KitPrepareDatabaseResult` — `{database, roles, created}`.
|
|
670
|
+
- `runMigrations` — `runMigrations({url, folder, schema}, logger?)`: `{applied}`, журнал `<schema>.__migrations`.
|
|
671
|
+
- `KitRunMigrationsOptions` — тип опций `runMigrations`.
|
|
672
|
+
- `deleteInBatches` — `deleteInBatches({db, sql, batchSize, maxBatches, shouldContinue?, connection?})`: `{deleted, batches, complete}`.
|
|
673
|
+
- `KitDeleteInBatchesOptions` — тип опций `deleteInBatches`.
|
|
674
|
+
- `KitDeleteInBatchesResult` — тип итога `deleteInBatches`.
|
|
675
|
+
- `runExclusive` — `runExclusive(db, key, (connection) => …)`: `{ran: true, result}` или `{ran: false}`; `fn` получает соединение блокировки.
|
|
676
|
+
- `occurredAtColumn` — `occurredAtColumn(name)`: `timestamptz` nullable.
|
|
677
|
+
- `ifNewer` — `ifNewer(column, occurredAt)`: условие «событие новее».
|
|
678
|
+
- `newId` — `newId(prefix)`: `<prefix>_<ulid>`, префикс `^[a-z]{2,5}$`.
|
|
679
|
+
- `ulid` — `ulid(now?)`: 26 знаков Crockford.
|
|
680
|
+
- `isUniqueViolation` — `isUniqueViolation(err, constraint?)`: 23505 в ошибке или `cause`.
|
|
681
|
+
- `isConnectionError` — обрыв соединения с PostgreSQL по форме.
|
|
682
|
+
- `isPoolTimeout` — срок ожидания клиента пула.
|
|
683
|
+
|
|
684
|
+
CLI: `verdeect-kit db prepare --service <svc>`, `db migrate --folder <путь> --schema <svc>`, `db move-journal --from <схема> --to <svc>` — см. «База данных».
|
|
685
|
+
|
|
686
|
+
### @verdeect/kit/idempotency
|
|
687
|
+
|
|
688
|
+
- `KitIdempotencyModule` — `forRoot({table})`: интерсептор `@Idempotent`, проверка функции очистки при старте, очистка раз в час.
|
|
689
|
+
- `KitIdempotencyModuleOptions` — тип опций `KitIdempotencyModule.forRoot`.
|
|
690
|
+
- `Idempotent` — `@Idempotent({required?, tenantFromBody?})`: маршрут принимает `Idempotency-Key`.
|
|
691
|
+
- `KitIdempotentOptions` — тип опций `@Idempotent`.
|
|
692
|
+
- `createIdempotencyTable` — `createIdempotencyTable(schema)`: таблица `idempotency_key` схемы сервиса.
|
|
693
|
+
- `KitIdempotencyTable` — тип таблицы идемпотентности.
|
|
694
|
+
- `idempotencyPurgeSql` — `idempotencyPurgeSql(schema)`: текст SQL функции очистки для миграции сервиса.
|
|
695
|
+
|
|
696
|
+
### @verdeect/kit/messaging
|
|
697
|
+
|
|
698
|
+
- `defineEvent` — `defineEvent({type, version, subject, tenant?, data})`: определение события с признаком тенанта.
|
|
699
|
+
- `KitEvent` — тип определения события.
|
|
700
|
+
- `KitEventTenant` — `'required' | 'none'`.
|
|
701
|
+
- `EnvelopeSchema` — схема zod конверта `01` §7.1.
|
|
702
|
+
- `Envelope` — `Envelope<E>`: конверт события, `tenant` по признаку, `data` — выход схемы.
|
|
703
|
+
- `createOutboxTable` — `createOutboxTable(schema, {tenantNullable?})`: таблица outbox.
|
|
704
|
+
- `createInboxTable` — `createInboxTable(schema)`: таблица inbox.
|
|
705
|
+
- `KitOutboxTable` — тип таблицы outbox.
|
|
706
|
+
- `KitInboxTable` — тип таблицы inbox.
|
|
707
|
+
- `KitOutboxTableOptions` — `{tenantNullable?}`.
|
|
708
|
+
- `KitMessagingModule` — `KitMessagingModule.forRoot({service, outbox?, subscriber?})`: соединение, relay, подписчик, очистка.
|
|
709
|
+
- `KitMessagingModuleOptions` — тип опций модуля.
|
|
710
|
+
- `OutboxStore` — `add(tx, Event, {subjectId, data})`: запись события в транзакции.
|
|
711
|
+
- `KitSubscriber` — `on(Event, handler)`, `onReplica(Event, handler)`.
|
|
712
|
+
- `KitEventHandler` — `(event, tx) => Promise<void>`; `tx = null` при `dedupe: 'none'`.
|
|
713
|
+
- `KitReplicaHandler` — `(event) => Promise<void>`.
|
|
714
|
+
- `ExpectedMessageError` — ожидаемый отказ: повтор, затем DLQ с кодом, без GlitchTip.
|
|
715
|
+
- `InvalidMessageError` — сообщение не обработать никогда: сразу DLQ с причиной.
|
|
716
|
+
- `MessagingStartupError` — отказ запуска `/messaging` с кодом `MESSAGING_TENANT_NOT_NULLABLE` или `MESSAGING_RETRY_TTL_MISMATCH` (`startupCode`, `startupDetails()`).
|
|
717
|
+
|
|
718
|
+
### @verdeect/kit/crypto
|
|
719
|
+
|
|
720
|
+
- `createCipher` — `createCipher({service, keys, purpose})`: шифр назначения; неверные `service`, `purpose` или `keys` — `TypeError` с путём без значений.
|
|
721
|
+
- `KitCipher` — `encrypt(plaintext, {aad})`, `decrypt(value, {aad})`, `needsReencrypt(value)`.
|
|
722
|
+
- `KitCipherOptions` — `{service, keys: [{version, secret}], purpose}`.
|
|
723
|
+
- `DecryptError` — отказ `decrypt` с `reason`, без открытого текста и ключей.
|
|
724
|
+
- `DecryptErrorReason` — `'format' | 'unknown_version' | 'auth'`.
|
|
725
|
+
- `generateSecret` — `generateSecret(bytes = 32)`: секрет base64url без `=`; `bytes` не целое или < 32 — `RangeError`.
|
|
726
|
+
|
|
727
|
+
CLI: `verdeect-kit crypto generate-secret [--bytes 32]` → `{"secret":"…"}` в stdout; `--bytes` — целое ≥ 32, иначе `{"error": {"code": "USAGE"}}` и выход 1.
|
|
728
|
+
|
|
729
|
+
### @verdeect/kit/redis
|
|
730
|
+
|
|
731
|
+
- `KitRedisModule` — `KitRedisModule.forRoot({service, subscriber?: true})`: соединение `<svc>:`, каналы, лимитер, проба `redis`, закрытие на шаге 4; глобальный.
|
|
732
|
+
- `KitRedisModuleOptions` — `{service, subscriber?: true}`.
|
|
733
|
+
- `KIT_REDIS` — токен `Redis` ioredis основного соединения с `keyPrefix <svc>:`.
|
|
734
|
+
- `KitRedisChannels` — `publish(channel, message)`, `subscribe(channel, handler)` → отписка; каналы `<svc>:<channel>`.
|
|
735
|
+
- `RateLimiter` — `hit(key, opts)`, `peek(key, opts)`: фиксированное окно, fail-open с метрикой.
|
|
736
|
+
- `RateLimitOptions` — `{limit, windowMs}`, целые ≥ 1.
|
|
737
|
+
- `RateLimitResult` — `{allowed, remaining, retryAfterMs}`.
|
|
738
|
+
- `throwRateLimited` — `throwRateLimited(result)`: 429 `RATE_LIMITED` + `Retry-After` в секундах вверх.
|
|
739
|
+
|
|
740
|
+
CLI: `verdeect-kit redis acl --services <svc,…> --out <файл>` → aclfile и `{"out", "services"}` в stdout; ошибки — `USAGE` (в том числе без `--out`), `INVALID_ENVIRONMENT` с `keys`, выход 1.
|
|
741
|
+
|
|
742
|
+
### @verdeect/kit/testing
|
|
743
|
+
|
|
744
|
+
- `createTestApp` — `createTestApp({module, service, overrides?, bodyLimit?})`: приложение сервиса для тестов.
|
|
745
|
+
- `KitTestAppOptions` — тип опций `createTestApp`.
|
|
746
|
+
- `KitTestOverride` — подмена провайдера `{provide, useValue}`.
|
|
747
|
+
- `expectProblem` — `expectProblem(res, {status, code, issues?})`: утверждение об ответе-ошибке.
|
|
748
|
+
- `KitExpectedProblem` — тип ожидаемой ошибки.
|
|
749
|
+
- `KitTestResponse` — тип ответа `{statusCode, json()}`.
|
|
750
|
+
- `expectMatchesApi` — `expectMatchesApi(res, [Controller, 'method'])`: ответ по схеме `@Api`.
|
|
751
|
+
- `TEST_IMAGES` — образы тестов и compose сервисов.
|
|
752
|
+
- `startTestStack` — `globalSetup` Vitest: контейнеры раз на прогон, адреса через `provide`; флаги `postgres`, `migrations`, `mailpit`, `minio`, `rabbitmq`, `redis?`, `outboxEvents`.
|
|
753
|
+
- `assertRepoConventions` — проверка репозитория сервиса по правилам выше.
|
|
754
|
+
- `tcpProxy` — запущенный TCP-прокси к цели.
|
|
755
|
+
- `TcpProxy` — тип прокси: `url`, `break`, `restore`, `blackhole`, `freeze`, `blackholeOn`, `delayOn`, `close`.
|
|
756
|
+
- `timingGuard` — `globalSetup` уровня замеров времени.
|
|
757
|
+
- `TEST_TIMEOUT_SCALE` — множитель тайм-аутов из `TEST_TIMEOUT_SCALE`, по умолчанию 1.
|
|
758
|
+
- `createScratchDatabase` — пустая мигрированная БД из шаблона `startTestStack`.
|
|
759
|
+
- `KitScratchDatabase` — `{name, url, ownerUrl, adminUrl, drop()}`.
|
|
760
|
+
- `workerScope` — `workerScope()`: область воркера Vitest.
|
|
761
|
+
- `KitWorkerScope` — `{tenant, rabbitmq, redisPrefix}`: слаг компании, vhost и префикс Redis воркера.
|
|
762
|
+
- `redisWorkerOverride` — `redisWorkerOverride()`: подмена префикса `KitRedisModule` префиксом воркера для `createTestApp({overrides})`.
|
|
763
|
+
- `countQueries` — `countQueries(pool, fn, {onQuery?})`: `{result, count, queries}`.
|
|
764
|
+
- `KitCountablePool` — тип пула с событием `acquire`.
|
|
765
|
+
- `KitCountQueriesOptions` — `{onQuery?}`: колбэк на каждый запрос пула.
|
|
766
|
+
- `assertStrictTenantIsolation` — `assertStrictTenantIsolation(db, {schema, except?})`: строгий RLS схемы под ролью приложения.
|
|
767
|
+
- `KitTenantIsolationOptions` — тип опций `assertStrictTenantIsolation`.
|
|
768
|
+
- `assertAppRoleIsNotOwner` — `assertAppRoleIsNotOwner(db)`: роль приложения не владелец.
|
|
769
|
+
- `KitQueryable` — тип соединения с `query` (`pg.Pool`, `pg.Client`).
|
|
770
|
+
- `expectOutbox` — `expectOutbox(tx, Event, {subjectId?, data?})`: строка события в текущей транзакции; успешная проверка пишет покрытие.
|
|
771
|
+
- `assertOutboxCoverage` — `assertOutboxCoverage(events)`: события без успешного `expectOutbox` (teardown `startTestStack` при `KIT_OUTBOX_COVERAGE=1`).
|
|
772
|
+
- `publishTestEvent` — `publishTestEvent(Event, data, opts?)`: конверт `01` §7.1 в `verdeect.events` vhost воркера.
|
|
773
|
+
- `waitForInbox` — `waitForInbox(id, {timeoutMs})`: ожидание строки `<svc>.inbox` без `sleep`.
|
|
774
|
+
|
|
775
|
+
## Документация
|
|
776
|
+
|
|
777
|
+
- [docs/testing.md](docs/testing.md) — уровни тестов пакета и как их запускать.
|
|
778
|
+
- [docs/releasing.md](docs/releasing.md) — выпуск версии.
|
|
779
|
+
- [CHANGELOG.md](CHANGELOG.md) — изменения по версиям.
|
|
780
|
+
|
|
781
|
+
## Выпуск
|
|
782
|
+
|
|
783
|
+
Выпусков по этапам нет: E1–E8 копятся в `[Unreleased]`. После E8 — `1.0.0-rc.1` вручную мейнтейнером с меткой `next`, затем `npm trust`; дальше `1.0.0-rc.N` (после E8) — тегом `v1.0.0-rc.N` через CI с меткой `next`; `1.0.0` — после перехода Identity, тегом `v1.0.0` (метка `latest`). Публикует CI через npm Trusted Publishing. Подробно — [docs/releasing.md](docs/releasing.md).
|
|
784
|
+
|
|
785
|
+
## Разработка
|
|
786
|
+
|
|
787
|
+
```bash
|
|
788
|
+
make install
|
|
789
|
+
make help # все цели
|
|
790
|
+
make lint # ESLint, Prettier, правила lint AC-9.4
|
|
791
|
+
make analyse # tsc --noEmit, политика зависимостей
|
|
792
|
+
make test # U: без сети и Docker; make test FILTER='AC-1.8'
|
|
793
|
+
make test-module M='config testing' # U + I подпутей
|
|
794
|
+
make test-integration # I: Testcontainers
|
|
795
|
+
make test-timing # T: только на тихой машине
|
|
796
|
+
make pack-check # сборка, архив, проекты-потребители, publish.mjs
|
|
797
|
+
make ci # конвейер CI
|
|
798
|
+
make test-full # перед слиянием и выпуском
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
Для `test-integration`, `test-timing`, `pack-check`, `ci` и `test-full` нужен Docker. Цели вызывают скрипты `pnpm` из `package.json`.
|
|
802
|
+
|
|
803
|
+
## Лицензия
|
|
804
|
+
|
|
805
|
+
UNLICENSED.
|