@application-kit/kafka 0.2.0 → 0.4.0
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 +16 -0
- package/README.md +17 -0
- package/dist/cjs/event-contract/debezium-header-text.d.ts +1 -0
- package/dist/cjs/event-contract/debezium-header-text.js +9 -0
- package/dist/cjs/event-contract/event-contract-version.js +2 -5
- package/dist/cjs/event-contract/index.d.ts +1 -0
- package/dist/cjs/event-contract/index.js +1 -0
- package/dist/cjs/libs/kafka-message-context.js +2 -7
- package/dist/cjs/testing/assert-consumer-topics-consistent.d.ts +13 -0
- package/dist/cjs/testing/assert-consumer-topics-consistent.js +95 -0
- package/dist/cjs/testing/index.d.ts +1 -0
- package/dist/cjs/testing/index.js +17 -0
- package/dist/esm/event-contract/debezium-header-text.d.ts +1 -0
- package/dist/esm/event-contract/debezium-header-text.js +5 -0
- package/dist/esm/event-contract/event-contract-version.js +2 -5
- package/dist/esm/event-contract/index.d.ts +1 -0
- package/dist/esm/event-contract/index.js +1 -0
- package/dist/esm/libs/kafka-message-context.js +2 -7
- package/dist/esm/testing/assert-consumer-topics-consistent.d.ts +13 -0
- package/dist/esm/testing/assert-consumer-topics-consistent.js +88 -0
- package/dist/esm/testing/index.d.ts +1 -0
- package/dist/esm/testing/index.js +1 -0
- package/package.json +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @application-kit/kafka
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bedf9ac: `eventIdOf` и `eventVersionOfHeaders` читают заголовок Debezium в его настоящем формате `{"schema":…,"payload":…}`: `KafkaParser` превращает такой заголовок в объект, и идентификатор события был `null`, а версия контракта всегда `1`. Новый экспорт `debeziumHeaderText` достаёт текст такого заголовка.
|
|
8
|
+
|
|
9
|
+
После обновления проверка версии контракта начинает работать: сообщение чужой версии уходит в DLQ, а сообщение v2 в топике `.v2` принимается. Журнал аудита начинает получать `event_id`, записи без него остаются как есть.
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- e9411eb: Спеки-гейты продукта переехали в пакеты: спека теперь — вызов помощника с параметрами продукта, а нарушение приходит ошибкой со списком.
|
|
16
|
+
|
|
17
|
+
`ui/testing`: `assertTokensDeclared`. `kafka/testing` (новый вход): `assertConsumerTopicsConsistent`. `authorization/testing` (новый вход): `assertAbilityRegistryConsistent`. `nestjs-vite/static-assets-gate` (новый вход, отдельно от `/testing`, чтобы спеки с фейком рендерера не грузили `vite`): `assertStaticAssetsLayout`. `lint-config` разрешает импорт нового входа `nestjs-vite/static-assets-gate`.
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -101,3 +101,20 @@ export class UserConsumer extends ConsumerAbstract {
|
|
|
101
101
|
Правила версии живут и отдельным входом `@application-kit/kafka/event-contract` — без `kafkajs`,
|
|
102
102
|
`@nestjs/microservices` и побочных эффектов. Его берёт производитель события (web-процесс, outbox),
|
|
103
103
|
которому транспорт не нужен; корень пакета экспортирует то же самое.
|
|
104
|
+
|
|
105
|
+
## Гейт реестра топиков
|
|
106
|
+
|
|
107
|
+
Имя очереди живёт в манифесте модуля, статике `queueName` события, подписке консьюмера литералом и ключе
|
|
108
|
+
`kafka.eventQueues` values, и типом эти носители не связаны. `assertConsumerTopicsConsistent` из входа
|
|
109
|
+
`@application-kit/kafka/testing` читает их как текст и падает со списком расхождений:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
await assertConsumerTopicsConsistent({
|
|
113
|
+
chartValuesFile: 'ops/.helm/values-product.yaml',
|
|
114
|
+
consumerSources: '*/user-interface/consumers/*.ts',
|
|
115
|
+
eventSources: '**/domain/events/*.event.ts',
|
|
116
|
+
manifests: CONSUMER_MANIFESTS,
|
|
117
|
+
modulesDirectory: 'src/modules',
|
|
118
|
+
rootDirectory: process.cwd(),
|
|
119
|
+
})
|
|
120
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const debeziumHeaderText: (raw: unknown) => null | string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.debeziumHeaderText = void 0;
|
|
4
|
+
const debeziumHeaderText = (raw) => {
|
|
5
|
+
const value = typeof raw === 'object' && raw !== null && 'payload' in raw ? raw.payload : raw;
|
|
6
|
+
const text = Buffer.isBuffer(value) ? value.toString('utf8') : value;
|
|
7
|
+
return typeof text === 'string' && text !== '' ? text : null;
|
|
8
|
+
};
|
|
9
|
+
exports.debeziumHeaderText = debeziumHeaderText;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.versionedQueueName = exports.eventVersionOfHeaders = exports.eventVersionOfTopic = exports.EVENT_VERSION_HEADER = exports.DEFAULT_EVENT_VERSION = void 0;
|
|
4
|
+
const debezium_header_text_js_1 = require("./debezium-header-text.js");
|
|
4
5
|
exports.DEFAULT_EVENT_VERSION = '1';
|
|
5
6
|
exports.EVENT_VERSION_HEADER = 'x-event-version';
|
|
6
7
|
const VERSION_SEGMENT = /^v\d+$/u;
|
|
@@ -10,11 +11,7 @@ const eventVersionOfTopic = (topic) => {
|
|
|
10
11
|
return segment === undefined ? exports.DEFAULT_EVENT_VERSION : segment.slice(1);
|
|
11
12
|
};
|
|
12
13
|
exports.eventVersionOfTopic = eventVersionOfTopic;
|
|
13
|
-
const eventVersionOfHeaders = (headers) =>
|
|
14
|
-
const raw = headers?.[exports.EVENT_VERSION_HEADER];
|
|
15
|
-
const value = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
|
|
16
|
-
return typeof value === 'string' && value !== '' ? value : exports.DEFAULT_EVENT_VERSION;
|
|
17
|
-
};
|
|
14
|
+
const eventVersionOfHeaders = (headers) => (0, debezium_header_text_js_1.debeziumHeaderText)(headers?.[exports.EVENT_VERSION_HEADER]) ?? exports.DEFAULT_EVENT_VERSION;
|
|
18
15
|
exports.eventVersionOfHeaders = eventVersionOfHeaders;
|
|
19
16
|
const versionedQueueName = (queueName, version) => `${queueName}${versionSegment(version)}`;
|
|
20
17
|
exports.versionedQueueName = versionedQueueName;
|
|
@@ -14,4 +14,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./debezium-header-text.js"), exports);
|
|
17
18
|
__exportStar(require("./event-contract-version.js"), exports);
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.eventIdOf = void 0;
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
if (Buffer.isBuffer(raw)) {
|
|
7
|
-
return raw.toString('utf8');
|
|
8
|
-
}
|
|
9
|
-
return typeof raw === 'string' && raw !== '' ? raw : null;
|
|
10
|
-
};
|
|
4
|
+
const debezium_header_text_js_1 = require("../event-contract/debezium-header-text.js");
|
|
5
|
+
const eventIdOf = (context) => (0, debezium_header_text_js_1.debeziumHeaderText)(context.getMessage().headers?.id);
|
|
11
6
|
exports.eventIdOf = eventIdOf;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ConsumerTopicsManifestInterface {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly queues: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export interface ConsumerTopicsOptionsInterface {
|
|
6
|
+
readonly chartValuesFile: string;
|
|
7
|
+
readonly consumerSources: string;
|
|
8
|
+
readonly eventSources: string;
|
|
9
|
+
readonly manifests: readonly ConsumerTopicsManifestInterface[];
|
|
10
|
+
readonly modulesDirectory: string;
|
|
11
|
+
readonly rootDirectory: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const assertConsumerTopicsConsistent: (options: ConsumerTopicsOptionsInterface) => Promise<void>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.assertConsumerTopicsConsistent = void 0;
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const EVENT_QUEUE = /static readonly queueName\b[^\n]*'(?<queue>[^\n']+)'/u;
|
|
10
|
+
const SUBSCRIBED_QUEUE = /eventTopics\([^\n]*'(?<queue>[^\n']+)'/gu;
|
|
11
|
+
const CHART_QUEUES_BLOCK = /^ {2}eventQueues:\n(?<block>(?:(?: {4}.*)?\n)*)/mu;
|
|
12
|
+
const CHART_QUEUE = /^ {4}(?<queue>[\w\-.]+):/gmu;
|
|
13
|
+
const readSources = async (directory, pattern) => {
|
|
14
|
+
const files = await Array.fromAsync((0, promises_1.glob)(pattern, { cwd: directory }));
|
|
15
|
+
const sources = files.filter((file) => !file.endsWith('.spec.ts'));
|
|
16
|
+
return Promise.all(sources.map(async (file) => ({
|
|
17
|
+
file,
|
|
18
|
+
text: await (0, promises_1.readFile)(node_path_1.default.join(directory, file), 'utf8'),
|
|
19
|
+
})));
|
|
20
|
+
};
|
|
21
|
+
const missingFrom = (actual, expected) => {
|
|
22
|
+
const rest = [...actual];
|
|
23
|
+
return expected.filter((queue) => {
|
|
24
|
+
const index = rest.indexOf(queue);
|
|
25
|
+
if (index === -1) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
rest.splice(index, 1);
|
|
29
|
+
return false;
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const chartViolations = (values, declared) => {
|
|
33
|
+
const block = CHART_QUEUES_BLOCK.exec(values)?.groups?.block ?? '';
|
|
34
|
+
const chartQueues = [...block.matchAll(CHART_QUEUE)].map((match) => match.groups?.queue ?? '');
|
|
35
|
+
return [
|
|
36
|
+
...missingFrom(chartQueues, declared).map((queue) => `очередь ${queue} из манифеста без ключа kafka.eventQueues: автосоздание топиков выключено, консьюмер упадёт`),
|
|
37
|
+
...missingFrom(declared, chartQueues).map((queue) => `ключ kafka.eventQueues.${queue} без очереди в манифесте: топик без производителя`),
|
|
38
|
+
];
|
|
39
|
+
};
|
|
40
|
+
const queueOf = ({ text }) => EVENT_QUEUE.exec(text)?.groups?.queue;
|
|
41
|
+
const ownerOf = ({ file }) => file.split('/', 1).join('');
|
|
42
|
+
const producedQueues = (events) => {
|
|
43
|
+
const produced = new Map();
|
|
44
|
+
for (const event of events) {
|
|
45
|
+
const queue = queueOf(event);
|
|
46
|
+
if (queue !== undefined) {
|
|
47
|
+
produced.set(ownerOf(event), [...produced.get(ownerOf(event)) ?? [], queue]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return produced;
|
|
51
|
+
};
|
|
52
|
+
const ownerViolations = (events, manifests) => {
|
|
53
|
+
const produced = producedQueues(events);
|
|
54
|
+
const declared = new Map(manifests.map(({ name, queues }) => [name, queues]));
|
|
55
|
+
const owners = [...new Set([...produced.keys(), ...declared.keys()])];
|
|
56
|
+
return [
|
|
57
|
+
...events.filter((event) => queueOf(event) === undefined).map(({ file }) => `событие без queueName (${file})`),
|
|
58
|
+
...owners.flatMap((owner) => {
|
|
59
|
+
const ownQueues = produced.get(owner) ?? [];
|
|
60
|
+
const declaredQueues = declared.get(owner) ?? [];
|
|
61
|
+
return [
|
|
62
|
+
...missingFrom(declaredQueues, ownQueues).map((queue) => `модуль ${owner}: очередь ${queue} есть у события, но не в манифесте модуля`),
|
|
63
|
+
...missingFrom(ownQueues, declaredQueues).map((queue) => `модуль ${owner}: очередь ${queue} в манифесте без события модуля`),
|
|
64
|
+
];
|
|
65
|
+
}),
|
|
66
|
+
];
|
|
67
|
+
};
|
|
68
|
+
const subscriptionViolations = (consumers, declared) => consumers.flatMap(({ file, text }) => [...text.matchAll(SUBSCRIBED_QUEUE)]
|
|
69
|
+
.map((match) => match.groups?.queue ?? '')
|
|
70
|
+
.filter((queue) => !declared.includes(queue))
|
|
71
|
+
.map((queue) => `подписка на очередь ${queue} вне реестра: топик никто не производит (${file})`));
|
|
72
|
+
const assertConsumerTopicsConsistent = async (options) => {
|
|
73
|
+
const { chartValuesFile, consumerSources, eventSources, manifests, modulesDirectory, rootDirectory } = options;
|
|
74
|
+
const directory = node_path_1.default.join(rootDirectory, modulesDirectory);
|
|
75
|
+
const [values, events, consumers] = await Promise.all([
|
|
76
|
+
(0, promises_1.readFile)(node_path_1.default.join(rootDirectory, chartValuesFile), 'utf8'),
|
|
77
|
+
readSources(directory, eventSources),
|
|
78
|
+
readSources(directory, consumerSources),
|
|
79
|
+
]);
|
|
80
|
+
const declared = manifests.flatMap(({ queues }) => queues);
|
|
81
|
+
const emptyPatterns = [
|
|
82
|
+
...events.length === 0 ? [`шаблон событий ${eventSources} не нашёл файлов`] : [],
|
|
83
|
+
...consumers.length === 0 ? [`шаблон консьюмеров ${consumerSources} не нашёл файлов`] : [],
|
|
84
|
+
];
|
|
85
|
+
const violations = [
|
|
86
|
+
...emptyPatterns,
|
|
87
|
+
...chartViolations(values, declared),
|
|
88
|
+
...ownerViolations(events, manifests),
|
|
89
|
+
...subscriptionViolations(consumers, declared),
|
|
90
|
+
];
|
|
91
|
+
if (violations.length > 0) {
|
|
92
|
+
throw new Error(`Гейт реестра топиков Kafka:\n${violations.join('\n')}`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
exports.assertConsumerTopicsConsistent = assertConsumerTopicsConsistent;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './assert-consumer-topics-consistent.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./assert-consumer-topics-consistent.js"), exports);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const debeziumHeaderText: (raw: unknown) => null | string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const debeziumHeaderText = (raw) => {
|
|
2
|
+
const value = typeof raw === 'object' && raw !== null && 'payload' in raw ? raw.payload : raw;
|
|
3
|
+
const text = Buffer.isBuffer(value) ? value.toString('utf8') : value;
|
|
4
|
+
return typeof text === 'string' && text !== '' ? text : null;
|
|
5
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { debeziumHeaderText } from './debezium-header-text.js';
|
|
1
2
|
export const DEFAULT_EVENT_VERSION = '1';
|
|
2
3
|
export const EVENT_VERSION_HEADER = 'x-event-version';
|
|
3
4
|
const VERSION_SEGMENT = /^v\d+$/u;
|
|
@@ -6,9 +7,5 @@ export const eventVersionOfTopic = (topic) => {
|
|
|
6
7
|
const segment = topic.split('.').find((part) => VERSION_SEGMENT.test(part));
|
|
7
8
|
return segment === undefined ? DEFAULT_EVENT_VERSION : segment.slice(1);
|
|
8
9
|
};
|
|
9
|
-
export const eventVersionOfHeaders = (headers) =>
|
|
10
|
-
const raw = headers?.[EVENT_VERSION_HEADER];
|
|
11
|
-
const value = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
|
|
12
|
-
return typeof value === 'string' && value !== '' ? value : DEFAULT_EVENT_VERSION;
|
|
13
|
-
};
|
|
10
|
+
export const eventVersionOfHeaders = (headers) => debeziumHeaderText(headers?.[EVENT_VERSION_HEADER]) ?? DEFAULT_EVENT_VERSION;
|
|
14
11
|
export const versionedQueueName = (queueName, version) => `${queueName}${versionSegment(version)}`;
|
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
if (Buffer.isBuffer(raw)) {
|
|
4
|
-
return raw.toString('utf8');
|
|
5
|
-
}
|
|
6
|
-
return typeof raw === 'string' && raw !== '' ? raw : null;
|
|
7
|
-
};
|
|
1
|
+
import { debeziumHeaderText } from '../event-contract/debezium-header-text.js';
|
|
2
|
+
export const eventIdOf = (context) => debeziumHeaderText(context.getMessage().headers?.id);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ConsumerTopicsManifestInterface {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly queues: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export interface ConsumerTopicsOptionsInterface {
|
|
6
|
+
readonly chartValuesFile: string;
|
|
7
|
+
readonly consumerSources: string;
|
|
8
|
+
readonly eventSources: string;
|
|
9
|
+
readonly manifests: readonly ConsumerTopicsManifestInterface[];
|
|
10
|
+
readonly modulesDirectory: string;
|
|
11
|
+
readonly rootDirectory: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const assertConsumerTopicsConsistent: (options: ConsumerTopicsOptionsInterface) => Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { glob, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const EVENT_QUEUE = /static readonly queueName\b[^\n]*'(?<queue>[^\n']+)'/u;
|
|
4
|
+
const SUBSCRIBED_QUEUE = /eventTopics\([^\n]*'(?<queue>[^\n']+)'/gu;
|
|
5
|
+
const CHART_QUEUES_BLOCK = /^ {2}eventQueues:\n(?<block>(?:(?: {4}.*)?\n)*)/mu;
|
|
6
|
+
const CHART_QUEUE = /^ {4}(?<queue>[\w\-.]+):/gmu;
|
|
7
|
+
const readSources = async (directory, pattern) => {
|
|
8
|
+
const files = await Array.fromAsync(glob(pattern, { cwd: directory }));
|
|
9
|
+
const sources = files.filter((file) => !file.endsWith('.spec.ts'));
|
|
10
|
+
return Promise.all(sources.map(async (file) => ({
|
|
11
|
+
file,
|
|
12
|
+
text: await readFile(path.join(directory, file), 'utf8'),
|
|
13
|
+
})));
|
|
14
|
+
};
|
|
15
|
+
const missingFrom = (actual, expected) => {
|
|
16
|
+
const rest = [...actual];
|
|
17
|
+
return expected.filter((queue) => {
|
|
18
|
+
const index = rest.indexOf(queue);
|
|
19
|
+
if (index === -1) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
rest.splice(index, 1);
|
|
23
|
+
return false;
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const chartViolations = (values, declared) => {
|
|
27
|
+
const block = CHART_QUEUES_BLOCK.exec(values)?.groups?.block ?? '';
|
|
28
|
+
const chartQueues = [...block.matchAll(CHART_QUEUE)].map((match) => match.groups?.queue ?? '');
|
|
29
|
+
return [
|
|
30
|
+
...missingFrom(chartQueues, declared).map((queue) => `очередь ${queue} из манифеста без ключа kafka.eventQueues: автосоздание топиков выключено, консьюмер упадёт`),
|
|
31
|
+
...missingFrom(declared, chartQueues).map((queue) => `ключ kafka.eventQueues.${queue} без очереди в манифесте: топик без производителя`),
|
|
32
|
+
];
|
|
33
|
+
};
|
|
34
|
+
const queueOf = ({ text }) => EVENT_QUEUE.exec(text)?.groups?.queue;
|
|
35
|
+
const ownerOf = ({ file }) => file.split('/', 1).join('');
|
|
36
|
+
const producedQueues = (events) => {
|
|
37
|
+
const produced = new Map();
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
const queue = queueOf(event);
|
|
40
|
+
if (queue !== undefined) {
|
|
41
|
+
produced.set(ownerOf(event), [...produced.get(ownerOf(event)) ?? [], queue]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return produced;
|
|
45
|
+
};
|
|
46
|
+
const ownerViolations = (events, manifests) => {
|
|
47
|
+
const produced = producedQueues(events);
|
|
48
|
+
const declared = new Map(manifests.map(({ name, queues }) => [name, queues]));
|
|
49
|
+
const owners = [...new Set([...produced.keys(), ...declared.keys()])];
|
|
50
|
+
return [
|
|
51
|
+
...events.filter((event) => queueOf(event) === undefined).map(({ file }) => `событие без queueName (${file})`),
|
|
52
|
+
...owners.flatMap((owner) => {
|
|
53
|
+
const ownQueues = produced.get(owner) ?? [];
|
|
54
|
+
const declaredQueues = declared.get(owner) ?? [];
|
|
55
|
+
return [
|
|
56
|
+
...missingFrom(declaredQueues, ownQueues).map((queue) => `модуль ${owner}: очередь ${queue} есть у события, но не в манифесте модуля`),
|
|
57
|
+
...missingFrom(ownQueues, declaredQueues).map((queue) => `модуль ${owner}: очередь ${queue} в манифесте без события модуля`),
|
|
58
|
+
];
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
const subscriptionViolations = (consumers, declared) => consumers.flatMap(({ file, text }) => [...text.matchAll(SUBSCRIBED_QUEUE)]
|
|
63
|
+
.map((match) => match.groups?.queue ?? '')
|
|
64
|
+
.filter((queue) => !declared.includes(queue))
|
|
65
|
+
.map((queue) => `подписка на очередь ${queue} вне реестра: топик никто не производит (${file})`));
|
|
66
|
+
export const assertConsumerTopicsConsistent = async (options) => {
|
|
67
|
+
const { chartValuesFile, consumerSources, eventSources, manifests, modulesDirectory, rootDirectory } = options;
|
|
68
|
+
const directory = path.join(rootDirectory, modulesDirectory);
|
|
69
|
+
const [values, events, consumers] = await Promise.all([
|
|
70
|
+
readFile(path.join(rootDirectory, chartValuesFile), 'utf8'),
|
|
71
|
+
readSources(directory, eventSources),
|
|
72
|
+
readSources(directory, consumerSources),
|
|
73
|
+
]);
|
|
74
|
+
const declared = manifests.flatMap(({ queues }) => queues);
|
|
75
|
+
const emptyPatterns = [
|
|
76
|
+
...events.length === 0 ? [`шаблон событий ${eventSources} не нашёл файлов`] : [],
|
|
77
|
+
...consumers.length === 0 ? [`шаблон консьюмеров ${consumerSources} не нашёл файлов`] : [],
|
|
78
|
+
];
|
|
79
|
+
const violations = [
|
|
80
|
+
...emptyPatterns,
|
|
81
|
+
...chartViolations(values, declared),
|
|
82
|
+
...ownerViolations(events, manifests),
|
|
83
|
+
...subscriptionViolations(consumers, declared),
|
|
84
|
+
];
|
|
85
|
+
if (violations.length > 0) {
|
|
86
|
+
throw new Error(`Гейт реестра топиков Kafka:\n${violations.join('\n')}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './assert-consumer-topics-consistent.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './assert-consumer-topics-consistent.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@application-kit/kafka",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Kafka-транспорт для NestJS: консьюмер-процесс с heartbeat, auto-commit, retry/DLQ и контракт версии события",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"consumer",
|
|
@@ -42,7 +42,17 @@
|
|
|
42
42
|
"default": "./dist/cjs/event-contract/index.js"
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
|
-
"./package.json": "./package.json"
|
|
45
|
+
"./package.json": "./package.json",
|
|
46
|
+
"./testing": {
|
|
47
|
+
"import": {
|
|
48
|
+
"types": "./dist/esm/testing/index.d.ts",
|
|
49
|
+
"default": "./dist/esm/testing/index.js"
|
|
50
|
+
},
|
|
51
|
+
"require": {
|
|
52
|
+
"types": "./dist/cjs/testing/index.d.ts",
|
|
53
|
+
"default": "./dist/cjs/testing/index.js"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
46
56
|
},
|
|
47
57
|
"main": "./dist/cjs/index.js",
|
|
48
58
|
"module": "./dist/esm/index.js",
|
|
@@ -51,7 +61,7 @@
|
|
|
51
61
|
"dist"
|
|
52
62
|
],
|
|
53
63
|
"scripts": {
|
|
54
|
-
"build": "rm -rf ./dist && node ../../scripts/barrels.mjs create ./src && tsc --project tsconfig.build.json && tsc --project tsconfig.build.cjs.json && node ../../scripts/barrels.mjs clean ./src && node ../../scripts/mark-cjs.mjs ./dist/cjs",
|
|
64
|
+
"build": "rm -rf ./dist && node ../../scripts/barrels.mjs create ./src --entry-only=testing && tsc --project tsconfig.build.json && tsc --project tsconfig.build.cjs.json && node ../../scripts/barrels.mjs clean ./src && node ../../scripts/mark-cjs.mjs ./dist/cjs",
|
|
55
65
|
"lint:tsc": "tsc --noEmit --pretty --project .",
|
|
56
66
|
"test:mutation": "stryker run",
|
|
57
67
|
"test:unit": "vitest run"
|