@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.
- package/CHANGELOG.md +138 -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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Exchange событий; объявляет его только definitions.json, сервис проверяет пассивно. */
|
|
2
|
+
export declare const EVENTS_EXCHANGE = "verdeect.events";
|
|
3
|
+
/** Права пользователя сервиса на vhost (регулярки RabbitMQ). */
|
|
4
|
+
export interface RabbitPermissions {
|
|
5
|
+
/** Регулярка configure. */
|
|
6
|
+
readonly configure: string;
|
|
7
|
+
/** Регулярка write. */
|
|
8
|
+
readonly write: string;
|
|
9
|
+
/** Регулярка read. */
|
|
10
|
+
readonly read: string;
|
|
11
|
+
}
|
|
12
|
+
/** Права M3: свои очереди `<svc>`, `<svc>.retry`, `<svc>.dlq`, `<svc>.replica.<id>`; публикация в `verdeect.events`. */
|
|
13
|
+
export declare function permissionsOf(service: string): RabbitPermissions;
|
|
14
|
+
/** Хеш пароля `rabbit_password_hashing_sha256`: `base64(salt ‖ sha256(salt ‖ utf8(password)))`, соль 4 байта. */
|
|
15
|
+
export declare function hashRabbitPassword(password: string, salt?: Buffer): string;
|
|
16
|
+
/** Ключ окружения CLI с хешем пароля сервиса: `bff-app` → `RABBITMQ_PASSWORD_HASH_BFF_APP` (П3). */
|
|
17
|
+
export declare function passwordHashKey(service: string): string;
|
|
18
|
+
/** Пользователь брокера сервиса: имя по умолчанию — имя сервиса; права — на очереди `service`. */
|
|
19
|
+
export interface KitBrokerUser {
|
|
20
|
+
/** Имя сервиса — права на его очереди. */
|
|
21
|
+
readonly service: string;
|
|
22
|
+
/** Имя пользователя; по умолчанию — имя сервиса. */
|
|
23
|
+
readonly user?: string;
|
|
24
|
+
/** Хеш `rabbit_password_hashing_sha256`. */
|
|
25
|
+
readonly passwordHash: string;
|
|
26
|
+
}
|
|
27
|
+
/** definitions.json формата импорта management plugin RabbitMQ 4.x. */
|
|
28
|
+
export interface RabbitDefinitions {
|
|
29
|
+
/** Версия брокера формата. */
|
|
30
|
+
readonly rabbit_version: string;
|
|
31
|
+
/** vhost. */
|
|
32
|
+
readonly vhosts: readonly {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
}[];
|
|
35
|
+
/** Пользователи. */
|
|
36
|
+
readonly users: readonly {
|
|
37
|
+
/** Имя. */
|
|
38
|
+
readonly name: string;
|
|
39
|
+
/** Хеш пароля. */
|
|
40
|
+
readonly password_hash: string;
|
|
41
|
+
/** Алгоритм хеша. */
|
|
42
|
+
readonly hashing_algorithm: string;
|
|
43
|
+
/** Теги. */
|
|
44
|
+
readonly tags: readonly string[];
|
|
45
|
+
}[];
|
|
46
|
+
/** Права. */
|
|
47
|
+
readonly permissions: readonly ({
|
|
48
|
+
/** Имя пользователя; по умолчанию — имя сервиса. */
|
|
49
|
+
readonly user: string;
|
|
50
|
+
/** vhost. */
|
|
51
|
+
readonly vhost: string;
|
|
52
|
+
} & RabbitPermissions)[];
|
|
53
|
+
/** Exchange. */
|
|
54
|
+
readonly exchanges: readonly {
|
|
55
|
+
/** Имя. */
|
|
56
|
+
readonly name: string;
|
|
57
|
+
/** vhost. */
|
|
58
|
+
readonly vhost: string;
|
|
59
|
+
/** Тип. */
|
|
60
|
+
readonly type: 'topic';
|
|
61
|
+
/** Переживает перезапуск. */
|
|
62
|
+
readonly durable: true;
|
|
63
|
+
/** Без автоудаления. */
|
|
64
|
+
readonly auto_delete: false;
|
|
65
|
+
/** Не внутренний. */
|
|
66
|
+
readonly internal: false;
|
|
67
|
+
/** Аргументы. */
|
|
68
|
+
readonly arguments: Readonly<Record<string, never>>;
|
|
69
|
+
}[];
|
|
70
|
+
/** Очередей нет — их объявляет подписчик. */
|
|
71
|
+
readonly queues: readonly never[];
|
|
72
|
+
/** Привязок нет. */
|
|
73
|
+
readonly bindings: readonly never[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* definitions.json: vhost, пользователи с хешами, права M3, exchange `verdeect.events` (topic, durable). Очередей нет:
|
|
77
|
+
* топологию объявляет подписчик (TTL `.retry` не фиксируется в файле развёртывания).
|
|
78
|
+
* @throws {TypeError} имя сервиса вне формата или дубликат.
|
|
79
|
+
*/
|
|
80
|
+
export declare function buildDefinitions(input: {
|
|
81
|
+
/** vhost. */
|
|
82
|
+
readonly vhost?: string;
|
|
83
|
+
/** Пользователи. */
|
|
84
|
+
readonly users: readonly KitBrokerUser[];
|
|
85
|
+
}): RabbitDefinitions;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// definitions.json RabbitMQ (prd.md §3.4 M3, UC-12, AC-5.10): одна функция для CLI `rabbitmq definitions` и тестового
|
|
2
|
+
// стека. Эталон прав — O docs/messaging.md (or-119), P docs/deployment.md (реплика); обобщение: configure
|
|
3
|
+
// ^<svc>(\..*)?$, read — и своя DLQ (П13). Без внешних пакетов: только node:crypto.
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { assertServiceName } from './service-name.js';
|
|
6
|
+
/** Exchange событий; объявляет его только definitions.json, сервис проверяет пассивно. */
|
|
7
|
+
export const EVENTS_EXCHANGE = 'verdeect.events';
|
|
8
|
+
/** Алгоритм хеша пароля пользователя в definitions. */
|
|
9
|
+
const HASHING = 'rabbit_password_hashing_sha256';
|
|
10
|
+
/** Права M3: свои очереди `<svc>`, `<svc>.retry`, `<svc>.dlq`, `<svc>.replica.<id>`; публикация в `verdeect.events`. */
|
|
11
|
+
export function permissionsOf(service) {
|
|
12
|
+
const own = `${service.replaceAll('-', '\\-')}(\\..*)?`;
|
|
13
|
+
return {
|
|
14
|
+
configure: `^${own}$`,
|
|
15
|
+
write: `^(verdeect\\.events|amq\\.default|${own})$`,
|
|
16
|
+
read: `^(verdeect\\.events|${own})$`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Хеш пароля `rabbit_password_hashing_sha256`: `base64(salt ‖ sha256(salt ‖ utf8(password)))`, соль 4 байта. */
|
|
20
|
+
export function hashRabbitPassword(password, salt = randomBytes(4)) {
|
|
21
|
+
const digest = createHash('sha256')
|
|
22
|
+
.update(Buffer.concat([salt, Buffer.from(password, 'utf8')]))
|
|
23
|
+
.digest();
|
|
24
|
+
return Buffer.concat([salt, digest]).toString('base64');
|
|
25
|
+
}
|
|
26
|
+
/** Ключ окружения CLI с хешем пароля сервиса: `bff-app` → `RABBITMQ_PASSWORD_HASH_BFF_APP` (П3). */
|
|
27
|
+
export function passwordHashKey(service) {
|
|
28
|
+
return `RABBITMQ_PASSWORD_HASH_${service.toUpperCase().replaceAll('-', '_')}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* definitions.json: vhost, пользователи с хешами, права M3, exchange `verdeect.events` (topic, durable). Очередей нет:
|
|
32
|
+
* топологию объявляет подписчик (TTL `.retry` не фиксируется в файле развёртывания).
|
|
33
|
+
* @throws {TypeError} имя сервиса вне формата или дубликат.
|
|
34
|
+
*/
|
|
35
|
+
export function buildDefinitions(input) {
|
|
36
|
+
const vhost = input.vhost ?? '/';
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const { service } of input.users) {
|
|
39
|
+
assertServiceName(service, 'rabbitmq definitions');
|
|
40
|
+
if (seen.has(service)) {
|
|
41
|
+
throw new TypeError(`rabbitmq definitions: сервис ${service} указан дважды`);
|
|
42
|
+
}
|
|
43
|
+
seen.add(service);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
rabbit_version: '4.3.6',
|
|
47
|
+
vhosts: [{ name: vhost }],
|
|
48
|
+
users: input.users.map(({ service, user, passwordHash }) => ({
|
|
49
|
+
name: user ?? service,
|
|
50
|
+
password_hash: passwordHash,
|
|
51
|
+
hashing_algorithm: HASHING,
|
|
52
|
+
tags: [],
|
|
53
|
+
})),
|
|
54
|
+
permissions: input.users.map(({ service, user }) => ({
|
|
55
|
+
user: user ?? service,
|
|
56
|
+
vhost,
|
|
57
|
+
...permissionsOf(service),
|
|
58
|
+
})),
|
|
59
|
+
exchanges: [
|
|
60
|
+
{
|
|
61
|
+
name: EVENTS_EXCHANGE,
|
|
62
|
+
vhost,
|
|
63
|
+
type: 'topic',
|
|
64
|
+
durable: true,
|
|
65
|
+
auto_delete: false,
|
|
66
|
+
internal: false,
|
|
67
|
+
arguments: {},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
queues: [],
|
|
71
|
+
bindings: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Формат хеша пароля в aclfile: sha256, 64 hex в нижнем регистре. */
|
|
2
|
+
export declare const REDIS_PASSWORD_HASH: RegExp;
|
|
3
|
+
/** Пользователь сервиса в aclfile. */
|
|
4
|
+
export interface RedisAclUser {
|
|
5
|
+
/** Имя сервиса = имя пользователя Redis. */
|
|
6
|
+
readonly service: string;
|
|
7
|
+
/** sha256 пароля, 64 hex. */
|
|
8
|
+
readonly passwordHash: string;
|
|
9
|
+
}
|
|
10
|
+
/** Ключ окружения с хешем пароля сервиса: `REDIS_PASSWORD_HASH_<SVC>` (заглавные, `-` → `_`). */
|
|
11
|
+
export declare function redisPasswordHashKey(service: string): string;
|
|
12
|
+
/** sha256 пароля в формате aclfile. */
|
|
13
|
+
export declare function redisPasswordHash(password: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* aclfile: первая строка `user default off`, далее по строке на сервис
|
|
16
|
+
* `user <svc> on #<hash> ~<svc>:* &<svc>:* +@all -@dangerous`, затем `extra` (администратор теста); `\n` в конце.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildRedisAclFile(options: {
|
|
19
|
+
readonly users: readonly RedisAclUser[];
|
|
20
|
+
readonly extra?: readonly string[];
|
|
21
|
+
}): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// aclfile Redis (prd.md §3.7, UC-12): общий для CLI redis acl и startTestStack (как internal/rabbitmq-definitions). Пароли —
|
|
2
|
+
// sha256 hex (#<hash>), default off. Права сервиса — свои ключи и каналы <svc>:*, все команды кроме @dangerous (D3 плана
|
|
3
|
+
// E8). Без внешних пакетов: только node:crypto.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { assertServiceName } from './service-name.js';
|
|
6
|
+
/** Формат хеша пароля в aclfile: sha256, 64 hex в нижнем регистре. */
|
|
7
|
+
export const REDIS_PASSWORD_HASH = /^[0-9a-f]{64}$/;
|
|
8
|
+
/** Ключ окружения с хешем пароля сервиса: `REDIS_PASSWORD_HASH_<SVC>` (заглавные, `-` → `_`). */
|
|
9
|
+
export function redisPasswordHashKey(service) {
|
|
10
|
+
return `REDIS_PASSWORD_HASH_${service.toUpperCase().replaceAll('-', '_')}`;
|
|
11
|
+
}
|
|
12
|
+
/** sha256 пароля в формате aclfile. */
|
|
13
|
+
export function redisPasswordHash(password) {
|
|
14
|
+
return createHash('sha256').update(password, 'utf8').digest('hex');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* aclfile: первая строка `user default off`, далее по строке на сервис
|
|
18
|
+
* `user <svc> on #<hash> ~<svc>:* &<svc>:* +@all -@dangerous`, затем `extra` (администратор теста); `\n` в конце.
|
|
19
|
+
*/
|
|
20
|
+
export function buildRedisAclFile(options) {
|
|
21
|
+
const lines = ['user default off'];
|
|
22
|
+
for (const { service, passwordHash } of options.users) {
|
|
23
|
+
assertServiceName(service, 'buildRedisAclFile');
|
|
24
|
+
if (!REDIS_PASSWORD_HASH.test(passwordHash)) {
|
|
25
|
+
throw new TypeError(`buildRedisAclFile: хеш пароля «${service}» — не 64 hex`);
|
|
26
|
+
}
|
|
27
|
+
lines.push(`user ${service} on #${passwordHash} ~${service}:* &${service}:* +@all -@dangerous`);
|
|
28
|
+
}
|
|
29
|
+
return `${[...lines, ...(options.extra ?? [])].join('\n')}\n`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Имя сервиса = имя экземпляра (prd.md §3.4, §3.5, §3.6; CE-3). Одна проверка для startTestStack, bootstrapService,
|
|
2
|
+
// createCipher и модулей.
|
|
3
|
+
/** Формат имени сервиса. */
|
|
4
|
+
export const SERVICE_NAME_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
|
5
|
+
/** Бросает TypeError, если имя вне формата; значение в сообщение попадает (это не секрет). */
|
|
6
|
+
export function assertServiceName(service, where) {
|
|
7
|
+
if (!SERVICE_NAME_PATTERN.test(service)) {
|
|
8
|
+
throw new TypeError(`${where}: имя сервиса «${service}» вне формата ^[a-z][a-z0-9-]{1,31}$`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// quoteIdentifier — экранирование идентификаторов PostgreSQL без pg (KT-20 п. 1; алгоритм escapeIdentifier pg:
|
|
2
|
+
// двойные кавычки по краям, внутренняя `"` удваивается).
|
|
3
|
+
/**
|
|
4
|
+
* Идентификатор SQL в двойных кавычках: `bff-app` → `"bff-app"`, внутренняя `"` удваивается.
|
|
5
|
+
* @throws {TypeError} пустое имя или символ NUL.
|
|
6
|
+
*/
|
|
7
|
+
export function quoteIdentifier(name) {
|
|
8
|
+
if (name === '' || name.includes('\0')) {
|
|
9
|
+
throw new TypeError('quoteIdentifier: пустое имя или символ NUL');
|
|
10
|
+
}
|
|
11
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Токен `KitMetrics` экземпляра приложения. */
|
|
2
|
+
export declare const KIT_METRICS: unique symbol;
|
|
3
|
+
/** Токен логгера pino экземпляра приложения. */
|
|
4
|
+
export declare const KIT_LOGGER: unique symbol;
|
|
5
|
+
/** @internal Префикс ключей и каналов Redis сервиса (`<svc>:`; в тестах — префикс воркера). Наружу не реэкспортируется. */
|
|
6
|
+
export declare const KIT_REDIS_PREFIX: unique symbol;
|
|
7
|
+
/** @internal Подписочное соединение Redis (`KitRedisModule.forRoot({subscriber: true})`). Наружу не реэкспортируется. */
|
|
8
|
+
export declare const KIT_REDIS_SUBSCRIBER: unique symbol;
|
|
9
|
+
/** @internal Логгер фоновых операций /redis (pino экземпляра или адаптер Nest). Наружу не реэкспортируется. */
|
|
10
|
+
export declare const KIT_REDIS_LOG: unique symbol;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// DI-токены ядра HTTP (prd.md §3.5; KT-11): метрики и логгер pino экземпляра приложения; внутренние токены /redis
|
|
2
|
+
// (§3.7, E8). Классы KitLifecycle и HealthRegistry — сами себе токены. Внутренний модуль: из подпутей не реэкспортируется.
|
|
3
|
+
/** Токен `KitMetrics` экземпляра приложения. */
|
|
4
|
+
export const KIT_METRICS = Symbol('KIT_METRICS');
|
|
5
|
+
/** Токен логгера pino экземпляра приложения. */
|
|
6
|
+
export const KIT_LOGGER = Symbol('KIT_LOGGER');
|
|
7
|
+
/** @internal Префикс ключей и каналов Redis сервиса (`<svc>:`; в тестах — префикс воркера). Наружу не реэкспортируется. */
|
|
8
|
+
export const KIT_REDIS_PREFIX = Symbol('KIT_REDIS_PREFIX');
|
|
9
|
+
/** @internal Подписочное соединение Redis (`KitRedisModule.forRoot({subscriber: true})`). Наружу не реэкспортируется. */
|
|
10
|
+
export const KIT_REDIS_SUBSCRIBER = Symbol('KIT_REDIS_SUBSCRIBER');
|
|
11
|
+
/** @internal Логгер фоновых операций /redis (pino экземпляра или адаптер Nest). Наружу не реэкспортируется. */
|
|
12
|
+
export const KIT_REDIS_LOG = Symbol('KIT_REDIS_LOG');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DbContext } from '../database/tenant.js';
|
|
2
|
+
/** Связывает транзакцию `Db.transaction`/`Db.read` с её контекстом; запись живёт, пока жив `tx`. */
|
|
3
|
+
export declare function bindTxContext(tx: object, ctx: DbContext): void;
|
|
4
|
+
/** Контекст транзакции `Db`; для `readGlobal` и чужого объекта — `undefined`. */
|
|
5
|
+
export declare function txContextOf(tx: object): DbContext | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const contexts = new WeakMap();
|
|
2
|
+
/** Связывает транзакцию `Db.transaction`/`Db.read` с её контекстом; запись живёт, пока жив `tx`. */
|
|
3
|
+
export function bindTxContext(tx, ctx) {
|
|
4
|
+
contexts.set(tx, ctx);
|
|
5
|
+
}
|
|
6
|
+
/** Контекст транзакции `Db`; для `readGlobal` и чужого объекта — `undefined`. */
|
|
7
|
+
export function txContextOf(tx) {
|
|
8
|
+
return contexts.get(tx);
|
|
9
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type * as Database from '../database/index.js';
|
|
2
|
+
import type { KitMetrics } from '../observability/metrics.js';
|
|
3
|
+
import { type MessagingLog } from './rabbit-connection.js';
|
|
4
|
+
import type { KitInboxTable, KitOutboxTable } from './tables.js';
|
|
5
|
+
/** Пределы прохода. */
|
|
6
|
+
interface CleanupLimits {
|
|
7
|
+
readonly batchSize: number;
|
|
8
|
+
readonly maxBatches: number;
|
|
9
|
+
}
|
|
10
|
+
/** Зависимости очистки (внутренние). */
|
|
11
|
+
export interface MessagingCleanupDeps {
|
|
12
|
+
/** `Db` сервиса. */
|
|
13
|
+
readonly db: Database.Db;
|
|
14
|
+
/** Значения /database из динамического импорта `forRoot`. */
|
|
15
|
+
readonly database: Pick<typeof Database, 'runExclusive' | 'deleteInBatches'>;
|
|
16
|
+
/** Схема сервиса. */
|
|
17
|
+
readonly schema: string;
|
|
18
|
+
/** Заданные таблицы. */
|
|
19
|
+
readonly tables: {
|
|
20
|
+
readonly outbox?: KitOutboxTable;
|
|
21
|
+
readonly inbox?: KitInboxTable;
|
|
22
|
+
};
|
|
23
|
+
/** Метрики. */
|
|
24
|
+
readonly metrics?: KitMetrics;
|
|
25
|
+
/** Логгер. */
|
|
26
|
+
readonly log?: MessagingLog;
|
|
27
|
+
}
|
|
28
|
+
/** Очистка опубликованного outbox и inbox старше 7 дней одной репликой (внутренний класс модуля). */
|
|
29
|
+
export declare class MessagingCleanup {
|
|
30
|
+
private readonly deps;
|
|
31
|
+
private readonly log;
|
|
32
|
+
private readonly tables;
|
|
33
|
+
private stopped;
|
|
34
|
+
private interval;
|
|
35
|
+
private current;
|
|
36
|
+
private running;
|
|
37
|
+
constructor(deps: MessagingCleanupDeps);
|
|
38
|
+
/** Первый проход сразу после старта, затем раз в час. */
|
|
39
|
+
onApplicationBootstrap(): void;
|
|
40
|
+
/** Текущий проход (включая ещё не начатый первый); нет — разрешённый промис. */
|
|
41
|
+
idle(): Promise<void>;
|
|
42
|
+
/** Проход очистки; идущий проход не накладывается. Ошибка не пробрасывается. */
|
|
43
|
+
run(limits?: CleanupLimits): Promise<void>;
|
|
44
|
+
/** Шаг 3 остановки: пачка дорабатывает, новая (и новая таблица) не начинается. */
|
|
45
|
+
beforeApplicationShutdown(): Promise<void>;
|
|
46
|
+
private pass;
|
|
47
|
+
private cleanTable;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Очистка outbox и inbox (prd.md §3.4, UC-9, AC-5.11, AC-5.12, AC-9.5). Эталон — O messaging-cleanup.job.ts (ошибка одной
|
|
2
|
+
// таблицы не мешает другой); по PRD — runExclusive, пачки на соединении блокировки (E4 KT-22 п. 4), раз в час. Метрика —
|
|
3
|
+
// verdeect_cleanup_runs_total{table: outbox|inbox} (общая, E4 П8), число строк — info-лог. Расписание и итог прохода —
|
|
4
|
+
// по образцу IdempotencyCleanup. Прямой DELETE под ролью приложения с пустым тенантом: outbox — исключение политики,
|
|
5
|
+
// inbox без RLS (П15). Значения /database (runExclusive, deleteInBatches) передаёт forRoot: messaging их не импортирует.
|
|
6
|
+
import { sql } from 'drizzle-orm';
|
|
7
|
+
import { getTableConfig } from 'drizzle-orm/pg-core';
|
|
8
|
+
import { resultOf } from '../internal/cleanup-result.js';
|
|
9
|
+
import { quoteIdentifier } from '../internal/sql-identifier.js';
|
|
10
|
+
import { sanitizeError } from '../observability/sanitize-error.js';
|
|
11
|
+
import { captureError } from '../observability/sentry.js';
|
|
12
|
+
import { nestLog } from './rabbit-connection.js';
|
|
13
|
+
import { CLEANUP_BATCH_SIZE, CLEANUP_INTERVAL_MS, CLEANUP_MAX_BATCHES, RETENTION_DAYS, } from './topology.js';
|
|
14
|
+
/**
|
|
15
|
+
* Пачка удаления строк старше RETENTION_DAYS по колонке времени. `lock` — `FOR UPDATE SKIP LOCKED` (outbox: строки
|
|
16
|
+
* держит relay); inbox — без него: проход один (runExclusive), а `FOR UPDATE` потребовал бы права UPDATE у роли
|
|
17
|
+
* приложения (сервис выдаёт на inbox только SELECT, INSERT, DELETE).
|
|
18
|
+
*/
|
|
19
|
+
function batchSql(table, column, lock) {
|
|
20
|
+
const config = getTableConfig(table);
|
|
21
|
+
const name = sql.raw(`${quoteIdentifier(config.schema ?? '')}.${quoteIdentifier(config.name)}`);
|
|
22
|
+
const at = sql.raw(quoteIdentifier(column));
|
|
23
|
+
const locking = sql.raw(lock ? ' for update skip locked' : '');
|
|
24
|
+
return (batch) => sql `with d as (delete from ${name} where id in (
|
|
25
|
+
select id from ${name} where ${at} < now() - make_interval(days => ${RETENTION_DAYS})
|
|
26
|
+
order by ${at} limit ${batch}${locking}) returning 1)
|
|
27
|
+
select count(*)::int as deleted from d`;
|
|
28
|
+
}
|
|
29
|
+
/** Очистка опубликованного outbox и inbox старше 7 дней одной репликой (внутренний класс модуля). */
|
|
30
|
+
export class MessagingCleanup {
|
|
31
|
+
deps;
|
|
32
|
+
log;
|
|
33
|
+
tables;
|
|
34
|
+
stopped = false;
|
|
35
|
+
interval;
|
|
36
|
+
current;
|
|
37
|
+
running = false;
|
|
38
|
+
constructor(deps) {
|
|
39
|
+
this.deps = deps;
|
|
40
|
+
this.log = deps.log ?? nestLog('MessagingCleanup');
|
|
41
|
+
const { outbox, inbox } = deps.tables;
|
|
42
|
+
this.tables = [
|
|
43
|
+
...(outbox === undefined
|
|
44
|
+
? []
|
|
45
|
+
: [['outbox', batchSql(outbox, 'published_at', true)]]),
|
|
46
|
+
...(inbox === undefined
|
|
47
|
+
? []
|
|
48
|
+
: [['inbox', batchSql(inbox, 'received_at', false)]]),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
/** Первый проход сразу после старта, затем раз в час. */
|
|
52
|
+
onApplicationBootstrap() {
|
|
53
|
+
this.current = new Promise((resolve) => {
|
|
54
|
+
setTimeout(resolve, 0).unref();
|
|
55
|
+
}).then(() => this.run());
|
|
56
|
+
this.interval = setInterval(() => void this.run(), CLEANUP_INTERVAL_MS);
|
|
57
|
+
this.interval.unref();
|
|
58
|
+
}
|
|
59
|
+
/** Текущий проход (включая ещё не начатый первый); нет — разрешённый промис. */
|
|
60
|
+
idle() {
|
|
61
|
+
return this.current ?? Promise.resolve();
|
|
62
|
+
}
|
|
63
|
+
/** Проход очистки; идущий проход не накладывается. Ошибка не пробрасывается. */
|
|
64
|
+
run(limits = { batchSize: CLEANUP_BATCH_SIZE, maxBatches: CLEANUP_MAX_BATCHES }) {
|
|
65
|
+
if (this.stopped) {
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
68
|
+
if (this.running && this.current !== undefined) {
|
|
69
|
+
return this.current;
|
|
70
|
+
}
|
|
71
|
+
this.running = true;
|
|
72
|
+
const pass = this.pass(limits).finally(() => {
|
|
73
|
+
this.running = false;
|
|
74
|
+
});
|
|
75
|
+
this.current = pass;
|
|
76
|
+
return pass;
|
|
77
|
+
}
|
|
78
|
+
/** Шаг 3 остановки: пачка дорабатывает, новая (и новая таблица) не начинается. */
|
|
79
|
+
async beforeApplicationShutdown() {
|
|
80
|
+
this.stopped = true;
|
|
81
|
+
clearInterval(this.interval);
|
|
82
|
+
await this.current;
|
|
83
|
+
}
|
|
84
|
+
async pass(limits) {
|
|
85
|
+
const { db, database, schema } = this.deps;
|
|
86
|
+
const tables = this.tables.map(([name]) => name);
|
|
87
|
+
try {
|
|
88
|
+
const outcome = await database.runExclusive(db, `verdeect-kit:messaging-cleanup:${schema}`, async (connection) => {
|
|
89
|
+
for (const [name, sqlOf] of this.tables) {
|
|
90
|
+
if (this.stopped) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
await this.cleanTable(name, sqlOf, limits, connection);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
if (!outcome.ran) {
|
|
97
|
+
this.log.debug({ tables }, 'очистка сообщений: другая реплика');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// Блокировку взять не удалось (БД недоступна): ошибка обеих таблиц.
|
|
102
|
+
for (const table of tables) {
|
|
103
|
+
this.deps.metrics?.cleanupRuns.inc({ table, result: 'error' });
|
|
104
|
+
}
|
|
105
|
+
this.log.error({ tables, err: sanitizeError(error) }, 'очистка сообщений: ошибка');
|
|
106
|
+
captureError(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async cleanTable(table, sqlOf, limits, connection) {
|
|
110
|
+
try {
|
|
111
|
+
// Пачки — на соединении блокировки: второе соединение пула не берётся (KT-22 п. 4).
|
|
112
|
+
const { deleted, batches, complete } = await this.deps.database.deleteInBatches({
|
|
113
|
+
db: this.deps.db,
|
|
114
|
+
sql: sqlOf,
|
|
115
|
+
...limits,
|
|
116
|
+
shouldContinue: () => !this.stopped,
|
|
117
|
+
connection,
|
|
118
|
+
});
|
|
119
|
+
const result = resultOf(complete, batches, limits.maxBatches);
|
|
120
|
+
this.deps.metrics?.cleanupRuns.inc({ table, result });
|
|
121
|
+
this.log.info({ table, deleted, batches, result }, 'очистка сообщений: проход');
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.deps.metrics?.cleanupRuns.inc({ table, result: 'error' });
|
|
125
|
+
this.log.error({ table, err: sanitizeError(error) }, 'очистка сообщений: ошибка');
|
|
126
|
+
captureError(error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type * as Database from '../database/index.js';
|
|
2
|
+
import type { KitEvent } from './define-event.js';
|
|
3
|
+
import type { KitInboxTable, KitOutboxTable } from './tables.js';
|
|
4
|
+
/** Нормализованные опции модуля (внутренние). */
|
|
5
|
+
export interface KitMessagingConfig {
|
|
6
|
+
/** Имя экземпляра. */
|
|
7
|
+
readonly service: string;
|
|
8
|
+
/** Схема таблиц outbox и inbox. */
|
|
9
|
+
readonly schema: string | undefined;
|
|
10
|
+
/** Защита от повторов подписчика. */
|
|
11
|
+
readonly dedupe: 'inbox' | 'none';
|
|
12
|
+
/** Подписчик задан. */
|
|
13
|
+
readonly subscriber: boolean;
|
|
14
|
+
/** События outbox по type. */
|
|
15
|
+
readonly events: ReadonlyMap<string, KitEvent>;
|
|
16
|
+
/** Таблица outbox. */
|
|
17
|
+
readonly outbox: KitOutboxTable | undefined;
|
|
18
|
+
/** Таблица inbox. */
|
|
19
|
+
readonly inbox: KitInboxTable | undefined;
|
|
20
|
+
/** Значения /database — только при outbox или inbox. */
|
|
21
|
+
readonly database: typeof Database | undefined;
|
|
22
|
+
}
|
|
23
|
+
/** Окружение модуля (внутреннее). */
|
|
24
|
+
export interface KitMessagingRuntimeEnv {
|
|
25
|
+
/** Адрес брокера. */
|
|
26
|
+
readonly RABBITMQ_URL: string;
|
|
27
|
+
/** Пауза переподключения и TTL .retry, с. */
|
|
28
|
+
readonly RABBITMQ_RETRY_SECONDS: number;
|
|
29
|
+
/** Реплика. */
|
|
30
|
+
readonly REPLICA_ID: string;
|
|
31
|
+
/** Общий срок остановки, мс. */
|
|
32
|
+
readonly SHUTDOWN_TIMEOUT_MS: number;
|
|
33
|
+
/** Пауза перед остановкой, мс. */
|
|
34
|
+
readonly SHUTDOWN_DRAIN_MS: number;
|
|
35
|
+
}
|
|
36
|
+
/** Нормализованные опции (внутренний токен; тесты пакета). */
|
|
37
|
+
export declare const KIT_MESSAGING_OPTIONS: unique symbol;
|
|
38
|
+
/** Окружение модуля (внутренний токен). */
|
|
39
|
+
export declare const KIT_MESSAGING_ENV: unique symbol;
|
|
40
|
+
/** Соединение RabbitConnection (внутренний токен; тесты пакета). */
|
|
41
|
+
export declare const KIT_RABBIT_CONNECTION: unique symbol;
|
|
42
|
+
/** Логгер фоновых операций (внутренний токен). */
|
|
43
|
+
export declare const KIT_MESSAGING_LOG: unique symbol;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Нормализованные опции (внутренний токен; тесты пакета). */
|
|
2
|
+
export const KIT_MESSAGING_OPTIONS = Symbol('KIT_MESSAGING_OPTIONS');
|
|
3
|
+
/** Окружение модуля (внутренний токен). */
|
|
4
|
+
export const KIT_MESSAGING_ENV = Symbol('KIT_MESSAGING_ENV');
|
|
5
|
+
/** Соединение RabbitConnection (внутренний токен; тесты пакета). */
|
|
6
|
+
export const KIT_RABBIT_CONNECTION = Symbol('KIT_RABBIT_CONNECTION');
|
|
7
|
+
/** Логгер фоновых операций (внутренний токен). */
|
|
8
|
+
export const KIT_MESSAGING_LOG = Symbol('KIT_MESSAGING_LOG');
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Признак тенанта события: `'required'` — у события всегда есть компания, `'none'` — событие без компании. */
|
|
3
|
+
export type KitEventTenant = 'required' | 'none';
|
|
4
|
+
/** Событие каталога: тип, версия, вид субъекта, признак тенанта, схема `data` (zod без `.strict()`). */
|
|
5
|
+
export interface KitEvent<TType extends string = string, TData extends z.ZodObject = z.ZodObject, TTenant extends KitEventTenant = KitEventTenant> {
|
|
6
|
+
/** Тип `<service>.<entity>.<action>`. */
|
|
7
|
+
readonly type: TType;
|
|
8
|
+
/** Версия схемы `data`, целое ≥ 1. */
|
|
9
|
+
readonly version: number;
|
|
10
|
+
/** Вид субъекта (`subject.kind` конверта), например `company`. */
|
|
11
|
+
readonly subject: string;
|
|
12
|
+
/** Признак тенанта. */
|
|
13
|
+
readonly tenant: TTenant;
|
|
14
|
+
/** Схема `data`. */
|
|
15
|
+
readonly data: TData;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Определение события. `tenant` по умолчанию `'required'`. `data` — `z.object` без `.strict()`: новые поля событий
|
|
19
|
+
* добавляются без мажора. Результат заморожен.
|
|
20
|
+
* @throws {TypeError} `type` вне формата каталога, `version` не целое ≥ 1, `subject` вне `^[a-z][a-z0-9_]*$`, `tenant`
|
|
21
|
+
* вне `required | none`, `data` strict.
|
|
22
|
+
*/
|
|
23
|
+
export declare function defineEvent<TType extends string, TData extends z.ZodObject, TTenant extends KitEventTenant = 'required'>(definition: {
|
|
24
|
+
readonly type: TType;
|
|
25
|
+
readonly version: number;
|
|
26
|
+
readonly subject: string;
|
|
27
|
+
readonly tenant?: TTenant;
|
|
28
|
+
readonly data: TData;
|
|
29
|
+
}): KitEvent<TType, TData, TTenant>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// defineEvent (prd.md §3.4, AC-5.15): определение события каталога 01 §7.2 с признаком tenant. Эталона в сервисах нет —
|
|
2
|
+
// там схемы событий в @verdict/contracts без признака; формат type — каталог 01 §7.2.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const TYPE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9_-]*)+$/;
|
|
5
|
+
const MAX_TYPE = 120;
|
|
6
|
+
const SUBJECT = /^[a-z][a-z0-9_]*$/;
|
|
7
|
+
/**
|
|
8
|
+
* Определение события. `tenant` по умолчанию `'required'`. `data` — `z.object` без `.strict()`: новые поля событий
|
|
9
|
+
* добавляются без мажора. Результат заморожен.
|
|
10
|
+
* @throws {TypeError} `type` вне формата каталога, `version` не целое ≥ 1, `subject` вне `^[a-z][a-z0-9_]*$`, `tenant`
|
|
11
|
+
* вне `required | none`, `data` strict.
|
|
12
|
+
*/
|
|
13
|
+
export function defineEvent(definition) {
|
|
14
|
+
const { type, version, subject, data } = definition;
|
|
15
|
+
const fail = (reason) => {
|
|
16
|
+
throw new TypeError(`defineEvent(${type}): ${reason}`);
|
|
17
|
+
};
|
|
18
|
+
if (!TYPE.test(type) || type.length > MAX_TYPE) {
|
|
19
|
+
fail('type вне формата <service>.<entity>.<action>');
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
22
|
+
fail('version — целое ≥ 1');
|
|
23
|
+
}
|
|
24
|
+
if (!SUBJECT.test(subject)) {
|
|
25
|
+
fail('subject вне ^[a-z][a-z0-9_]*$');
|
|
26
|
+
}
|
|
27
|
+
const tenant = definition.tenant ?? 'required';
|
|
28
|
+
if (tenant !== 'required' && tenant !== 'none') {
|
|
29
|
+
fail("tenant — 'required' или 'none'");
|
|
30
|
+
}
|
|
31
|
+
if (!(data instanceof z.ZodObject)) {
|
|
32
|
+
fail('data — z.object');
|
|
33
|
+
}
|
|
34
|
+
if (data._zod.def.catchall instanceof z.ZodNever) {
|
|
35
|
+
fail('data без .strict(): новые поля событий добавляются без мажора');
|
|
36
|
+
}
|
|
37
|
+
return Object.freeze({ type, version, subject, tenant, data });
|
|
38
|
+
}
|