Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e3280967 |
73
package-lock.json
generated
73
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"compression": "^1.8.1",
|
||||
"joi": "^18.2.1",
|
||||
"pg": "^8.13.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
@@ -1035,6 +1036,54 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/formula": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
|
||||
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/pinpoint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz",
|
||||
"integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
|
||||
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -2565,6 +2614,12 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
@@ -7171,6 +7226,24 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/formula": "^3.0.2",
|
||||
"@hapi/hoek": "^11.0.7",
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@@ -21,27 +21,28 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"compression": "^1.8.1",
|
||||
"joi": "^18.2.1",
|
||||
"pg": "^8.13.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.11.10",
|
||||
"eslint": "^9.18.0",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"eslint": "^9.18.0",
|
||||
"globals": "^16.0.0"
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { envValidationSchema } from './common/env.validation';
|
||||
import { CurrentModule } from './current/current.module';
|
||||
import { DbModule } from './db/db.module';
|
||||
import { EdgeModule } from './edge/edge.module';
|
||||
@@ -9,7 +10,10 @@ import { IngestModule } from './ingest/ingest.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema: envValidationSchema,
|
||||
}),
|
||||
DbModule,
|
||||
IngestModule,
|
||||
EdgeModule,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Читает числовой env-параметр с безопасным fallback, чтобы конфиг не ломал старт сервиса.
|
||||
export function getNumberConfig(
|
||||
config: ConfigService,
|
||||
key: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
const value = config.get<string>(key);
|
||||
if (value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
// Используется для лимитов и размеров пулов, где дробные значения не имеют смысла.
|
||||
export function getIntegerConfig(
|
||||
config: ConfigService,
|
||||
key: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
return Math.floor(getNumberConfig(config, key, fallback));
|
||||
}
|
||||
30
src/common/env.validation.ts
Normal file
30
src/common/env.validation.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as Joi from 'joi';
|
||||
|
||||
export interface EnvironmentVariables {
|
||||
PORT: number;
|
||||
DATABASE_URL: string;
|
||||
CORS_ALLOWED_ORIGINS: string;
|
||||
INGEST_API_KEY: string;
|
||||
PG_POOL_MAX: number;
|
||||
INGEST_MAX_BATCH_SIZE: number;
|
||||
HISTORY_TARGET_POINTS: number;
|
||||
HISTORY_MAX_TARGET_POINTS: number;
|
||||
}
|
||||
|
||||
export const envValidationSchema = Joi.object({
|
||||
PORT: Joi.number().port().default(3100),
|
||||
|
||||
DATABASE_URL: Joi.string().required(),
|
||||
|
||||
CORS_ALLOWED_ORIGINS: Joi.string().allow('').default(''),
|
||||
|
||||
INGEST_API_KEY: Joi.string().allow('').default(''),
|
||||
|
||||
PG_POOL_MAX: Joi.number().integer().min(1).default(20),
|
||||
|
||||
INGEST_MAX_BATCH_SIZE: Joi.number().integer().min(1).default(10_000),
|
||||
|
||||
HISTORY_TARGET_POINTS: Joi.number().integer().min(100).default(2000),
|
||||
|
||||
HISTORY_MAX_TARGET_POINTS: Joi.number().integer().min(100).default(2000),
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
import { EnvironmentVariables } from './env.validation';
|
||||
|
||||
@Injectable()
|
||||
export class IngestApiKeyGuard implements CanActivate {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
constructor(private readonly config: ConfigService<EnvironmentVariables, true>) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const expectedKey = this.config.get<string>('INGEST_API_KEY');
|
||||
const expectedKey = this.config.get('INGEST_API_KEY');
|
||||
if (!expectedKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
|
||||
import { getIntegerConfig } from '../common/config-number';
|
||||
|
||||
import { EnvironmentVariables } from '../common/env.validation';
|
||||
type DatabaseHealth = {
|
||||
now: Date;
|
||||
timescaledb_installed: boolean;
|
||||
@@ -13,18 +12,13 @@ type DatabaseHealth = {
|
||||
export class DbService implements OnModuleInit, OnModuleDestroy {
|
||||
private pool!: Pool;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
constructor(private readonly config: ConfigService<EnvironmentVariables, true>) {}
|
||||
|
||||
// Инициализируем пул один раз на модуль и сразу проверяем доступность БД.
|
||||
async onModuleInit(): Promise<void> {
|
||||
const connectionString = this.config.get<string>('DATABASE_URL');
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL is required.');
|
||||
}
|
||||
|
||||
this.pool = new Pool({
|
||||
connectionString,
|
||||
max: getIntegerConfig(this.config, 'PG_POOL_MAX', 20),
|
||||
connectionString: this.config.get('DATABASE_URL'),
|
||||
max: this.config.get('PG_POOL_MAX'),
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
statement_timeout: 60_000,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getIntegerConfig } from '../common/config-number';
|
||||
import { EnvironmentVariables } from '../common/env.validation';
|
||||
import { normalizeRequiredText } from '../common/normalize-text';
|
||||
import { GetHistoryDto } from './dto/get-history.dto';
|
||||
import { chooseHistorySource } from './history-source';
|
||||
@@ -12,7 +12,7 @@ import { HistoryResponse } from './history.types';
|
||||
export class HistoryService {
|
||||
constructor(
|
||||
private readonly repository: HistoryRepository,
|
||||
private readonly config: ConfigService,
|
||||
private readonly config: ConfigService<EnvironmentVariables, true>,
|
||||
) {}
|
||||
|
||||
// Use-case для графиков: выбирает latest/raw/aggregate и возвращает единый API-формат.
|
||||
@@ -84,8 +84,8 @@ export class HistoryService {
|
||||
|
||||
// Ограничивает плотность ответа, чтобы API не возвращал больше точек, чем способен отрисовать UI.
|
||||
private getTargetPoints(requested?: number): number {
|
||||
const defaultValue = getIntegerConfig(this.config, 'HISTORY_TARGET_POINTS', 2000);
|
||||
const maxValue = getIntegerConfig(this.config, 'HISTORY_MAX_TARGET_POINTS', 2000);
|
||||
const defaultValue = this.config.get('HISTORY_TARGET_POINTS');
|
||||
const maxValue = this.config.get('HISTORY_MAX_TARGET_POINTS');
|
||||
const value = requested ?? defaultValue;
|
||||
return Math.min(Math.max(Math.floor(value), 100), maxValue);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getIntegerConfig } from '../common/config-number';
|
||||
import { EnvironmentVariables } from '../common/env.validation';
|
||||
import { normalizeRequiredText } from '../common/normalize-text';
|
||||
import { CurrentEventsService } from '../current/current-events.service';
|
||||
import { DbService } from '../db/db.service';
|
||||
import { IngestPointDto } from './dto/ingest-point.dto';
|
||||
|
||||
const DEFAULT_MAX_BATCH_SIZE = 10_000;
|
||||
|
||||
type NormalizedPoint = {
|
||||
time: Date;
|
||||
edge: string;
|
||||
@@ -33,7 +31,7 @@ type CurrentValueRow = {
|
||||
export class IngestService {
|
||||
constructor(
|
||||
private readonly db: DbService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly config: ConfigService<EnvironmentVariables, true>,
|
||||
private readonly currentEvents: CurrentEventsService,
|
||||
) {}
|
||||
|
||||
@@ -185,10 +183,7 @@ export class IngestService {
|
||||
|
||||
// Ограничивает размер одного запроса, чтобы один producer не занял память и соединение надолго.
|
||||
private assertBatchSize(size: number): void {
|
||||
const maxBatchSize = Math.max(
|
||||
getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE),
|
||||
1,
|
||||
);
|
||||
const maxBatchSize = this.config.get('INGEST_MAX_BATCH_SIZE');
|
||||
|
||||
if (size > maxBatchSize) {
|
||||
throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`);
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -1,14 +1,18 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import compression from 'compression';
|
||||
import { AppModule } from './app.module';
|
||||
import { EnvironmentVariables } from './common/env.validation';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.use(compression());
|
||||
|
||||
const corsAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
? process.env.CORS_ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
||||
const config = app.get(ConfigService<EnvironmentVariables, true>);
|
||||
const corsOrigins = config.get('CORS_ALLOWED_ORIGINS');
|
||||
const corsAllowedOrigins = corsOrigins
|
||||
? corsOrigins.split(',').map((origin: string) => origin.trim()).filter(Boolean)
|
||||
: true;
|
||||
|
||||
// CORS настраивается через env, чтобы один build работал локально и в окружениях деплоя.
|
||||
@@ -28,7 +32,7 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3100);
|
||||
await app.listen(config.get('PORT'));
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
Reference in New Issue
Block a user