@application-kit/kafka 0.2.0 → 0.3.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 +8 -0
- package/README.md +17 -0
- 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/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,13 @@
|
|
|
1
1
|
# @application-kit/kafka
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e9411eb: Спеки-гейты продукта переехали в пакеты: спека теперь — вызов помощника с параметрами продукта, а нарушение приходит ошибкой со списком.
|
|
8
|
+
|
|
9
|
+
`ui/testing`: `assertTokensDeclared`. `kafka/testing` (новый вход): `assertConsumerTopicsConsistent`. `authorization/testing` (новый вход): `assertAbilityRegistryConsistent`. `nestjs-vite/static-assets-gate` (новый вход, отдельно от `/testing`, чтобы спеки с фейком рендерера не грузили `vite`): `assertStaticAssetsLayout`. `lint-config` разрешает импорт нового входа `nestjs-vite/static-assets-gate`.
|
|
10
|
+
|
|
3
11
|
## 0.2.0
|
|
4
12
|
|
|
5
13
|
### 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,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,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.3.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"
|